只按了一次儲存,請求顯示暫時失敗,自動重試後成功,清單裡卻出現兩則相同的便箋。第二則究竟是在哪裡建立的?

假設有一個每次請求建立一則便箋的小服務。這個獨立 JavaScript 範例把失敗安排在寫入之後,再用兩個用戶端呼叫同一個服務,比較重試留下的資料。

把寫入和失敗放到同一條時間線上

開啟下載的範例,先看傳輸函式,再看重試迴圈。它呼叫便箋服務、記錄傳回的 ID,隨後才拋出 ResponseLost。用戶端收到錯誤時,第一次寫入已經完成。

步驟 用戶端看到的狀態 服務裡已有的便箋
第一次請求寫入 等待回應 一則
傳輸層遺失回應 錯誤 一則
用戶端再次傳送 再次等待 取決於 key 和伺服器查詢
第二次回應到達 成功 一則或兩則

失敗的位置刻意放在寫入之後。如果移到呼叫之前,測到的就是另一種情況,重複建立的問題也會被掩蓋。範例依預設次數拋出錯誤,不依賴真實網路或計時器。

進入重試迴圈之前,為操作產生 key

兩個用戶端只有產生 key 的位置不同:

function saveUnsafe(send, title, newKey) {
  return createWithRetry(send, title, () => newKey());
}

function save(send, title, newKey) {
  const key = newKey();
  return createWithRetry(send, title, () => key);
}

saveUnsafe 的回呼每次傳送都產生新的 key。服務把兩次請求當成兩次操作,因此建立兩則便箋。save 在重試前保留一個 key,兩次傳送帶上相同的識別碼和標題。

範例使用連續編號的字串,方便比較。key 識別一次儲存意圖,不是單次網路嘗試,也不是便箋標題。兩次獨立儲存即使標題相同,也會拿到不同的 key。閱讀專案程式碼時,要檢查產生器的生命週期:變數叫「冪等鍵」,也可能在重試包裝函式裡被重新建立。

沿著 key 找到伺服器的查詢與提前傳回

穩定的 key 在這裡有效,是因為服務會在新增便箋之前查詢 completed 裡的記錄。deduplicate 預設開啟,有一個測試會把它關掉,略過這次查詢:

if (deduplicate && completed.has(key)) {
  const previous = completed.get(key);
  if (previous.title !== title)
    throw new Error('key reused with another title');
  return previous.note;
}
const note = Object.freeze({ id: `note-${notes.length + 1}`, title });
notes.push(note);
completed.set(key, { title, note });
return note;

同一個 key、同一個標題,傳回先前建立的便箋。同一個 key 換了標題,則在再次寫入之前拋出錯誤。標題是這個範例的全部請求內容,因此比較標題就涵蓋了這裡的所有輸入。

記錄同時保留輸入和結果。讀完查詢還要看提前傳回;如果繼續執行新增操作,仍會重複建立。修正測試既檢查最後的便箋數量,也檢查傳回的物件。另一個重現測試關閉伺服器去重,此時用戶端即使重用 key,仍然會建立兩則便箋。

執行失敗情境,比較最後留下的資料

下載 retry-idempotency.mjs,使用 Node.js 22 或更新版本執行,不必安裝相依套件:

node --test retry-idempotency.mjs

六個測試全部通過。兩個名稱含「reproduce」的測試,在預設缺陷確實出現時才會通過。createWithRetry 最多傳送兩次,只重試模擬的回應遺失錯誤;標題衝突在一次嘗試後就終止。

情境 傳送次數 已儲存便箋數 呼叫端結果
重試時產生新 key 2 2 第二則便箋,note-2
重用 key,伺服器重用結果 2 1 原來的便箋,note-1
兩次獨立儲存 2 2 兩個不同 ID
已有 key 改用另一個標題 額外 1 次 共 1 則 拒絕
兩次回應都遺失 2 1 第二次嘗試後報錯
重用 key,伺服器關閉去重 2 2 第二則便箋

第五列特別適合檢查錯誤處理:用戶端最後報錯,但便箋已經存在。斷言同時查看儲存資料、每次嘗試的 key 和傳回 ID,避免只檢查成功回應而漏掉重複寫入。

在實際專案裡追蹤這五個位置

  1. 找到使用者操作,判斷哪些嘗試屬於同一次儲存意圖。
  2. 定位 key 的建立位置,追蹤它穿過每層重試包裝與請求參數。
  3. 開啟真正寫入的函式,標出哪些錯誤可能發生在寫入完成之後。
  4. 跟進伺服器查詢、輸入比較,以及傳回舊結果的分支。
  5. 把重試次數上限、最後的錯誤分支與實際儲存狀態放在一起檢查。

記下對應程式碼位置。如果包裝函式會重新執行產生 key 的回呼,就繼續讀這個回呼。需要從路由一路追到資料層時,可以接著看如何沿程式碼追蹤一個 HTTP 請求。

記憶體範例能說明什麼,還缺少什麼

這個服務把所有資料存在單一行程的 Map 和陣列裡,查詢與寫入都是同步執行。它沒有測試資料庫交易、多個 worker 並行、重新啟動或持久化復原,無法提供正式環境的 exactly-once 保證。

實際實作還需要確定 key 的作用範圍、請求內容的比較方式、保存期限、並行請求處理,以及業務寫入和結果記錄如何一起承受故障。這個用戶端在儲存結束後也會丟掉 key;使用者稍後手動重試或重新整理頁面,需要另外設計操作識別碼的生命週期。這些問題必須回到真實儲存與用戶端程式碼中確認。可以按照閱讀程式碼指南整理相關呼叫端與定義,繼續追蹤。