# 同一筆付款送了兩次：從 Santander 的聖誕節講冪等與重送

> 扣款 API 逾時後只能重送，重送要安全，雙方得先講好「同一筆」怎麼認：idempotency key 何時產生、包含哪些欄位、內容不同或退款先到怎麼辦。從 Santander 重複付款講起。

原文：https://sheng.page/posts/idempotency-and-retries/ · 發布：2026-10-02 · 作者：Sheng

2021 年聖誕節，大部分人還在拆禮物，Santander 英國分行約 2,000 個企業客戶的付款被重複執行了一次。大約 75,000 筆交易、總額約 1.3 億英鎊，多出來的那一份照常進了 Barclays、HSBC、NatWest 等銀行的帳戶，收款的個人和公司帳上平白多了一筆。銀行對外只說是「scheduling issue」，細節沒有公布，後續靠英國銀行業的錯帳回收流程，和多家銀行一起把重複的款項要回來，銀行預估要花上幾天，並表示客戶沒有因此損失。

公開資訊不夠，這裡不猜它的根因。比較值得看的是事情發生之後的處境，第二筆付款一旦被對方當成一筆新的、格式正確的指令收下，當下就很難再攔住，剩下的只有事後對帳和人工追討。所以重複得在送出前或對方入帳的那一刻處理掉，過了這個點，本來一行 SQL 的事就要花好幾天跨行協調。

一般系統之間的扣款、加款、轉帳，碰到的是同一件事，金額小得多，次數卻多得多。網路會逾時，對方會重啟，worker 會被殺掉，重送躲不掉，能不能安全重送，就看雙方對「同一筆」有沒有一致的定義。下面分幾個地方講，範例用簡化過的表在 PostgreSQL 16.15 上跑過，扮演收款端，也就是持有餘額、負責入帳的那一方。

## 一、逾時的時候，你不知道對方做了沒有

呼叫端送出一筆扣款，等不到回應，可能是請求根本沒到，可能對方還在處理，也可能已經做完，只是回應在路上掉了，從呼叫端看出去都只是一個逾時。Two Generals' Problem 搬到 API 上就是這個樣子，確認訊息本身也會遺失，多來回幾輪也一樣。

能做的只剩兩件事，用同一個識別碼重送，讓對方判斷是不是做過了，或者拿同一個識別碼去查狀態。兩條路的前提相同，這筆交易從第一次送出開始，就得帶著一個固定不變的編號。

這個編號要在「決定要扣款」的時候產生，寫進自己的資料庫，之後每一次重送都讀同一個值。如果重送的程式碼在送出前才產生編號，或者排程每執行一次就重新組一批付款指令、各自帶新的編號，下游的去重做得再好也認不出來，在它眼裡這就是兩筆不同的交易。

## 二、收款端：把 key 和扣款放在同一個 transaction

收款端的基本做法是一張交易表，用呼叫端給的交易編號當主鍵，第一次看到就執行並把結果存下來，之後看到同一個編號就回傳存好的結果：

```sql
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);
```

扣款寫成一個函式，先插入交易紀錄，插入成功才動餘額：

```sql
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 $$;
```

同一筆送兩次：

```sql
SELECT debit('op-a', 'tx-1001', 'a1', 100);
SELECT debit('op-a', 'tx-1001', 'a1', 100);
SELECT balance FROM wallets;
```

```text
 {"status": "OK", "balance": 900}
 {"status": "OK", "balance": 900, "replayed": true}
 balance
---------
     900
```

第二次沒有動到餘額，回傳的是第一次存下來的結果。容易漏掉的是失敗也要存，第一次因為餘額不足被拒，使用者接著儲值，重送同一筆時如果重新判斷，就會變成扣款成功，呼叫端那邊卻可能已經依照第一次的失敗取消了這筆訂單。Stripe 的 idempotency 也是這樣設計的，第一次的狀態碼和 body 不論成功失敗都存下來，連 `500` 也照原樣回放。

交易紀錄和餘額更新放在同一個 transaction，是這個設計能成立的前提。兩個同編號的請求同時進來時，後到的那個會在主鍵衝突上等待，直到先到的 commit 之後才拿到結果。實測讓 session A 執行扣款後停三秒才 commit，session B 在中間送同一筆，整理成時間線如下：

