exactly-once 是謊言:前端工程師終於學會「冪等」
寫了很多年前端,「冪等(idempotent)」這個詞我當然看過——HTTP 規範裡有、面試題裡有。但我從來沒有意識到它:沒有在設計系統時把它當成一個要主動守住的性質。直到我的爬蟲系統經歷了一連串資料庫事故(覆盤見系列第二篇),我才發現整個系統能自癒的地基就是這兩個字,而且回頭一看——前端其實天天在用它,只是沒人告訴我它的名字。
一個無解的問題:請求失敗時,你永遠不知道發生了什麼
先從讓冪等變得重要的根本困境說起。一個網路請求失敗(timeout、連線斷、沒有回應)時,它實際處於三種狀態之一:
- 請求根本沒送到,什麼都沒發生
- 請求執行完了,只是回應在路上丟了
- 請求執行到一半被打斷
關鍵在於:從 client 端,這三者無法區分。這不是工程沒做好,是分散式系統的理論性質。我曾經對著一個 timeout 的資料庫寫入問「所以它到底寫進去了沒?」——答案是不知道,而且永遠無法事後知道。
而失敗的唯一通用恢復手段是「再送一次」。於是問題變成:再送一次安全嗎? 如果那個操作做一次和做兩次的結果一樣——冪等——重試就是免費的;如果不是,你就在「可能漏做」和「可能重複做」之間二選一。
exactly-once 是行銷詞
順著這個推論,分散式系統的訊息傳遞只有兩種誠實的選項:
- at-most-once:失敗就算了。絕不重複,但可能遺漏。
- at-least-once:失敗就重試。絕不遺漏,但可能重複。
「exactly-once」呢?在不可靠網路上它無法單獨成立。實務上所有號稱 exactly-once 的系統,本質都是這條公式:
exactly-once = at-least-once + 冪等
重試會產生重複,冪等把重複吸收掉,最終效果看起來就像恰好一次。我的爬蟲現在就是這個公式的字面實作:資料庫請求 timeout → abort → 盲目重試 → 冪等寫入吸收一切。每天自動上演,不需要任何人思考「上次到底寫進去了沒」。
前端其實天天在用它
回頭盤點,冪等在前端無所不在,只是都藏在別人幫你做好的設計裡:
瀏覽器的重新提交警告。 HTTP 規範定義 GET/PUT/DELETE 是冪等方法,POST 不是1。你在 POST 後按重新整理時跳出的「確定要重新提交表單嗎?」對話框,存在的唯一理由就是:瀏覽器無法保證重送一個非冪等請求是安全的,只好把決定丟還給你。那個你按過幾百次的對話框,就是冪等性的 UI。
React 的 render 必須是純函式。 同樣的 props/state 必須渲染出同樣的結果——React 才敢在任何時刻重跑你的 component。StrictMode 在開發模式故意把 component 掛載兩次、effect 執行兩次,就是專門獵殺非冪等的程式碼2。整個宣告式 UI 的心智模型,建立在「重複執行渲染是安全的」之上。
送出按鈕的 disable-after-click。 這是前端在幫非冪等的後端擦屁股:怕使用者連點兩下造成重複下單。治本的做法在後端——Stripe 的付款 API 讓 client 帶一個 idempotency key,同一個 key 重送一百次也只會扣一次款3。前端的 disable 是緩解,idempotency key 才是修復。
怎麼把寫入設計成冪等:寫「狀態」,不寫「增量」
冪等不是加個 ON CONFLICT 就自動擁有的,它是資料流設計的結果。我從自己系統歸納的判準只有一條:
寫狀態(絕對值),不寫增量(相對變化)。
-- 冪等:寫入「此刻的完整狀態」,重送 N 次結果相同
INSERT INTO daily_stats (item_id, date, total)
VALUES (?, ?, ?)
ON CONFLICT (item_id, date) DO UPDATE SET total = excluded.total;
-- 非冪等:累加增量,重送一次就重複計算一次
UPDATE daily_stats SET total = total + ? WHERE item_id = ? AND date = ?;
我的爬蟲僥倖天生安全:上游 API 給的是「當日累計總額」,每次抓取都是完整狀態、每次寫入都是覆蓋——recompute-from-source + overwrite。如果上游給的是增量 tick、而我用累加的方式聚合,同一套「卡了就盲目重試」的架構會立刻變成資料污染器。
配套的第二層保險是 transaction 的原子性:把整批寫入包成單一 transaction,「執行到一半」這第三種狀態就被消滅了——結局只剩全有或全無,兩種都能被冪等重試安全覆蓋。
同一個系統,兩種失敗合約
最後一個學到的細節:冪等不是教條,是選項的成本分析。我的系統裡同時存在兩種失敗處理,而且都是對的:
- 資料寫入:at-least-once + 冪等。 資料漏了會影響後續所有分析,重複寫入被 upsert 吸收——遺漏的代價高、重複的代價零,所以拚命重試。
- 通知推播:at-most-once。 推播失敗只記 log、不重試。因為重試的代價是同一份報告轟炸使用者兩次,而漏一次推播無關痛癢——重複的代價高、遺漏的代價低,所以失敗就放手。
設計每一條寫入路徑時值得問的問題:這個操作重複執行的代價是什麼?遺漏的代價是什麼? 答案決定你站在哪一邊,以及需不需要花力氣把它做成冪等。
環境資訊
- 案例系統:Node.js v22.x + Turso(libSQL)
- 寫入模式:
INSERT ... ON CONFLICT DO UPDATE(upsert)+ 單一 transaction batch
本文撰寫時間:2026 年 7 月,技術版本可能隨時間更新,請以官方文件為準。
Reference
- Idempotent - MDN Web Docs Glossary — HTTP 冪等方法的定義;規範層級的出處為 RFC 9110 §9.2.2。 ↩
- StrictMode – React — 開發模式下重複執行 render 與 effect,以暴露不純(非冪等)的實作。 ↩
- Idempotent requests | Stripe API Reference — 以 idempotency key 讓付款這類天生一次性的操作可以安全重試。 ↩