同一筆付款送了兩次:從 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 的保留期限要長過所有重送路徑;帳務場景通常直接永久保留。

延伸閱讀

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 PostgreSQL 16.15 實測。