在 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 抽獎時,業務邏輯牽涉到三份資料:
- QR Code 狀態 (
qr_codes):代表這一次的抽獎資格(available→used)。 - 機器儲位庫存 (
slots):決定這一次抽到的商品實際存放位置(stock減 1)。 - 抽獎結果記錄 (
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)庫存,不一定要先
SELECT 再 UPDATE,可以直接利用資料庫底層的 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 Code→Slot - 錯誤: 流程 A 走
QR Code→Slot,流程 B 走Slot→QR 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 會瞬間爆滿。
這時,才是我引出 Redis 與 Queue 的最佳時機:
[ Frontend Request ]
│
▼
[ Redis Lua Script (預扣庫存/資格驗證) ] ──(失敗)──> [ 回傳:已售罄 ]
│ (成功)
▼
[ Dispatch 到 Laravel Queue (SQS/Redis) ]
│
▼
[ DB Worker (寫入 Transaction, 保證 Consistency) ]
- Redis Defer/Pre-decanting (預扣庫存):使用 Redis Lua Script 進行單線程的庫存預扣。Redis 一秒能吞吐數萬 QPS,未搶到庫存的 990 個請求在 Redis 層就被擋掉,只有成功的 10 個請求會進到 DB。
- 兩階段處理與 Outbox Pattern:抽獎成功後,不直接在 Transaction 內發推播或寫 Log,而是觸發
event(new LotteryWon($result)),由實作ShouldQueue的 Listener 去非同步執行。
總結:資深工程師的價值在於「知道為什麼而選」
面試官真正想聽到的,從來不是你背出多少個新穎的名詞,而是你有沒有能力回答:
- 為什麼這裡用 Row Lock 而不是 Optimistic Lock?
- Transaction 的邊界你怎麼切分?
- 遇到死鎖時你的系統如何自我恢復(Self-healing)?
- 什麼時候 DB 就夠用?什麼時候才真的需要引入 Redis/Queue?
先講 資料一致性,再講 資料庫優化與 Index,最後才談 分散式快取與削峰填谷(Queue)。這樣由內而外、漸進式的拆解,才是架構師等級的思考深度。
💬 交流時間
你在專案中處理高併發搶購或抽獎時,踩過最大的 DB 坑是什麼?歡迎在下方留言討論!
沒有留言:
張貼留言