```text
 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 在兩次重送之間被換掉，就會出事：

```sql
SELECT debit('op-a', 'tx-1002:sess-A', 'a1', 100);
SELECT debit('op-a', 'tx-1002:sess-B', 'a1', 100);
SELECT balance FROM wallets;
```

```text
 {"status": "OK", "balance": 800}
 {"status": "OK", "balance": 700}
 balance
---------
     700
```

同一筆扣款扣了兩次。時間戳記、重試次數、請求 ID、簽章，這些每次送出都不一樣的欄位都不能進 key，key 描述的是業務上的這一筆交易，只能用業務上不會變的東西組成，也就是哪個呼叫方、哪個交易編號。`scope` 負責隔開不同呼叫方，免得甲、乙兩邊剛好用了同一個編號，它的邊界也要想清楚，切得太細（例如每個 session 一個 scope），等於把會變的東西又放了回去。

## 四、同一個 key，內容不一樣

呼叫端的 bug 可能讓兩筆不同的交易拿到同一個編號，例如編號產生器在重啟後從頭計數。收款端如果只看編號，第二筆會被當成重送，拿到第一筆的結果，就這樣無聲無息地消失，雙方都以為成功了。

所以除了編號，還要存一份請求內容的雜湊，重送時比對：

```sql
SELECT debit('op-a', 'tx-1001', 'a1', 500);
```

```text
ERROR:  idempotency key reused with different request: tx-1001
```

雜湊只放決定這筆交易語意的欄位，帳戶、金額、操作種類、訂單編號，不放時間戳記和簽章，理由和上一節一樣。Stripe 的文件寫的也是這個行為，同一個 key 帶著不同參數進來會直接報錯。這種錯誤要讓人看得到，它幾乎都代表呼叫端有 bug，默默回傳舊結果只會把問題藏起來。

## 五、退款比扣款先到

呼叫端扣款逾時，查狀態也逾時，最後決定取消這筆訂單，送出一筆退款，指名退的是剛才那筆扣款。退款順利送到了，那筆扣款卻還停在某個 proxy 的重送佇列裡，幾秒後才抵達。

如果收款端收到退款時發現原扣款不存在，就回「沒東西可退」然後結束，晚到的扣款會被當成一筆新交易正常執行。呼叫端已經把這筆訂單當成取消，使用者的錢卻被扣走了。

做法是退款時一併佔住原扣款的編號，留一筆 tombstone：

```sql
-- 原交易還沒到：先佔住它的 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`。實測：

```sql
SELECT refund('op-a', 'rf-3001', 'tx-3001', 'a1');
SELECT debit('op-a', 'tx-3001', 'a1', 100);
SELECT balance FROM wallets;
```

```text
 {"status": "OK", "refunded": 0}
 {"status": "CANCELLED"}
 balance
---------
    1000
```

遲到的扣款拿到 `CANCELLED`，餘額沒動。正常順序也跑一次，扣款、退款、退款重送，再換一個退款編號退同一筆：

```text
 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](https://www.nbcnews.com/business/business-news/bank-accidentally-deposits-176-million-peoples-accounts-christmas-day-rcna10538)〉與 Fortune〈[Santander accidentally sent $175 million in payments on Christmas Day](https://fortune.com/2021/12/31/santander-bank-accidentally-sent-175-million-payments-christmas-day-wants-money-back)〉：開頭那件事的新聞報導，數字與銀行聲明出自這兩篇，根因細節銀行沒有公開。
- Stripe 文件〈[Idempotent requests](https://docs.stripe.com/api/idempotent_requests)〉：結果不論成敗都存、`500` 照樣回放、參數不同報錯、key 至少保留 24 小時，這幾條行為的原文。
- Stripe Engineering〈[Designing robust and predictable APIs with idempotency](https://stripe.com/blog/idempotency)〉：從網路失敗的幾種情況講起，說明為什麼逾時之後要靠 idempotency key 才能安全重送。
- Wikipedia〈[Two Generals' Problem](https://en.wikipedia.org/wiki/Two_Generals%27_Problem)〉：確認訊息本身也會遺失，所以多送幾輪確認也無法讓雙方確定狀態一致。
- PostgreSQL 文件〈[INSERT: ON CONFLICT Clause](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT)〉：`ON CONFLICT DO NOTHING` 的語法與限制；遇到尚未 commit 的衝突列會先等待，這點本文以兩個 session 實測。

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