同一筆付款送了兩次:從 Santander 的聖誕節講冪等與重送
2021 年聖誕節,大部分人還在拆禮物,Santander 英國分行約 2,000 個企業客戶的付款被重複執行了一次。大約 75,000 筆交易、總額約 1.3 億英鎊,多出來的那一份照常進了 Barclays、HSBC、NatWest 等銀行的帳戶,收款的個人和公司帳上平白多了一筆。銀行對外只說是「scheduling issue」,細節沒有公布,後續靠英國銀行業的錯帳回收流程,和多家銀行一起把重複的款項要回來,銀行預估要花上幾天,並表示客戶沒有因此損失。
公開資訊不夠,這裡不猜它的根因。比較值得看的是事情發生之後的處境,第二筆付款一旦被對方當成一筆新的、格式正確的指令收下,當下就很難再攔住,剩下的只有事後對帳和人工追討。所以重複得在送出前或對方入帳的那一刻處理掉,過了這個點,本來一行 SQL 的事就要花好幾天跨行協調。
一般系統之間的扣款、加款、轉帳,碰到的是同一件事,金額小得多,次數卻多得多。網路會逾時,對方會重啟,worker 會被殺掉,重送躲不掉,能不能安全重送,就看雙方對「同一筆」有沒有一致的定義。下面分幾個地方講,範例用簡化過的表在 PostgreSQL 16.15 上跑過,扮演收款端,也就是持有餘額、負責入帳的那一方。
一、逾時的時候,你不知道對方做了沒有
呼叫端送出一筆扣款,等不到回應,可能是請求根本沒到,可能對方還在處理,也可能已經做完,只是回應在路上掉了,從呼叫端看出去都只是一個逾時。Two Generals’ Problem 搬到 API 上就是這個樣子,確認訊息本身也會遺失,多來回幾輪也一樣。
能做的只剩兩件事,用同一個識別碼重送,讓對方判斷是不是做過了,或者拿同一個識別碼去查狀態。兩條路的前提相同,這筆交易從第一次送出開始,就得帶著一個固定不變的編號。
這個編號要在「決定要扣款」的時候產生,寫進自己的資料庫,之後每一次重送都讀同一個值。如果重送的程式碼在送出前才產生編號,或者排程每執行一次就重新組一批付款指令、各自帶新的編號,下游的去重做得再好也認不出來,在它眼裡這就是兩筆不同的交易。
二、收款端:把 key 和扣款放在同一個 transaction
收款端的基本做法是一張交易表,用呼叫端給的交易編號當主鍵,第一次看到就執行並把結果存下來,之後看到同一個編號就回傳存好的結果:
CREATE TABLE wallets (
account_id text PRIMARY KEY,
balance numeric NOT NULL CHECK (balance >= 0)
);
CREATE TABLE wallet_tx (
scope text NOT NULL,
tx_id text NOT NULL,
op text NOT NULL,
request_hash text NOT NULL,
amount numeric,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (scope, tx_id)
);
INSERT INTO wallets VALUES ('a1', 1000);
扣款寫成一個函式,先插入交易紀錄,插入成功才動餘額:
CREATE FUNCTION debit(p_scope text, p_tx text, p_account text, p_amount numeric)
RETURNS jsonb LANGUAGE plpgsql AS $$
DECLARE
h text := md5(concat_ws('|', 'debit', p_account, p_amount));
r wallet_tx;
bal numeric;
res jsonb;
BEGIN
INSERT INTO wallet_tx (scope, tx_id, op, request_hash, amount)
VALUES (p_scope, p_tx, 'debit', h, p_amount)
ON CONFLICT DO NOTHING;
IF NOT FOUND THEN
SELECT * INTO r FROM wallet_tx WHERE scope = p_scope AND tx_id = p_tx;
IF r.request_hash = 'tombstone' THEN
RETURN r.response;
END IF;
IF r.request_hash <> h THEN
RAISE EXCEPTION 'idempotency key reused with different request: %', p_tx;
END IF;
RETURN r.response || '{"replayed": true}';
END IF;
UPDATE wallets SET balance = balance - p_amount
WHERE account_id = p_account AND balance >= p_amount
RETURNING balance INTO bal;
res := CASE WHEN bal IS NULL
THEN jsonb_build_object('status', 'INSUFFICIENT_FUNDS')
ELSE jsonb_build_object('status', 'OK', 'balance', bal) END;
UPDATE wallet_tx SET response = res WHERE scope = p_scope AND tx_id = p_tx;
RETURN res;
END $$;
同一筆送兩次:
SELECT debit('op-a', 'tx-1001', 'a1', 100);
SELECT debit('op-a', 'tx-1001', 'a1', 100);
SELECT balance FROM wallets;
{"status": "OK", "balance": 900}
{"status": "OK", "balance": 900, "replayed": true}
balance
---------
900
第二次沒有動到餘額,回傳的是第一次存下來的結果。容易漏掉的是失敗也要存,第一次因為餘額不足被拒,使用者接著儲值,重送同一筆時如果重新判斷,就會變成扣款成功,呼叫端那邊卻可能已經依照第一次的失敗取消了這筆訂單。Stripe 的 idempotency 也是這樣設計的,第一次的狀態碼和 body 不論成功失敗都存下來,連 500 也照原樣回放。
交易紀錄和餘額更新放在同一個 transaction,是這個設計能成立的前提。兩個同編號的請求同時進來時,後到的那個會在主鍵衝突上等待,直到先到的 commit 之後才拿到結果。實測讓 session A 執行扣款後停三秒才 commit,session B 在中間送同一筆,整理成時間線如下:
a_start 13:22:49 A 執行 debit,回 {"status": "OK", "balance": 900}
b_start 13:22:50 B 送同一筆,卡在 INSERT
a_commit 13:22:52
b_done 13:22:52 B 拿到 {"status": "OK", "balance": 900, "replayed": true}
餘額只扣了一次。A 如果中途失敗 rollback,交易紀錄跟著消失,B 的插入就會成功,改由 B 執行,結果一樣正確。反過來看,如果先在一個 transaction 裡寫交易紀錄、commit 之後才去扣款,中間崩潰就會留下一筆「有紀錄、沒扣錢、也沒有結果」的資料,之後的重送會被當成做過了。執行過程沒辦法放進同一個 transaction 的時候(例如中間要再呼叫外部服務),紀錄就得有 pending 狀態,重送遇到 pending 要回「處理中」,不能回成功。
三、key 裡面放了會變的東西
主鍵是 (scope, tx_id),看起來很單純,問題在 tx_id 怎麼組。常見的錯是把當下 session 或 token 一起組進去,理由通常是「同一個交易編號在不同 session 可能重複」。使用者的 token 在兩次重送之間被換掉,就會出事:
SELECT debit('op-a', 'tx-1002:sess-A', 'a1', 100);
SELECT debit('op-a', 'tx-1002:sess-B', 'a1', 100);
SELECT balance FROM wallets;
{"status": "OK", "balance": 800}
{"status": "OK", "balance": 700}
balance
---------
700
同一筆扣款扣了兩次。時間戳記、重試次數、請求 ID、簽章,這些每次送出都不一樣的欄位都不能進 key,key 描述的是業務上的這一筆交易,只能用業務上不會變的東西組成,也就是哪個呼叫方、哪個交易編號。scope 負責隔開不同呼叫方,免得甲、乙兩邊剛好用了同一個編號,它的邊界也要想清楚,切得太細(例如每個 session 一個 scope),等於把會變的東西又放了回去。
四、同一個 key,內容不一樣
呼叫端的 bug 可能讓兩筆不同的交易拿到同一個編號,例如編號產生器在重啟後從頭計數。收款端如果只看編號,第二筆會被當成重送,拿到第一筆的結果,就這樣無聲無息地消失,雙方都以為成功了。
所以除了編號,還要存一份請求內容的雜湊,重送時比對:
SELECT debit('op-a', 'tx-1001', 'a1', 500);
ERROR: idempotency key reused with different request: tx-1001
雜湊只放決定這筆交易語意的欄位,帳戶、金額、操作種類、訂單編號,不放時間戳記和簽章,理由和上一節一樣。Stripe 的文件寫的也是這個行為,同一個 key 帶著不同參數進來會直接報錯。這種錯誤要讓人看得到,它幾乎都代表呼叫端有 bug,默默回傳舊結果只會把問題藏起來。
五、退款比扣款先到
呼叫端扣款逾時,查狀態也逾時,最後決定取消這筆訂單,送出一筆退款,指名退的是剛才那筆扣款。退款順利送到了,那筆扣款卻還停在某個 proxy 的重送佇列裡,幾秒後才抵達。
如果收款端收到退款時發現原扣款不存在,就回「沒東西可退」然後結束,晚到的扣款會被當成一筆新交易正常執行。呼叫端已經把這筆訂單當成取消,使用者的錢卻被扣走了。
做法是退款時一併佔住原扣款的編號,留一筆 tombstone:
-- 原交易還沒到:先佔住它的 key,之後遲到的 debit 只會拿到 CANCELLED
INSERT INTO wallet_tx (scope, tx_id, op, request_hash, response)
VALUES (p_scope, p_ref_tx, 'debit', 'tombstone', '{"status": "CANCELLED"}')
ON CONFLICT DO NOTHING;
完整的 refund 函式也是先以退款自己的編號做冪等,再處理原扣款:tombstone 插入成功就代表原扣款還沒來過;插入失敗代表原扣款已經存在,鎖住那一列,狀態是 OK 才加回金額並標成 REFUNDED。實測:
SELECT refund('op-a', 'rf-3001', 'tx-3001', 'a1');
SELECT debit('op-a', 'tx-3001', 'a1', 100);
SELECT balance FROM wallets;
{"status": "OK", "refunded": 0}
{"status": "CANCELLED"}
balance
---------
1000
遲到的扣款拿到 CANCELLED,餘額沒動。正常順序也跑一次,扣款、退款、退款重送,再換一個退款編號退同一筆:
debit tx-3002 {"status": "OK", "balance": 900}
refund rf-3002 {"status": "OK", "balance": 1000, "refunded": 100}
refund rf-3002(重送) {"status": "OK", "balance": 1000, "refunded": 100, "replayed": true}
refund rf-3002b {"status": "OK", "refunded": 0}
退款本身也會重送,所以它要有自己的編號,走同一套冪等;換了編號的第二次退款,靠原扣款已經是 REFUNDED 擋下來。tombstone 和扣款搶的是同一個主鍵,兩者同時到達也只有一方能寫入,不需要額外的鎖。
結算和退款撞在一起也是類似的情況,只是狀態更多,一筆交易已經結算就不能再退,已經退了就不能再結算。這些規則要寫成狀態轉移,在收款端用同一列的狀態判斷,不要靠呼叫端「應該不會這樣送」。
六、防重放和冪等是兩層
對外的 API 通常還有一層簽章,用時間戳記加 nonce 防止別人把截到的請求原封不動再送一次。這一層看到相同的 nonce 會直接拒絕,和冪等層的「看到相同的編號就回傳舊結果」剛好相反,兩層疊在一起,順序和範圍要想清楚。
呼叫端合法的重送應該重新簽章,帶新的時間戳記和 nonce,通過防重放那一層之後,再由冪等層靠交易編號認出它是同一筆。如果呼叫端重送時沿用舊簽章,會在第一層就被當成重放擋掉,看起來像是對方拒絕交易。nonce 的去重範圍也要包含 API 路徑,不然同一秒內內容相同、打到不同端點的兩個請求(例如查詢和扣款),會被誤判成重放。
七、key 要留多久
冪等紀錄一旦刪掉,同一個編號再進來就會被當成新交易。Stripe 的 key 至少保留 24 小時,之後才可能被清掉。
保留期限要比所有重送路徑加起來都長:呼叫端 worker 的最長重試時間、佇列裡可能積壓的時間、人工補送的作業時間。帳務場景通常直接永久保留,交易紀錄本來就是帳的一部分,像本文這樣讓冪等表和交易明細共用一張表,要考慮的就只剩儲存成本。
補充筆記
- 逾時代表結果未知,可能沒到、還在做、或做完了回應掉了;唯一的出路是用同一個編號重送或查詢。
- 交易編號在決定扣款時產生並落地,重送一律讀同一個值;送出前才產生,下游就認不出重複。
- 收款端把交易紀錄和餘額變動放在同一個 transaction,靠主鍵衝突讓同編號的併發請求排隊。
- 失敗結果也要存,餘額不足的那一筆重送時要回同樣的失敗。
- key 裡不能放 session、token、時間戳記、簽章這類每次會變的欄位。
- 同 key 不同內容要報錯,比對的雜湊只放決定交易語意的欄位。
- 退款先到時留 tombstone 佔住原編號,遲到的扣款拿到取消;退款自己也要有編號、走冪等。
- 防重放擋相同 nonce,冪等認相同編號;重送要重新簽章,nonce 範圍要含路徑。
- key 的保留期限要長過所有重送路徑;帳務場景通常直接永久保留。
延伸閱讀
- NBC News〈Bank accidentally deposits $176 million into people’s accounts on Christmas Day〉與 Fortune〈Santander accidentally sent $175 million in payments on Christmas Day〉:開頭那件事的新聞報導,數字與銀行聲明出自這兩篇,根因細節銀行沒有公開。
- Stripe 文件〈Idempotent requests〉:結果不論成敗都存、
500照樣回放、參數不同報錯、key 至少保留 24 小時,這幾條行為的原文。 - Stripe Engineering〈Designing robust and predictable APIs with idempotency〉:從網路失敗的幾種情況講起,說明為什麼逾時之後要靠 idempotency key 才能安全重送。
- Wikipedia〈Two Generals’ Problem〉:確認訊息本身也會遺失,所以多送幾輪確認也無法讓雙方確定狀態一致。
- PostgreSQL 文件〈INSERT: ON CONFLICT Clause〉:
ON CONFLICT DO NOTHING的語法與限制;遇到尚未 commit 的衝突列會先等待,這點本文以兩個 session 實測。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 PostgreSQL 16.15 實測。