2026年10月16日 星期五

從 lockForUpdate 到 CAS:我們如何在自動販賣機系統中解決 QRCode 高併發問題


在許多涉及票券、庫存、預約、金流的系統中,最常見的問題之一就是:如何避免同一個資源被同時使用兩次?

以我們的自動販賣機系統為例,每個 QRCode 都代表一次有效兌換資格。如果兩台機器、兩個使用者,甚至同一個使用者因為網路不穩重複送出請求,瞬間湧入 100 個併發請求同時掃描同一張 QRCode,就可能發生重複出貨的問題。這在販賣機場景裡就是直接的商品損失。

這篇文章分享我們最終採用的設計,以及為什麼我們沒有使用 lockForUpdate(),而是透過狀態機(State Machine)+ CAS(Compare And Swap)的方式解決高併發問題,並進一步探討在生產環境中如何處理硬體與網路帶來的真實一致性挑戰。

1. 第一個直覺:lockForUpdate()

許多工程師第一時間會想到透過資料庫的事務鎖來解決:

PHP
DB::transaction(function () {
    $ticket = OrderQRCode::where(...)
        ->lockForUpdate()
        ->first();

    if ($ticket->status !== 'unused') {
        throw new Exception('票券已被使用');
    }

    $ticket->status = 'processing';
    $ticket->save();
});

原理與優點

原理很簡單直觀,資料庫會幫你保證:同一時間只有一個 Transaction 能修改資料。

缺點與瓶頸

問題在於「排隊等待」。

當 100 個請求同時打進來時:

  1. 請求 A 取得鎖,開始處理。

  2. 請求 B、C、D、E 只能在後面等待。

  3. 直到 A Commit 之後,B 才能進去檢查狀態,最後發現已被使用而失敗。

在販賣機或實體核銷場景,業務需求不是「排隊等待取得票券」,而是「成功就處理,失敗就立即拒絕(Fail Fast)」。讓使用者或機台在數毫秒內收到失敗回應,體驗遠比在機台前卡住數秒等待好得多。

2. 我們最終選擇:資料庫層級的 CAS

我們實際採用的是條件更新(Conditional Update):

PHP
$updatedRows = OrderQRCode::where('code_serial', $ticketCode)
    ->where('tenant_id', $tenant->id)
    ->where('status', OrderQRCode::STATUS_UNUSED)
    ->update([
        'status' => 'processing',
    ]);

if ($updatedRows === 0) {
    // 搶票失敗,代表已被其他請求處理
    return response()->json(['message' => '票券已被使用或處理中'], 400);
}
對應的 SQL:

SQL
UPDATE order_qrcodes
SET status = 'processing'
WHERE code_serial = ?
  AND tenant_id = ?
  AND status = 'unused';
這本質上就是 CAS (Compare And Swap):

  • Compare:檢查目前的 status 是否為 unused?

  • Swap:如果是,更新為 processing 並回傳影響筆數 1;否則條件不成立,回傳影響筆數 0。

高併發下的表現

假設 100 個請求同時掃同一張票:

  • 第一個請求:符合 status = 'unused',成功更新為 processing,影響筆數為 1。

  • 其他 99 個請求:此時資料已變成 processing,WHERE status = 'unused' 條件不成立,影響筆數為 0,直接 Fail Fast 失敗。

沒有等待、沒有鎖競爭、沒有長交易,純粹透過資料庫的原子性 UPDATE 完成資源爭奪。

3. 真正重要的靈魂:狀態機 (State Machine)

如果只有 CAS 還不夠,因為在真實業務中:取得票券 ≠ 完成出貨。

我們必須建立完整的狀態流轉機制,明確定義資源生命週期:

Plaintext
[ unused ] ──(CAS 搶票)──> [ processing ] ──(出貨成功)──> [ used ]
                               │
                               └──(異常/逾時)──> [ abnormal ]
  • 只有 unused 可以搶票。

  • 只有 processing 可以完成出貨(在 confirmDispense 時再次驗證 where('status', 'processing'))。

這代表 CAS 與 State Machine 共同保證了系統在軟體層面的狀態正確性。

4. CAS 解決的是「競爭」,不是「一致性」

很多文章寫到 CAS 就結束了,但實際上最大的問題才剛開始。

很多人會有一個誤解:「我用了 CAS,所以系統就完全安全了。」其實不是。

  • CAS 解決的是:誰取得處理權。

  • CAS 沒解決的是:整個交易流程與實體世界是否一致。

假設情境:

  1. unused $\rightarrow$ processing CAS 成功(代表只有這台機台拿到處理權)。

  2. 伺服器發送出貨指令給自動販賣機。

  3. 販賣機掉落商品,成功出貨。

  4. 伺服器在準備將狀態更新為 used 的前一刻,發生 Server Crash / Network Timeout / Fatal Error。

此時 CAS 其實完全正常運作,但業務狀態(DB 還在 processing)與真實世界(商品已被拿走)已經出現偏差。

為什麼不能直接把逾時的 processing 改回 unused?

有些團隊會寫個 CronJob 定時把過期的 processing 刷回 unused,這極度危險。如果商品已經掉出來了,只是資料庫沒改到 used,定時任務一刷回 unused,這張票就能再刷一次,造成第二次重複領貨,這比票券卡死更嚴重。

5. 生產級系統的四層防禦組合拳

面對實體設備與網路不可靠的真實世界,我們最終將架構拆解為四層:

第一層:CAS (Compare And Swap)

  • 負責:極速搶票與併發防禦。

  • 限制:確保 unused $\rightarrow$ processing 的狀態切換只會成功一次。

第二層:State Machine (狀態機)

  • 負責:狀態合法性。

  • 限制:嚴格限制流轉路徑(unused $\rightarrow$ processing $\rightarrow$ used),禁止跨越或非法倒退。

第三層:Idempotency (冪等性)

  • 負責:網路重試與硬體通訊安全。

  • 機制:當機台因為 Timeout 重試發送 confirmDispense 時,系統憑藉 transaction_id 或 machine_id 回傳第一次的處理結果,而不是再次觸發硬體吐貨。

第四層:Compensation (補償機制)

  • 負責:極端異常恢復。

  • 機制:增加 processing_at、processing_machine_id 等欄位。當 processing 超過預期時間,狀態不回退至 unused,而是轉為 abnormal(異常)。後續由自動對帳流程或後台審查機制去核對機台硬體 Log,確認是否真的出貨,再決定進行人工退款還是補寫入 used。

結論

如果從單純語法的角度看這題,學到的只是「CAS 比 lockForUpdate 快」;但站在系統架構與實戰的角度看:

lockForUpdate 與 CAS 的選擇只是手段,真正的核心是先定義資源生命週期,再利用 State Machine 管理合法狀態轉移,透過 CAS 解決資源競爭,最後再用 Idempotency 與 Compensation 應對真實世界中的異常與網路不可靠性。

對於自動販賣機、票券、優惠券、庫存、預約等一次性資源場景來說,這種思維才能真正打造出既能扛高併發、又具備高度容錯能力的生產級系統。

從 lockForUpdate 到 CAS:我們如何在自動販賣機系統中解決 QRCode 高併發問題

在許多涉及票券、庫存、預約、金流的系統中,最常見的問題之一就是: 如何避免同一個資源被同時使用兩次? 以我們的自動販賣機系統為例,每個 QRCode 都代表一次有效兌換資格。如果兩台機器、兩個使用者,甚至同一個使用者因為網路不穩重複送出請求,瞬間湧入 100 個併發請求同時掃描同...