前面兩篇講了訂單狀態機、付款方式分流。但有一個環節,是整個支付系統最容易出事、出事最難查、還會直接變成錢的問題的:付款 callback——金流商非同步回報「這筆付款成功了」的那一下。

這篇不從理論講。從一個我真的踩過的帳差事故講起。

事故:從「有人重複點了按鈕」開始

一開始系統是單體架構(single app)。付款流程直接在請求裡同步跑完。看起來沒問題——直到有人在網路卡頓的時候,把付款按鈕點了兩次、或前端重送了 request

然後對帳的時候發現「怪怪的」——同一筆訂單的處理,有些地方對不太起來。當時的直覺是「這種同步塞在請求裡的做法不夠穩,應該把它丟到 message queue 去非同步處理」。方向沒錯。但轉 queue 的時候,queue worker 那邊的 log 規劃沒做好——出事的時候,你根本不知道哪一筆在哪個環節出了什麼事,因為 log 不夠、追不回時序。

結果就是:帳差。帳目對不起來,而且因為 log 不足,你連「差在哪、從哪一筆開始差」都很難還原。到最後不得不硬著頭皮人工處理。

這個事故教了我兩件事,正好對應 callback 的兩條生死線:冪等(擋掉重複)和可觀測(出事查得到)。而它還逼出了第三樣東西——對帳(那是另一篇的主角,這個故事的下集)。

生死線一:驗簽——不然任何人都能偽造「已付款」

在講冪等之前,先講一個更基本的:callback 一定要驗簽

callback 是金流商打到你伺服器的一個 HTTP 請求,通常是公開的 endpoint(csrf_exempt、沒有登入驗證,因為打過來的是金流商不是使用者)。問題來了:如果你不驗簽,任何人只要知道這個 URL,打一個「訂單 123 付款成功」的假請求,你就把沒付錢的訂單標成已付款、出貨了。

驗簽就是金流商用一組只有你們雙方知道的密鑰,對回傳內容算一個簽章(CheckMacValue 之類),你收到後用同樣的方式重算一次,對得上才是真的。不同金流商用的演算法不一樣——有的用 SHA256、有的用 AES-256-GCM——但精神一樣:沒有正確簽章的 callback,一律當偽造丟掉。

這條沒得省。省了,你的付款成功與否,等於開放讓全世界填。

生死線二:冪等——因為 callback 一定會重複

驗簽擋掉偽造,冪等擋掉重複。而重複不是意外,是常態:

  • 使用者重複點(我踩的那個)
  • 金流商的 callback 機制本身是 at-least-once——它為了確保你收到,會重送。網路一抖、你回應慢一點,它就再打一次。

所以你的 callback 處理一定會被同一筆付款觸發不只一次。如果每次都無腦執行「把訂單標 PAID、扣庫存、開發票」,同一筆付款就會被處理兩遍、庫存扣兩次、發票開兩張。冪等不是為了漂亮,是為了讓「重複執行」和「執行一次」結果一樣。

真實系統的冪等通常是多層防護疊起來的:

  1. 行鎖:處理前先鎖住這筆訂單的資料列(select_for_update),同一時間只有一個 callback 能進來動它。
  2. 狀態判斷:進來先看訂單是不是已經 PAID 了,是的話直接返回、什麼都不做。
  3. 交易包裹:整段放進一個 DB transaction,中途失敗全部 rollback,不留半套資料。
  4. 結構約束:Payment 和 Order 設成一對一(OneToOne),從資料結構層就擋掉「一筆訂單多筆付款」。

一層可能有漏,多層疊起來才穩。這也是為什麼我說 callback 是「最難的一站」——它同時要防偽造、防重複、還要在失敗時不留爛帳。

最深的坑:金流成功,但你內部失敗了怎麼辦

這是 callback 設計裡最兩難、也最誠實的一段。

想像這個時序:驗簽過了、確認金流商真的收到錢了,你開始處理內部邏輯(建單、扣庫存、開發票)——結果內部這一步掛了。現在你面對一個尷尬局面:錢已經收了(金流商那邊是成功的),但你系統裡沒建成單。

這時候 callback 要回什麼給金流商?如果回失敗,金流商會一直重送(它以為你沒收到),但你重送幾次內部都可能一樣失敗,變成無限重試。

我當時的處理(老實說,這已經有點久、細節有些模糊了,但邏輯是這樣):還是回「成功」給金流商(讓它別再重送),然後在系統裡留一筆 FAILED 記錄,等人工處理

這是一個 dual-write 不一致的經典難題——「錢的狀態」和「訂單的狀態」跨在兩個系統上,中間任何一步斷掉就對不齊。回成功留 FAILED 是務實的止血,但它不是根治。根治的方向是 Outbox pattern 那類「可靠事件」機制,加上——對帳兜底。這也是為什麼那次事故之後,我知道一定要做對帳。

收:callback 的三條線

  1. 驗簽:沒有正確簽章的 callback 一律丟掉,不然付款成功與否開放全世界填。
  2. 冪等:callback 是 at-least-once、一定會重複,用行鎖+狀態判斷+交易+結構約束多層防護,讓重複執行 = 執行一次。
  3. 失敗留痕 + 對帳兜底:「金流成功但內部失敗」是 dual-write 難題,止血是回成功留 FAILED,根治要靠可靠事件 + 對帳。而 log 沒規劃好,出事你連查都沒得查。

接下來往哪走