iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 18 篇

Day 18|列鎖:如何避免同一個使用者同時下單造成超支?

  • 分享至 

  • xImage
  •  

使用者參加團購時,系統要先確認他付得起,這裡看的不是帳戶實際餘額,而是可用餘額:

可用餘額 = 帳戶餘額 − 尚未結算品項的金額總和

目前 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 元。

https://ithelp.ithome.com.tw/upload/images/20260927/20168667uVTkgCMCGa.png

所以問題不只是「檢查餘額」,而是要讓檢查可用餘額與新增凍結品項之間不能被另一個請求插入。

為什麼不用 CAS 處理?

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)。

https://ithelp.ithome.com.tw/upload/images/20260927/20168667SGt7fqbR52.png

假設兩筆 70 元的請求同時進來,第一筆取得 users 的鎖後繼續檢查與新增;第二筆則必須等待。等第一筆交易提交後,第二筆取得鎖,再重新計算凍結金額,這時就會發現可用餘額只剩 30 元。

這和 CAS 的差異在於:

  • 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 列鎖。

否則未來如果新增「修改品項數量」的功能,卻沒有遵守同樣的鎖定規則,就可能繞過這套保護。

到這裡,我們解決的是「下單時如何避免凍結金額超過餘額」。但團購結算時又是另一個問題:如果扣到第三個使用者時才發現餘額不足,前面已經扣掉的錢該怎麼辦?這個問題就留給明天繼續囉~


上一篇
Day 17|CAS:兩台機器同時扣款會發生什麼事?
下一篇
Day 19|@Transactional 的邊界:哪些操作該被 rollback?
系列文
做一個團購後端,順便搞懂那些事 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言