使用者參加團購時,系統要先確認他付得起,這裡看的不是帳戶實際餘額,而是可用餘額:
可用餘額 = 帳戶餘額 − 尚未結算品項的金額總和
目前 users.balance 存帳戶餘額,而尚未結算的金額來自 order_items。先看一下相關的資料表(只列出本篇會用到的欄位):
CREATE TABLE users (
user_id VARCHAR(20) PRIMARY KEY,
balance BIGINT NOT NULL DEFAULT 0 -- 帳戶餘額
);
CREATE TABLE orders (
order_id VARCHAR(36) PRIMARY KEY,
status VARCHAR(20) NOT NULL
-- OPEN 開團中:可以下單
-- CLOSED 已截止:停止下單,準備結算
-- SETTLED 已結算:已從每位參與者的餘額扣款
-- CANCELLED 已取消:不會收款
-- FAILED 結算失敗:有人餘額不足,整筆扣款回滾,待重試
);
CREATE TABLE order_items (
item_id INT AUTO_INCREMENT PRIMARY KEY,
order_id VARCHAR(36) NOT NULL, -- 屬於哪張團購單
user_id VARCHAR(20) NOT NULL, -- 誰點的
unit_price INT NOT NULL, -- 下單當下的單價快照
quantity INT NOT NULL,
subtotal INT GENERATED ALWAYS AS (unit_price * quantity) STORED
);
一個使用者可以在多張團購單各下多筆品項,所以凍結金額是他名下 order_items 的加總;
而只有訂單還在 OPEN(或結算失敗 FAILED)時,這些品項才算「凍結中」:
users.balance − SUM(order_items.subtotal)(只算所屬訂單為 OPEN / FAILED 的品項)
如果直接採用「查詢 → 判斷 → 寫入」,併發時兩個請求可能同時看到相同的可用餘額。例如:小明帳戶有 100 元,同時在兩張團購各下一筆 70 元,兩個請求都可能判斷餘額足夠,最後凍結 140 元。

所以問題不只是「檢查餘額」,而是要讓檢查可用餘額與新增凍結品項之間不能被另一個請求插入。
Day 17 的 CAS 可以把條件直接放進 UPDATE(balance 就在 users 這一列裡),資料庫可以直接根據該列的值判斷更新是否成立。
UPDATE users
SET balance = balance - 70
WHERE user_id = ?
AND balance >= 70;
但這次的可用餘額是跨表計算的(計算方式如下),而且下單時不是更新 users,而是新增 order_items:
users.balance − SUM(order_items.subtotal)(只算所屬訂單為 OPEN / FAILED 的品項)
因此沒有一列可以直接寫成:
UPDATE ...
WHERE available_balance >= orderAmount
當然也可以在 users 增加 frozen_amount 欄位,把凍結金額反正規化存起來,但之後每次訂單狀態或品項變動,都必須同步維護這個欄位(這裡選擇保留原本的資料結構,每次重新計算凍結金額)。
既然 CAS 無法直接把「檢查」和「新增」放進同一個條件式更新,就換個方向:
讓同一個使用者的下單交易依序進行
@Transactional(isolation = Isolation.READ_COMMITTED)
public void createUserOrder(CreateOrderItemRequest request, String userId) {
// 先驗證訂單與菜單
Order order = orderMapper.selectById(request.getOrderId());
Menu menu = menuMapper.selectById(request.getMenuId());
int orderAmount = menu.getUnitPrice() * request.getQuantity();
// 列鎖: 鎖住這個使用者
User user = userMapper.selectForUpdate(userId);
if (user == null) {
throw new ResourceNotFoundException("使用者不存在");
}
// 拿到鎖後,再重新計算凍結金額
long frozenAmount = orderItemMapper.getFrozenAmount(userId);
long available = user.getBalance() - frozenAmount;
if (available < orderAmount) {
throw new InsufficientBalanceException("餘額不足");
}
// 確認沒問題後才新增凍結品項
OrderItem item = new OrderItem();
item.setOrderId(request.getOrderId());
item.setUserId(userId);
item.setMenuId(request.getMenuId());
item.setProductName(menu.getProductName());
item.setUnitPrice(menu.getUnitPrice());
item.setQuantity(request.getQuantity());
orderItemMapper.insert(item);
}
userMapper.selectForUpdate(userId) 會鎖住 users 中該使用者的資料列,這種方式稱為列鎖(row lock)。

假設兩筆 70 元的請求同時進來,第一筆取得 users 的鎖後繼續檢查與新增;第二筆則必須等待。等第一筆交易提交後,第二筆取得鎖,再重新計算凍結金額,這時就會發現可用餘額只剩 30 元。
這和 CAS 的差異在於:
READ_COMMITTED?這裡有一個容易忽略的細節:拿到 users 的鎖,不代表一般 SELECT 一定會看到前一個交易剛提交的資料。
MySQL 預設是 REPEATABLE_READ,一般 SELECT 可能使用交易快照;但這裡拿到鎖後,需要重新查詢 order_items,看到前一個交易已提交的凍結品項。
而快照是在交易中第一個一般 SELECT 執行時建立的。在這個做法裡,上鎖前的selectById(order) 就已經把快照固定住了,之後 selectForUpdate 雖然拿到了鎖,getFrozenAmount() 讀的仍然是那份舊快照,看不到前一個交易剛提交的品項。
因此這支交易使用:
@Transactional(isolation = Isolation.READ_COMMITTED)
讓後續一般 SELECT 以當下已提交的資料為準,再重新計算可用餘額。
可以簡單記成:
FOR UPDATE負責讓同一個使用者的交易排隊;READ_COMMITTED負責確保拿到鎖後的查詢看到前一個交易已提交的資料。
最後還有一個前提:
所有會增加凍結金額的寫入路徑,都必須先取得同一把
users列鎖。
否則未來如果新增「修改品項數量」的功能,卻沒有遵守同樣的鎖定規則,就可能繞過這套保護。
到這裡,我們解決的是「下單時如何避免凍結金額超過餘額」。但團購結算時又是另一個問題:如果扣到第三個使用者時才發現餘額不足,前面已經扣掉的錢該怎麼辦?這個問題就留給明天繼續囉~