在許多涉及票券、庫存、預約、金流的系統中,最常見的問題之一就是:如何避免同一個資源被同時使用兩次?
以我們的自動販賣機系統為例,每個 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 個請求同時打進來時:
- 請求 A 取得鎖,開始處理。
- 請求 B、C、D、E 只能在後面等待。
- 直到 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 沒解決的是:整個交易流程與實體世界是否一致。
假設情境:
unused$\rightarrow$processingCAS 成功(代表只有這台機台拿到處理權)。- 伺服器發送出貨指令給自動販賣機。
- 販賣機掉落商品,成功出貨。
- 伺服器在準備將狀態更新為
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 應對真實世界中的異常與網路不可靠性。
對於自動販賣機、票券、優惠券、庫存、預約等一次性資源場景來說,這種思維才能真正打造出既能扛高併發、又具備高度容錯能力的生產級系統。
自動販賣機 QRCode 高併發與分散式一致性:架構師面試精選問答
這是一份針對 Senior / Staff Engineer / Solution Architect 等級面試所設計的深度問答集。核心情境聚焦於:當自動販賣機、票券或預約系統面臨高併發掃描與實體硬體/網路不可靠時,如何透過系統化架構確保資料正確性。
Q1:在自動販賣機系統中,如何避免同一個 QRCode 被重複使用?為什麼你們最終捨棄了 lockForUpdate()?
💡 核心考量:Fail Fast 與效能瓶頸
在實體販賣機場景中,使用者的體驗是「秒級」的。如果第一直覺使用
DB::transaction 搭配 lockForUpdate():PHP
DB::transaction(function () {
$ticket = OrderQRCode::where(...)->lockForUpdate()->first();
// ... 檢查與更新
});
- 缺點(排隊等待):當 100 個請求同時打進來時,資料庫會讓後續的請求全部進入 Lock Wait 佇列排隊。這會導致 Connection Pool 迅速耗盡、伺服器執行緒卡死,使用者的體驗變成在機台前轉圈圈數秒後才失敗。
- 我們的解法(資料庫 CAS):我們改用條件更新(Compare And Swap)的 Atomic SQL:SQL
UPDATE order_qrcodes SET status = 'processing' WHERE code_serial = ? AND tenant_id = ? AND status = 'unused';藉由影響筆數(Affected Rows)來判斷是否搶票成功。若為0則代表已被其他人拿走,直接秒拒(Fail Fast)。這完全消除了長交易與鎖競爭,讓系統在高併發下迅速給出回應。
Q2:既然用了 CAS 解決競爭,為什麼還需要「狀態機(State Machine)」?
💡 核心考量:業務生命週期的邊界與合法性
CAS 解決的只是「誰取得處理權(Unused $\rightarrow$ Processing)」這一個單點動作,但業務流程是連續且多階段的(取得票券 $\neq$ 完成出貨)。
如果沒有狀態機嚴格規範,系統很容易在複雜的非同步流程中出現邏輯漏洞。我們定義了嚴格的單向生命週期:
Plaintext
[ unused ] ──(CAS 搶票)──> [ processing ] ──(出貨成功)──> [ used ]
│
└──(異常/逾時)──> [ abnormal ]
- 只有
unused可以被搶票。 - 只有
processing可以進行後續的confirmDispense(確認出貨)。 - 狀態單向不可逆,從根本上杜絕了非法跨越或狀態錯亂的可能性。
Q3:很多架構師會追問:「既然都處理併發了,為什麼不用 MQ(如 RabbitMQ / Kafka / SQS)做 Event-Driven 架構?」
💡 核心考量:同步互動 vs. 非同步解耦
- 面試官盲點:覺得 MQ 萬能,能解一切併發。
- 精準防守回答:MQ 非常適合用於解耦與非同步處理(例如訂單成立後的發票開立、發送通知、產生報表)。但在自動販賣機出貨這個場景中,它是一個需要立即回覆結果的同步交易流程。使用者掃碼後必須在零點幾秒內知道機台會不會掉貨,因此核心的狀態轉移必須在同步請求中完成。MQ 可以作為後續補償與對帳的輔助,但不適合擔任票券所有權競爭的主體。
Q4:另一道經典追問:「既然都用 CAS 了,為什麼不用 Redis Lua Script?不是更快嗎?」
💡 核心考量:Source of Truth 與雙寫一致性(Dual-write Consistency)
- 面試官盲點:追求極致效能而忽略系統複雜度。
- 精準防守回答:Redis Lua 的確極快,但在目前的系統規模下,效能瓶頸根本不在 SQL
UPDATE,而在業務流程本身。更重要的是,票券與訂單的最終狀態(Source of Truth)本來就在資料庫。如果使用 Redis 加鎖或存狀態,就會衍生出**「Redis 成功但 DB 寫入失敗」的雙寫一致性問題**。直接利用 DB CAS 完成狀態轉移,既能滿足效能需求,又能確保資料永遠以 DB 為唯一的 Source of Truth,大幅降低維運與除錯成本。
Q5:如果 CAS 成功了,但隨後伺服器 Crash 或網路 Timeout,導致票券卡在 processing,該怎麼處理?為什麼不能簡單地定時把逾時的單洗回 unused?
💡 核心考量:分散式系統的一致性(Consistency)與防呆機制
- 危險的做法:寫一個 CronJob 把超過 2 小時的
processing自動改回unused。- 風險:如果真實情況是「商品其實已經掉出來了(吐貨成功),只是伺服器在準備寫入
used的那一瞬間掛了」,這時候洗回unused會讓使用者拿著同一張票刷第二次,造成嚴重的商業損失。
- 生產級的解法(四層防禦中的第四層:Compensation):
- 引入追蹤欄位:
processing_at、processing_machine_id、transaction_id。 - 當逾時發生時,狀態絕對不回退至
unused,而是轉為abnormal(異常),並凍結該票券。 - 自動對帳機制(Reconciliation):機台本身有 Local Log 紀錄硬體感測器的吐貨紀錄。當機台恢復網路時會主動上傳離線 Log。後台定時比對:若 Log 顯示有吐貨 $\rightarrow$ 自動補寫為
used;若沒吐貨 $\rightarrow$ 觸發自動退款或人工審查。
Q6:談談你對這套設計的總結(Elevator Pitch)
「這套設計的核心並不是依賴某個單一工具(如 CAS),而是以資料庫作為唯一的 Source of Truth,並透過:
CAS 解決高併發下的資源競爭; State Machine 管理業務的合法生命週期; Idempotency 處理網路重試與硬體通訊安全; Compensation 應對伺服器當機與網路斷線的極端異常。最終在不可靠的網路與硬體環境中,維持系統的高併發吞吐與資料一致性。」
沒有留言:
張貼留言