只按了一次儲存,請求顯示暫時失敗,自動重試後成功,清單裡卻出現兩則相同的便箋。第二則究竟是在哪裡建立的?
假設有一個每次請求建立一則便箋的小服務。這個獨立 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,避免只檢查成功回應而漏掉重複寫入。
在實際專案裡追蹤這五個位置
- 找到使用者操作,判斷哪些嘗試屬於同一次儲存意圖。
- 定位 key 的建立位置,追蹤它穿過每層重試包裝與請求參數。
- 開啟真正寫入的函式,標出哪些錯誤可能發生在寫入完成之後。
- 跟進伺服器查詢、輸入比較,以及傳回舊結果的分支。
- 把重試次數上限、最後的錯誤分支與實際儲存狀態放在一起檢查。
記下對應程式碼位置。如果包裝函式會重新執行產生 key 的回呼,就繼續讀這個回呼。需要從路由一路追到資料層時,可以接著看如何沿程式碼追蹤一個 HTTP 請求。
記憶體範例能說明什麼,還缺少什麼
這個服務把所有資料存在單一行程的 Map 和陣列裡,查詢與寫入都是同步執行。它沒有測試資料庫交易、多個 worker 並行、重新啟動或持久化復原,無法提供正式環境的 exactly-once 保證。
實際實作還需要確定 key 的作用範圍、請求內容的比較方式、保存期限、並行請求處理,以及業務寫入和結果記錄如何一起承受故障。這個用戶端在儲存結束後也會丟掉 key;使用者稍後手動重試或重新整理頁面,需要另外設計操作識別碼的生命週期。這些問題必須回到真實儲存與用戶端程式碼中確認。可以按照閱讀程式碼指南整理相關呼叫端與定義,繼續追蹤。