2026年9月19日 星期六

面試被問「高併發抽獎」,為什麼直接講 Redis / Queue / 分庫分表的人大多拿到感謝信?

在 Laravel 相關的面試或 Code Review 中,只要遇到「高併發(High Concurrency)」這類題目,我常看到很多工程師一開口就是一套標準的組合拳:


「我們上 Redis 預扣庫存、開 SQS 跑 Queue、加分散式鎖、做 Read Replica,甚至做分庫分表(Sharding)。」

講得頭頭是道,但面試官通常只會冷笑一下,繼續追問:「那如果 Redis 扣成功,DB Transaction 寫入失敗怎麼辦?」 或者 「1000 個 Request 同時搶同一個 Redis 鍵值,你的 Connection 會不會爆掉?」

真正處理過高併發系統的工程師都知道:高併發的本質不是「效能(Performance)」,而是「資料一致性(Consistency)」與「臨界區間(Critical Section)的控制」。

這篇文章我想以多租戶(Multi-tenant)智慧販賣機的抽獎情境為例,分享我身為資深後端架構師,在面對這類問題時的五層拆解架構

思考框架:從 0 到 10,000 QPS 的高併發拆解鏈

面對高併發問題,不要急著給「工具(Tools)」,先給「分析模型(Mental Model)」:

Plaintext
[ 1. 競爭資源識別 ] ──> [ 2. 一致性邊界定義 ] ──> [ 3. DB 鎖與 Deadlock 防護 ]
                                                               ↓
[ 5. 架構降級與快取 (Redis/Queue) ] <── [ 4. 資料庫效能與 Index ]

一、先找出「真正的競爭資源」是什麼?

假設我們有一個多租戶智販機系統,當 1,000 個使用者同時掃描 QR Code 抽獎時,業務邏輯牽涉到三份資料:

  1. QR Code 狀態 (qr_codes):代表這一次的抽獎資格(availableused)。

  2. 機器儲位庫存 (slots):決定這一次抽到的商品實際存放位置(stock 減 1)。

  3. 抽獎結果記錄 (lottery_results):記錄誰在什麼時間抽中了哪個儲位。

這時候要問的第一個問題不是「Laravel 能不能撐住 1000 QPS?」,而是:
「當 1000 個 Request 同時修改同一筆 QR Code 或同一個 Slot 時,誰有權修改?資料怎麼保證正確?」

典型的 Race Condition 往往發生在「先讀取、再判斷、後更新」的時間差內:

PHP
// ❌ 錯誤範例:嚴重的 Race Condition,必導致重複消費
$qrCode = QrCode::where('code', $code)->first();

if ($qrCode->status === 'available') {
    // 時間差(Time-of-check to time-of-use)
    $qrCode->status = 'used';
    $qrCode->save();
}

二、定義最小的一致性邊界(Transaction Boundary)

既然要保證「QR Code 消費 + 扣庫存 + 建立紀錄」這三件事要麼同時成功,要麼同時失敗,我們就必須將它們打包在 DB Transaction 內:

PHP
DB::transaction(function () use ($code, $tenantId) {
    // 1. 鎖定 QR Code 資源
    // 2. 決定抽獎結果與 Slot
    // 3. 扣減 Slot 庫存
    // 4. 寫入 Lottery Result
}, attempts: 3); // 有限次重試

💡 資深架構師的坑邊告誡:

Transaction 不是包得越大越好!
千萬不要把第三方 API(如 LINE App 推播、簡訊通知、外部支付)包進 DB Transaction。Transaction 開得越久,Row Lock 持有時間就越長,DB 連線數(Connection Pool)會瞬間被耗盡。

三、從 Row Lock 到 Atomic Update

為了解決競爭問題,我們有兩種常規的實作方式:

