編碼面試:實作按鍵請求合併(Singleflight)
題目與情境
請實作一個具備並行安全性的非同步助手 coalesce(key, task)。同一個 key 在同一時間只能執行一個 task,其他呼叫者等待並共享完全相同的結果或錯誤;不同 key 必須能平行執行。
約束:task 可能同步拋出錯誤,也可能非同步 reject;呼叫者可以設定自己的等待逾時;任務成功或失敗後都必須清理項目,讓下一次呼叫可以重新執行。請說明取消、錯誤傳遞與測試策略。
面試官在考察什麼
核心是把「去重」落實成可證明的並行不變量:先安裝共享 promise,再開始非同步工作;清理時確認 map 仍指向目前的 entry;每個 key 彼此獨立;失敗不會永久污染狀態。面試官也會觀察你是否區分「某個呼叫者停止等待」和「取消共享任務」,以及是否能避免記憶體洩漏。
澄清問題
- key 是否要求非空、是否需要正規化?我會拒絕空 key,避免無意間把所有請求合成一組。
- 呼叫者逾時是否應取消上游任務?預設只取消該呼叫者的等待,不影響其他等待者。
- 任務失敗後是否快取錯誤?本題不快取,settled 後刪除,下一次呼叫重試。
- 是否需要跨程序合併?本題限定單一程序記憶體;跨程序需要共享儲存或閘道協調,屬於另一層設計。
30 秒回答
我用 Map<key, Entry> 保存進行中的任務。進入函式後先查 map;命中就回傳既有 promise。未命中時建立 entry,把 promise 放入 map,再啟動 task。finally 中只有當 map 仍指向這個 entry 才刪除它。如此同 key 共用一次執行,不同 key 互不阻塞,成功和失敗都會釋放狀態。呼叫者逾時透過 Promise.race 停止等待,不取消共享任務;測試涵蓋重複呼叫、不同 key、同步拋錯、reject 後重試與清理競態。
逐步拆解
先定義 Entry,保存共享 promise 和內部控制器(若產品需要真正取消)。關鍵順序如下:
const inFlight = new Map<string, Promise<unknown>>();
function coalesce<T>(key: string, task: () => Promise<T>): Promise<T> {
if (!key) return Promise.reject(new Error("key must not be empty"));
const existing = inFlight.get(key);
if (existing) return existing as Promise<T>;
let shared: Promise<T>;
try {
shared = Promise.resolve().then(task);
} catch (error) {
shared = Promise.reject(error);
}
inFlight.set(key, shared);
shared.finally(() => {
if (inFlight.get(key) === shared) inFlight.delete(key);
}).catch(() => undefined);
return shared;
}實際實作可把 entry 物件放入 map,方便記錄開始時間、等待者數量和 AbortController。Promise.resolve().then(task) 統一同步拋錯與非同步 reject。必須在第一次 await 之前寫入 map,否則兩個事件迴圈工作都可能看到空值並重複執行。清理使用 identity check,避免舊任務的 finally 刪掉新一輪任務。
呼叫者逾時屬於外圍策略:
function waitWithTimeout<T>(shared: Promise<T>, ms: number): Promise<T> {
return Promise.race([
shared,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error("wait timeout")), ms),
),
]);
}共享任務仍會完成並服務其他等待者;若所有等待者都離開,可再設計引用計數後取消,但必須把該語意寫進契約並測試。
複雜度:map 操作平均 O(1),每個 key 只有一個進行中任務;K 個不同 key 的記憶體用量為 O(K),一次結果通知的成本與等待者數量有關。生產環境還要限制 key 基數、記錄執行時間和逾時率,避免把 coalescing map 變成無界快取。
高品質示範回答
我會先確認邊界:這是單一程序、只合併 in-flight 工作、不快取最終結果。實作上使用 Map 保存 entry,發現缺失時立即建立 promise、立即登記,再執行使用者 task。所有呼叫者拿到同一個 promise,因此值和錯誤保持一致。finally 刪除 entry 時比較引用,確保舊任務不會刪掉新一輪 entry。
取消採用「取消等待、不取消共享工作」:一個呼叫者逾時不會讓其他呼叫者收到 AbortError,也不會中斷唯一的上游任務。若業務必須取消,我會增加共享 AbortController 和等待者引用計數,只有引用歸零才取消,並明確競態規則。
測試方面,我會用 barrier 讓多個呼叫同時進入,斷言 task 只執行一次、所有呼叫收到同一個值;再測試不同 key 平行、同步 throw、非同步 reject、失敗後下一次可重試、成功後再次呼叫會新建任務、舊任務 finally 不刪除新 entry,以及單一呼叫逾時不影響其他呼叫。最後加入高基數 key 的容量和觀測指標。
常見錯誤
- 先
await task()再寫入 map,競態下會重複執行。 - finally 無條件
delete(key),舊任務可能刪除新一輪 entry。 - 把一個呼叫者的 AbortSignal 直接傳給共享任務,導致其他等待者被連帶取消。
- 失敗後不清理,後續請求永久複用 rejected promise。
- 用全域鎖保護所有 key,讓互不相關的請求串列化。
- 只測順序呼叫,不測同時到達、同步拋錯和清理競態。
追問與回答
如何支援真正的取消?
何時需要跨程序合併?
如何防止高基數 key 洩漏?
真正取消需要共享控制器、引用計數和明確的零等待者策略。跨程序場景要把 in-flight 狀態放到 Redis、閘道或專用協調器,並處理租約、主節點故障和重複執行。高基數 key 可設定容量、TTL、拒絕策略和指標;這些機制不能改變「只保存進行中任務」的核心語意。