exactly-once 是謊言:前端工程師終於學會「冪等」

寫了很多年前端,「冪等(idempotent)」這個詞我當然看過——HTTP 規範裡有、面試題裡有。但我從來沒有意識到它:沒有在設計系統時把它當成一個要主動守住的性質。直到我的爬蟲系統經歷了一連串資料庫事故(覆盤見系列第二篇),我才發現整個系統能自癒的地基就是這兩個字,而且回頭一看——前端其實天天在用它,只是沒人告訴我它的名字

一個無解的問題:請求失敗時,你永遠不知道發生了什麼

先從讓冪等變得重要的根本困境說起。一個網路請求失敗(timeout、連線斷、沒有回應)時,它實際處於三種狀態之一:

  1. 請求根本沒送到,什麼都沒發生
  2. 請求執行完了,只是回應在路上丟了
  3. 請求執行到一半被打斷

關鍵在於:從 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 就自動擁有的,它是資料流設計的結果。我從自己系統歸納的判準只有一條:

寫狀態(絕對值),不寫增量(相對變化)。

sql
-- 冪等:寫入「此刻的完整狀態」,重送 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

  1. Idempotent - MDN Web Docs Glossary — HTTP 冪等方法的定義;規範層級的出處為 RFC 9110 §9.2.2
  2. StrictMode – React — 開發模式下重複執行 render 與 effect,以暴露不純(非冪等)的實作。
  3. Idempotent requests | Stripe API Reference — 以 idempotency key 讓付款這類天生一次性的操作可以安全重試。