結算團購訂單時,系統需要從每位參加者的帳戶餘額中扣除應付金額。
User user = userMapper.selectById(userId);
if (user.getBalance() < amount) {
throw new InsufficientBalanceException("餘額不足");
}
user.setBalance(user.getBalance() - amount);
userMapper.updateById(user);
如果系統只有一台機器,而且結算工作確定會依序執行,這段程式通常不會立刻出問題,但如果未來部署成多台機器,同一時間可能有兩個結算工作處理同一個使用者的訂單,就會出現race condition。
假設小明的餘額是 500,同一時間內,訂單 A 要扣 300、 訂單 B 要扣 400,兩台機器幾乎同時執行,兩邊都以為自己拿到了最新的餘額,因此都通過檢查,最後資料庫卻可能只剩 100。

實際上兩筆訂單應該總共扣掉 700,但帳戶卻只從 500 被扣成 100,其中一次更新把另一次更新的結果覆蓋掉了。
這次專案使用 CAS(Compare-And-Swap)的概念處理,把「餘額必須足夠」直接放進 UPDATE 的條件。
UPDATE users
SET balance = balance - #{amount}
WHERE user_id = #{userId}
AND balance >= #{amount}
這條 SQL 同時做了兩件事:「如果這個使用者的餘額足夠,就扣款;否則不要更新。」
這樣子做兩個請求仍然可以同時抵達資料庫,但針對同一筆 users 資料的更新會受到資料庫併發控制。

看到 WHERE ... AND ... 這種寫法,很容易聯想到 Optimistic Lock,兩者確實有相似之處:都是把某個條件放進 UPDATE,讓更新只有在條件成立時才成功,但兩者比較的東西不同。
下面是 Optimistic Lock的舉例:
UPDATE categories
SET name = #{name},
version = version + 1
WHERE category_id = #{categoryId}
AND version = #{version}
這裡不是在判斷「分類名稱是否還符合某個業務規則」,而是在確認:「我讀取資料時看到的版本,現在還是不是同一個版本?」
套用到這次race condition的情境,如果用上面版本比對的方式處理,會變成第二筆請求進來時,即使餘額足夠扣款,卻因為版號不同而被擋下來,所以這個做法這次在這裡並不適用。