跳至主要內容
科技 科普

餘額扣減在高並發下為什麼會扣錯:從一段「邏輯沒毛病」的程式碼談起

從一段看似沒問題的扣款程式碼出發,說明高並發下餘額扣減為什麼會出錯,以及資料庫行鎖、Redis 預扣與非同步落庫各自解決什麼問題。

YIM NEWS 編輯台 閱讀約 5 分鐘

(本文回顧一篇 2026 年 9 月初在掘金發布、近期再度被技術社羣轉發的工程實務文章,主題是支付與帳務系統裡最經典的難題之一:高並發下的餘額扣減。)

打開任何一個電商或支付系統的程式碼,幾乎都找得到扣款邏輯。最直覺的寫法是兩步:先查餘額,夠就扣。用程式碼表達,就是先 SELECT 拿到餘額,判斷大於等於扣款金額,再 UPDATE 更新餘額。

這段程式碼在開發環境跑一萬次都不會出事,因為開發環境沒有並發。問題出在「查」和「扣」之間有時間縫隙。想像你和朋友同時走進同一家超商,各自看到架上最後一個便當,都以為是自己的。兩個請求同時查到餘額一百元,各扣八十元,兩個都通過了餘額檢查,兩個都執行了更新,最終餘額是二十元。使用者總共花了一百六十元,帳上只扣了八十元。

這不是理論上的邊角案例。出事的機率與並發量成正比,流量一高就會踩到。而支付系統的鐵律是:帳可以慢,不能錯。

第一道修法:把兩步合成一步

問題的根源是「查」和「扣」之間會被別的請求插隊。最省事的修法是不要分兩步,直接合成一條 SQL:

UPDATE account SET balance = balance - 80 WHERE uid = 1001 AND balance >= 80;

這裡的關鍵在於 WHERE 條件把餘額檢查搬進了 UPDATE 本身。MySQL 執行 UPDATE 時會對該行加排他鎖,兩個請求同時抵達,一個先拿到鎖完成扣減,另一個排隊等到鎖釋放後才執行,此時 balance >= 80 的條件已經不成立,UPDATE 影響的行數為零。應用程式只要檢查受影響行數,就知道餘額不足,扣款失敗。

用比喻來說,原本的做法是先看冰箱有沒有雞蛋,再回頭拿鍋子,這中間室友可能把雞蛋拿走了。改成條件式更新,等於把「確認有雞蛋」和「拿走雞蛋」綁成同一個瞬間完成的動作,別人插不了手。

實際業務裡,扣完餘額通常還要寫一筆流水記錄,這時需要用資料庫交易把兩個操作包起來,保證扣餘額和記流水要嘛一起成功,要嘛一起回滾。流水表的交易編號加上唯一索引,重複請求會直接被資料庫攔下,等於免費獲得冪等保障,同一筆扣款被重複送出也不會扣兩次。

原文的判斷是:這個方案能應付絕大多數業務場景。一般使用者的扣款請求都是各扣各的,單一行資料的並發不高,行鎖的排隊等待幾乎無感。

行鎖的天花板:熱點帳戶

一條 SQL 解決了正確性,但 InnoDB 的行鎖意味著同一個帳戶的扣款請求只能嚴格排隊。平常沒事,唯獨一種場景是例外:熱點帳戶。

最典型的例子是企業發紅包。一個企業帳戶,幾萬人同時來領,所有扣款請求都指向同一行資料。排他鎖讓它們全部排成一列,而且 InnoDB 的鎖競爭不只是等待本身:執行緒的掛起與喚醒、死鎖偵測、undo log 膨脹,這些資料庫內部的開銷會拖慢整個實例,影響的不只是那一行。

這就像一家小店只有一個收銀機,平常結帳很快,但演唱會散場時幾萬人湧向同一個櫃檯,問題就不在店員手速,而在櫃檯只有一個。此時單靠一條 UPDATE 已經不夠。

第二道修法:Redis 預扣

思路是把最熱的櫃檯搬一份到門口。把餘額快取到 Redis,扣減先在 Redis 完成,成功後再透過訊息佇列非同步寫回資料庫。

Redis 採單執行緒處理命令,天然沒有並發問題,單機可達十幾萬 QPS,一個熱點帳戶被幾萬人同時扣也喫得下。但「查餘額、判斷、扣減」這三步在 Redis 裡同樣必須是原子的,拆成三條命令一樣會被插隊。解法是 Lua 腳本:整段腳本在 Redis 中原子執行,中間不會被打斷。腳本內容說白了就是「讀餘額,夠就扣並回傳成功,不夠就回傳失敗」這個判斷,只是被封裝成一個不可分割的操作。

Java 端呼叫腳本拿到成功結果後,發一筆訊息到訊息佇列,由消費端慢慢把扣減結果落進資料庫。熱點帳戶的瞬間洪峯,就這樣被 Redis 吸收,資料庫只面對平穩的非同步寫入。

所以呢:工程判斷的三個層次

這篇文章真正值得一般工程師帶走的,並不是某一段程式碼,而是分層判斷的思路。

第一層,先問正確性。查和扣分開就有競態,合併成條件式更新就消除縫隙,這一步不需要任何額外基礎設施,成本最低,應該是預設選項。

第二層,再問量級。行鎖的排隊在一般使用者場景下無感,只有熱點帳戶才會成為瓶頸。判斷依據是「多少請求落在同一行資料」,而非系統總流量。很多系統被過早優化,加了快取、佇列、分布式鎖,實際上單行並發從沒超過個位數。

第三層,引入新組件就要面對新的失敗模式。原文在結尾點出了關鍵問題:Redis 扣成功了,非同步落庫的訊息丟了怎麼辦?快取與資料庫從此是兩份資料,一致性的維護責任從資料庫轉移到了應用層。對帳、補償、冪等設計,都是這個選擇的配套成本。

回到日常情境。你在串流平臺扣月費、在超商用行動支付、在遊戲裡儲值,每一次都經過類似的邏輯。使用者感受到的永遠只是「扣款成功」四個字,而這四個字在系統裡走的每一步,都是為了保證一個樸素的承諾:錢不能扣錯。理解這套機制,對工程師是面試與實作的基礎功,對產品與營運人員則是判斷「這個功能為什麼要做這麼久」的入場券。帳務系統的複雜度,從來不是因為需求難懂,而是因為並發和失敗不會等你準備好。

主題

#mysql影響層面#redis影響層面#支付系統影響層面