具代表性的面試主題

編碼面試:實作按鍵請求合併(Singleflight)

程式題困難
OfferCC 編輯團隊發佈 更新

題幹

請實作一個具備並行安全性的非同步助手:同一個 key 的並行呼叫共享一次 task 執行及其結果或錯誤,不同 key 獨立執行;請說明逾時、取消、清理和測試。

題目與情境

請實作一個具備並行安全性的非同步助手 coalesce(key, task)。同一個 key 在同一時間只能執行一個 task,其他呼叫者等待並共享完全相同的結果或錯誤;不同 key 必須能平行執行。

約束:task 可能同步拋出錯誤,也可能非同步 reject;呼叫者可以設定自己的等待逾時;任務成功或失敗後都必須清理項目,讓下一次呼叫可以重新執行。請說明取消、錯誤傳遞與測試策略。

面試官在考察什麼

核心是把「去重」落實成可證明的並行不變量:先安裝共享 promise,再開始非同步工作;清理時確認 map 仍指向目前的 entry;每個 key 彼此獨立;失敗不會永久污染狀態。面試官也會觀察你是否區分「某個呼叫者停止等待」和「取消共享任務」,以及是否能避免記憶體洩漏。

澄清問題

  1. key 是否要求非空、是否需要正規化?我會拒絕空 key,避免無意間把所有請求合成一組。
  2. 呼叫者逾時是否應取消上游任務?預設只取消該呼叫者的等待,不影響其他等待者。
  3. 任務失敗後是否快取錯誤?本題不快取,settled 後刪除,下一次呼叫重試。
  4. 是否需要跨程序合併?本題限定單一程序記憶體;跨程序需要共享儲存或閘道協調,屬於另一層設計。

30 秒回答

我用 Map<key, Entry> 保存進行中的任務。進入函式後先查 map;命中就回傳既有 promise。未命中時建立 entry,把 promise 放入 map,再啟動 task。finally 中只有當 map 仍指向這個 entry 才刪除它。如此同 key 共用一次執行,不同 key 互不阻塞,成功和失敗都會釋放狀態。呼叫者逾時透過 Promise.race 停止等待,不取消共享任務;測試涵蓋重複呼叫、不同 key、同步拋錯、reject 後重試與清理競態。

逐步拆解

先定義 Entry,保存共享 promise 和內部控制器(若產品需要真正取消)。關鍵順序如下:

ts
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 刪掉新一輪任務。

呼叫者逾時屬於外圍策略:

ts
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、拒絕策略和指標;這些機制不能改變「只保存進行中任務」的核心語意。

公開來源

同類題目

資源

產品比較

每份比較均依官方資料整理,並標示複核日期。

查看完整比較

相關面試工具

用 Screenshot 處理演算法題

截圖題目後,依序看約束、解法、程式碼、邊界條件和複雜度。

查看工具