解法 A:悲觀鎖(Pessimistic Locking / lockForUpdate

當 QR Code 代表獨一無二的抽獎資格時,使用悲觀鎖鎖定該 Row:

PHP
$qrCode = QrCode::query()
    ->where('tenant_id', $tenantId)
    ->where('code', $code)
    ->lockForUpdate() // SELECT ... FOR UPDATE
    ->firstOrFail();

if ($qrCode->status !== 'available') {
    throw new DomainException('QR Code 已被使用');
}

$qrCode->update(['status' => 'used']);

解法 B:原子更新(Atomic Update)

如果只是單純扣減熱點儲位(Slot)庫存,不一定要先 SELECTUPDATE,可以直接利用資料庫底層的 Row Lock 與原子性:

PHP
$affected = DB::table('slots')
    ->where('id', $slotId)
    ->where('stock', '>', 0) // 關鍵條件:庫存必須大於 0
    ->decrement('stock');

if ($affected === 0) {
    throw new OutOfStockException('該儲位已無庫存');
}
這能完全避免「A 讀到 stock=1,B 也讀到 stock=1,最後兩人都更新成功導致超賣」的經典問題。

四、Deadlock 的預防與有限度 Retry

當一個 Transaction 內需要同時 Lock 多個資源時(例如:先 Lock QR Code,再 Lock Slot),死鎖(Deadlock) 隨時可能發生。

1. 統一鎖的順序(Lock Order)

規定整個系統內所有業務流程,鎖定資源的順序必須嚴格一致:

  • 正確: 永遠遵循 QR CodeSlot

  • 錯誤: 流程 A 走 QR CodeSlot,流程 B 走 SlotQR Code(必定觸發 Deadlock)

2. DB Transaction 重試機制

即使 Lock 順序一致,在高併發下仍可能遭遇 InnoDB 的偶發 Deadlock。Laravel 的 DB::transaction 支援第二個參數 attempts

PHP
// 當遭遇 Deadlock 時,自動 Rollback 並重試最多 3 次
DB::transaction(function () {
    // 抽獎邏輯
}, attempts: 3);
註:重試必須是「有限次」且「可預期」的,搭配 Log 與 APM(如 Datadog/Sentry)監控;無限重試只會讓過載的 DB 徹底崩潰。

五、當 1000 人同時搶「同一個大獎」:進階架構演進

上面的 DB 悲觀鎖或 Atomic Update,在散佈式的併發情境運作得很好。但如果 1000 人在同一秒搶「同一個特獎 Slot #15」,全部 Request 阻塞在同一行 DB Row,DB Connection Pool 會瞬間爆滿。

這時,才是我引出 RedisQueue 的最佳時機:

[ Frontend Request ]
        │
        ▼
[ Redis Lua Script (預扣庫存/資格驗證) ] ──(失敗)──> [ 回傳:已售罄 ]
        │ (成功)
        ▼
[ Dispatch 到 Laravel Queue (SQS/Redis) ]
        │
        ▼
[ DB Worker (寫入 Transaction, 保證 Consistency) ]
  1. Redis Defer/Pre-decanting (預扣庫存):
    使用 Redis Lua Script 進行單線程的庫存預扣。Redis 一秒能吞吐數萬 QPS,未搶到庫存的 990 個請求在 Redis 層就被擋掉,只有成功的 10 個請求會進到 DB。

  2. 兩階段處理與 Outbox Pattern:
    抽獎成功後,不直接在 Transaction 內發推播或寫 Log,而是觸發 event(new LotteryWon($result)),由實作 ShouldQueue 的 Listener 去非同步執行。

總結:資深工程師的價值在於「知道為什麼而選」

面試官真正想聽到的,從來不是你背出多少個新穎的名詞,而是你有沒有能力回答:

  1. 為什麼這裡用 Row Lock 而不是 Optimistic Lock?

  2. Transaction 的邊界你怎麼切分?

  3. 遇到死鎖時你的系統如何自我恢復(Self-healing)?

  4. 什麼時候 DB 就夠用?什麼時候才真的需要引入 Redis/Queue?

先講 資料一致性,再講 資料庫優化與 Index,最後才談 分散式快取與削峰填谷(Queue)。這樣由內而外、漸進式的拆解,才是架構師等級的思考深度。

💬 交流時間

你在專案中處理高併發搶購或抽獎時,踩過最大的 DB 坑是什麼?歡迎在下方留言討論!

沒有留言:

張貼留言

面試被問「高併發抽獎」,為什麼直接講 Redis / Queue / 分庫分表的人大多拿到感謝信?

在 Laravel 相關的面試或 Code Review 中,只要遇到「高併發(High Concurrency)」這類題目,我常看到很多工程師一開口就是一套標準的組合拳: 「我們上 Redis 預扣庫存、開 SQS 跑 Queue、加分散式鎖、做 Read Replica,甚至...