Laravel 點數系統的並發一致性:從 Race Condition、Deadlock 到真正的 Concurrency Test
原始碼:https://github.com/BpsEason/loyalty-api.git
在點數系統裡,「加點」和「扣點」看起來只是簡單的數字加減:
balance + 100
balance - 20但當系統開始面對真實 API 流量後,問題就不再只是 CRUD。
例如一個會員目前有 100 點,同時收到多個扣點請求:
Request A:扣 20
Request B:扣 20
Request C:扣 20
...如果多個 Request 同時讀到相同餘額,再各自更新,就可能出現 Race Condition,最終餘額錯誤、交易紀錄對不上,甚至出現負點數。
因此點數系統真正要回答的是:
多個請求同時操作同一個會員的點數時,如何保證最終餘額、交易紀錄、重試行為都仍然正確?
這也包含另一個常被忽略、但面試與上線都極關鍵的問題:
鎖會不會失效?會不會死鎖?死鎖之後系統怎麼收斂?
一、點數操作不是改一個欄位,而是一整段交易
單純這樣寫並不夠:
$account->balance += $amount;
$account->save();一次點數異動通常包含:
取得 PointAccount
→ 讀取目前餘額
→ 檢查餘額是否足夠
→ 更新 balance
→ 更新統計欄位
→ 建立 PointTransaction這些步驟必須被視為同一個業務交易單位。
任一環節失敗,都不該留下「餘額改了、交易沒寫入」或「交易寫了、餘額沒改」的半成品。
因此核心流程應落在:
DB::transaction(function () {
// 鎖定並讀取 PointAccount
// 檢查餘額
// 更新 PointAccount
// 建立 PointTransaction
});這樣當流程中任何一步拋出例外,資料庫可以一起 Rollback。
二、DB Transaction 解決的是 Atomicity,不是全部並發問題
DB::transaction() 很重要,但它主要保證的是:
這一組資料庫操作要嘛全部成功,要嘛全部失敗。
例如:
DB::transaction(function () {
$account->update(['balance' => 50]);
PointTransaction::create([/* ... */]);
throw new RuntimeException('Something went wrong');
});前面的更新會被 Rollback,避免資料寫到一半。
但 Transaction 本身不自動解決:
- 兩個交易同時根據舊餘額扣點
- 多實例同時進入同一業務臨界區
- Client 重試造成重複入帳
- 死鎖發生後如何恢復
所以「有 Transaction」不等於「並發安全」。
三、Row Lock:SELECT ... FOR UPDATE
為了避免兩個交易同時讀到同一個舊餘額,取得帳戶時應使用:
$customer->pointAccount()
->lockForUpdate()
->first();這對應資料庫的 row-level lock:
SELECT *
FROM point_accounts
WHERE customer_id = ?
FOR UPDATE;效果是:
Account balance = 100
Transaction A
→ SELECT ... FOR UPDATE
→ 取得鎖
→ 讀到 100
→ 扣 80
→ balance = 20
→ commit
Transaction B
→ 等待 A 結束
→ 讀到最新 20
→ 若再扣 80,餘額不足,拒絕這能避免「兩個交易都以為自己還有 100 點」的經典錯誤。
使用 lockForUpdate 的實務原則
- 鎖的範圍要小:只鎖真正會被更新的 row
- 交易要短:Transaction 內不要打外部 HTTP、不要做重計算、不要寫大量無關日誌
- 先鎖再判斷:先 lockForUpdate,再檢查餘額,再更新
- 避免在鎖內做不確定時長的操作
Row lock 持有越久,等待隊列越長,系統越容易出現 lock wait timeout,甚至放大死鎖機率。
四、為什麼有人會再加 Redis Distributed Lock?
如果系統只有單一資料庫、單一帳戶 row,很多場景其實:
DB::transaction + lockForUpdate 就足夠。
但若有以下需求,才值得考慮 Redis Lock:
- 多個 Application Instance 同時處理請求
- 臨界區不只是單一 DB row,還包含快取、外部系統、多表協調
- 希望在進 DB 前先在應用層把同一顧客的操作串行化,降低資料庫鎖競爭
典型寫法:
$lockKey = "point_customer:{$customerId}:tenant:{$tenantId}";
$lock = Cache::lock($lockKey, $ttl);
return $lock->block($waitSeconds, function () {
return DB::transaction(function () {
// lockForUpdate + update + create transaction
});
});Redis Lock 的價值與界線
Redis Lock 解決的是:
跨程序/跨實例,對同一個業務對象的互斥進入
它不能取代:
- DB Transaction 的原子性
- lockForUpdate 對餘額讀寫正確性的保護
- Unique Constraint 對資料完整性的最後防線
五、最容易被忽略的問題:死鎖與鎖失效
這是點數系統並發設計裡,最不該省略的一段。
1. Redis Lock TTL 與 DB Transaction 生命週期不一致
危險時序:
Process A
取得 Redis Lock(TTL = 5s)
進入 DB Transaction
lockForUpdate()
... 交易執行較久 ...
Redis Lock 先過期
Process B
取得同一個 Redis Lock
進入 DB Transaction
等待 A 的 row lock此時:
- 應用層以為「同一時間只有一個流程在做點數異動」
- 實際上 Redis 鎖已失效,第二個流程也進來了
- 最終仍要靠 DB row lock 硬撐
若你把 Redis Lock 當成正確性的唯一保障,這裡就會出問題。
實務建議:
- Redis Lock 的 TTL 必須明顯大於臨界區最壞執行時間
- 臨界區必須短,不能依賴「大概不會超時」
- 正確性仍應以 DB 約束與 row lock 為準,Redis Lock 只是降低競爭的外層互斥
2. 多資源時的經典死鎖(AB-BA)
若未來有轉點、合併、批次調整,一次鎖多個帳戶:
T1: lock account_1 → lock account_2
T2: lock account_2 → lock account_1MySQL 可能直接報:
SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock解法:
- 固定加鎖順序,例如永遠依 point_account_id 由小到大
- 捕捉 deadlock 後做有限次重試
- 重試要加 jitter,避免所有 victim 同時重撞
示意:
function withDeadlockRetry(callable $callback, int $times = 3)
{
beginning:
try {
return DB::transaction($callback);
} catch (Throwable $e) {
if ($times > 1 && isDeadlock($e)) {
$times--;
usleep(random_int(50_000, 150_000));
goto beginning;
}
throw $e;
}
}
function isDeadlock(Throwable $e): bool
{
$message = $e->getMessage();
return str_contains($message, '1213')
|| str_contains($message, 'Deadlock found')
|| str_contains(strtolower($message), 'deadlock');
}3. 長交易會把鎖競爭變成系統問題
即使沒有經典死鎖,只要 lockForUpdate 持有太久,也會出現:
- Lock wait timeout
- API 延遲升高
- 重試風暴
- 連線池被佔滿
所以並發安全不只是「有沒有加鎖」,而是:
鎖的粒度、持有時間、失敗重試、超時策略是否合理。
六、三層機制分別解決什麼,不要互相取代
完整保護鏈可以是:
Request
→ Redis Distributed Lock(可選,視部署與臨界區範圍)
→ DB Transaction
→ SELECT ... FOR UPDATE
→ 更新 PointAccount
→ 建立 PointTransaction
→ Commit
→ Release Redis Lock對應關係:
| 機制 | 解決的問題 | 不該誤用成 |
|---|---|---|
| Redis Lock | 多實例下同一業務對象的互斥 | DB 正確性的唯一保障 |
| DB Transaction | 多語句原子性 | 分散式互斥 |
| lockForUpdate() | 同一 row 的並發讀寫正確性 | 跨服務協調 |
| Unique Constraint | 資料層最後防線 | 業務重試語意 |
| Idempotency-Key | 同一請求被重送 | 並發扣點控制 |
一句話:
Redis Lock 管入口;Transaction 管原子;Row Lock 管餘額讀寫;Constraint 管底線;Idempotency 管重試。
七、PointAccount 不存在時,也是並發問題
會員第一次使用點數時,帳戶可能尚未建立:
Request A → 發現沒有 PointAccount
Request B → 發現沒有 PointAccount若兩邊都建立,可能撞 Unique Constraint,或產生重複帳戶。
較穩健的做法:
- 先查 PointAccount
- 不存在就建立
- 建立後重新取得並 lockForUpdate
- 若觸發 Unique 衝突,改查既有帳戶再鎖
重點是:
Application Lock 降低競爭,Database Unique Constraint 做最後保護。
兩者要搭配,不是二選一。
八、Idempotency 與 Concurrency 是兩件事
即便並發控制完整,仍可能遇到:
Client 送出扣點
→ 伺服器已成功
→ 回應因網路遺失
→ Client 重試
→ 又扣一次這不是 Race Condition,而是重複提交。
解法是 Idempotency-Key:
Idempotency-Key: unique-key-12345- 第一次:建立交易並回傳結果
- 相同 Key 再來:直接回傳第一次結果,不重複入帳
對照:
| 問題類型 | 典型場景 | 解法 |
|---|---|---|
| Concurrency | 多個不同請求同時扣同一人點數 | Lock + Transaction + Row Lock |
| Idempotency | 同一業務請求因重試被送多次 | Idempotency-Key + 唯一約束 |
完整的點數 API 兩者都要有。
九、Sequential Test 不是 Concurrency Test
很多測試會長這樣:
for ($i = 0; $i < 100; $i++) {
$this->pointService->earn(...);
}最後驗證:
balance = 1000
transaction count = 100這能證明:
- 連續執行下計算正確
- 交易有寫入
- 基本 Rollback 行為可能正常
但這不是真正的並發測試,因為它是同一個 process 依序執行:
Request 1 完成
→ Request 2 完成
→ Request 3 完成而不是:
Request 1 ─┐
Request 2 ─┼─ 同時競爭同一 customer
Request 3 ─┘真正該測的場景
假設:
balance = 100
同時 10 個 redeem,每個扣 20預期:
成功 5
失敗 5
最終 balance = 0
redeem 交易 5 筆
不會出現負數這才能驗證:
- 餘額判斷看到的是最新值
- 不會超扣
- 鎖與交易在競爭下仍收斂到正確狀態
若要更接近生產,還應覆蓋:
- 多 process / 多 worker 同時打同一顧客
- 刻意製造 deadlock 後是否會重試並最終正確
- Redis lock 過期或獲取失敗時的行為
- 第一次建立 PointAccount 的併發建立
十、目前測試應誠實區分「已驗證」與「尚未驗證」
較精準的表述應是:
已完成點數交易的原子性、Rollback、餘額不足拒絕、Lock 基本行為、帳戶自動建立、Idempotency 與連續操作正確性驗證。
真正的跨 process 高併發競爭、死鎖重試收斂,需要另行設計 concurrency test 驗證。
不要因為測試類別名叫 ConcurrencyTest,就宣稱並發已完全驗證。
測試名稱不能代替測試內容。
十一、PointTransaction 不是附屬日誌,而是審計核心
一筆點數交易至少應能回答:
- 交易前餘額多少
- 異動多少
- 交易後餘額多少
- 誰觸發的
- 哪個租戶
- 對應哪筆外部參考號
因此建議保存:
balance_before
amount
balance_after
type
customer_id
point_account_id
tenant_id
description
created_by
reference
idempotency_key點數系統最後一定會走到對帳、客服追查、外部系統爭議處理。
沒有完整交易軌跡,系統只是「當下數字看起來對」,不是「可審計」。
十二、建議的整體架構
External System
→ Authentication / Tenant Resolution
→ Point Transaction API
→ Idempotency Check
→ PointService
→ Redis Lock(可選)
→ DB Transaction
→ lockForUpdate(PointAccount)
→ Update Account
→ Create PointTransaction
→ Commit / Rollback
→ Release Lock分層職責:
| 層級 | 職責 |
|---|---|
| API / Auth / Tenant | 身份、租戶隔離 |
| Idempotency | 避免重試重複入帳 |
| Redis Lock | 多實例互斥(可選) |
| DB Transaction | 原子性 |
| lockForUpdate | 餘額並發正確性 |
| Unique Constraint | 防重複帳戶/重複關鍵交易 |
| PointTransaction | 審計與追蹤 |
| Deadlock Retry | 讓暫時性衝突可恢復 |
十三、設計點數系統時真正該問的問題
重點從來不是:
$balance += $amount;而是:
- 多個 Request 同時進來怎麼辦?
- 執行到一半失敗怎麼辦?
- Client Retry 怎麼辦?
- 多個 Application Instance 同時操作怎麼辦?
- 第一次建立帳戶發生競爭怎麼辦?
- Redis Lock 過期但 DB 交易未結束怎麼辦?
- 出現 MySQL Deadlock 時怎麼重試、重試幾次、如何避免重試風暴?
- 如何證明最終餘額與交易筆數一致?
- 如何追蹤每一筆點數變化?
因此,點數系統的核心能力應是:
Atomicity
Concurrency Control
Deadlock Resilience
Idempotency
Tenant Isolation
Auditability結語
一個可靠的 Laravel 點數系統,不是「加了 Redis Lock 和 lockForUpdate 就結束」。
更完整的標準是:
- 正常路徑正確
- 競爭路徑正確
- 失敗路徑可回滾
- 重試路徑不重複入帳
- 死鎖路徑可恢復
- 測試能區分 sequential 與真正 concurrent
- 每筆異動可審計
並發控制的本質,不是把鎖堆得越多越好,而是:
清楚每一把鎖保護什麼、會在什麼情況下失敗,以及失敗後系統如何回到一致狀態。
只有把這些問題都設計進去,並用對的測試驗證,點數系統才不只是功能可跑,而是開始具備面對真實流量與外部整合的可靠性。