Laravel 點數系統的並發一致性:從 Redis Lock 到真正的 Concurrency Test
您可以透過以下 GitHub 連結檢閱本專案的原始碼: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)。 因此,點數系統真正需要處理的問題是: 如何確保多個請求同時操作同一個會員的點數時,最終餘額與交易紀錄仍然一致? 這也是我在 Laravel 點數系統中實作並發控制時,實際遇到的一個問題。 一、點數系統真正需要保護的是「整個交易流程」 單純使用:
$account->balance += $amount;
$account->save();並不足以保證資料一致性。 因為一次點數操作其實包含多個步驟:
取得 PointAccount
↓
讀取目前餘額
↓
檢查餘額是否足夠
↓
更新 balance
↓
更新統計欄位
↓
建立 PointTransaction這些操作必須被視為一個完整的交易單位。 因此目前的 PointService 將核心流程放在:
DB::transaction(function () {
// PointAccount 更新
// PointTransaction 建立
});這樣當流程中任何一步發生例外時,資料庫可以一起 Rollback。 例如:
balance 更新成功
↓
PointTransaction 建立失敗
↓
Exception
↓
Transaction Rollback
↓
balance 恢復這避免只更新了一半資料。 二、Database Transaction 解決的是 Atomicity DB::transaction() 很重要,但它本身並不代表所有並發問題都解決了。 Transaction 主要解決的是: 這一組資料庫操作要嘛全部成功,要嘛全部失敗。 例如:
DB::transaction(function () {
$account->update([
'balance' => 50,
]);
PointTransaction::create([
// ...
]);
});如果後面發生例外:
throw new RuntimeException('Something went wrong');前面的資料庫修改也會被 Rollback。 因此測試中也驗證了:
原本 balance = 100
更新 balance → 50
建立交易紀錄
發生 Exception
↓
Rollback
balance = 100
transaction count 沒有增加但這仍然不是完整的並發控制。 三、Row Lock:SELECT ... FOR UPDATE 因此在取得 PointAccount 時,另外使用:
$customer->pointAccount()
->lockForUpdate()
->first();Laravel 的 lockForUpdate() 對應到資料庫層的 Row-Level Lock。 概念上相當於:
SELECT *
FROM point_accounts
WHERE customer_id = ?
FOR UPDATE;當 Transaction 還沒有結束時,其他交易如果也想取得相同資料列的寫入鎖,就必須等待。 這對點數扣除非常重要。 例如:
Account balance = 100
Transaction A
↓
SELECT ... FOR UPDATE
↓
取得鎖
↓
balance = 100
↓
扣 80
↓
balance = 20
Transaction B
↓
等待 A 完成
↓
取得最新 balance = 20
↓
發現不足 80
↓
拒絕交易因此可以避免兩個交易同時根據舊餘額進行扣點。 四、為什麼還需要 Redis Distributed Lock? 如果只有資料庫 Row Lock,資料庫本身可以控制同一筆資料的交易競爭。 但目前點數服務的設計又增加了一層:
$lock = Cache::lock($lockKey, $this->lockTTL);
$lock->block($this->lockWaitSeconds, function () {
return DB::transaction(function () {
// ...
});
});Lock Key 使用:
point_customer:{customer_id}:tenant:{tenant_id}也就是: 同一個 Tenant 下的同一個 Customer,使用同一把分散式鎖。 這樣如果系統未來由多個 Application Instance 處理 Request:
Application A ─┐
Application B ─┼── Redis Lock ── Customer
Application C ─┘不同 Application Instance 仍然可以透過 Redis 協調。 這就是 Distributed Lock 的價值。 五、Redis Lock、DB Transaction、Row Lock 是不同層次 這三個東西並不是互相取代。 可以把它理解成:
Redis Distributed Lock
↓
控制「同一個業務對象」的操作競爭
DB Transaction
↓
確保一組資料庫操作 Atomic
SELECT ... FOR UPDATE
↓
確保資料庫 Row 在 Transaction 中受到並發保護也就是:
Request
↓
Redis Lock
↓
DB Transaction
↓
SELECT ... FOR UPDATE
↓
更新 PointAccount
↓
建立 PointTransaction
↓
Commit
↓
Release Redis Lock這樣才能形成完整的保護鏈。 六、PointAccount 不存在時也是一個並發問題 另外一個容易被忽略的情況是: 如果會員第一次使用點數,PointAccount 還不存在呢? 例如:
Request A → 發現沒有 PointAccount
Request B → 發現沒有 PointAccount如果沒有額外處理,兩個 Request 都可能嘗試建立帳戶。 目前的實作會:
- 先查詢 PointAccount
- 不存在就建立
- 建立後重新取得並鎖定
- 如果資料庫 Unique Constraint 發生衝突,重新取得已存在的帳戶 概念上:
Customer
↓
PointAccount exists?
/ \
Yes No
↓ ↓
lockForUpdate Create
↓
Unique Constraint
↓
重新取得 Account這裡有一個重要觀念: Application Lock 與 Database Constraint 應該互相搭配,而不是只依賴其中一個。 Redis Lock 可以降低競爭。 Database Unique Constraint 則是最後一層資料完整性保護。 七、Idempotency 解決的是另一種問題 即使並發控制完整,也還有另一種常見問題:
外部系統
↓
POST /point-transactions
↓
伺服器其實已成功
↓
Response 因網路問題沒有收到
↓
Client Retry
↓
又送一次這時候問題不是 Race Condition,而是: 同一個業務請求被重送。 所以目前 Point Transaction API 已經加入:
Idempotency-Key例如:
Idempotency-Key: unique-key-12345第一次:
Idempotency-Key = ABC
amount = 100
→ 建立交易第二次:
Idempotency-Key = ABC
amount = 100
→ 不重新建立交易
→ 返回第一次交易結果這和 Redis Lock 解決的是不同問題。 八、Concurrency 與 Idempotency 不應該混在一起 這兩個概念很容易被混淆。 Concurrency 解決: 多個不同 Request 同時操作同一筆資料。 例如:
Request A → redeem 20
Request B → redeem 20
Request C → redeem 20Idempotency 解決: 同一個業務 Request 因 Retry 被送多次。 例如:
Request A
Idempotency-Key = ABC
Request A Retry
Idempotency-Key = ABC因此完整的點數 API 需要同時考慮:
Point API
│
┌──────────┴──────────┐
↓ ↓
Concurrency Idempotency
↓ ↓
Redis Lock Idempotency-Key
DB Transaction 避免重複交易
Row Lock九、測試過程中發現一個很重要的問題 目前測試已經包含:
for ($i = 0; $i < 100; $i++) {
$this->pointService->earn(...);
}例如測試:
100 次 Earn × 10最後驗證:
balance = 1000
total_earned = 1000
transaction count = 100這個測試可以驗證 PointService 的連續操作沒有出現計算錯誤。 但是後來會發現: 這其實不是真正的 Concurrent Test。 因為這段程式是同一個 Process 依序執行:
Request 1
↓
完成
Request 2
↓
完成
Request 3
↓
完成
...
Request 100而不是:
Request 1 ─┐
Request 2 ─┤
Request 3 ─┤
Request 4 ─┤
... ├── 同時競爭
Request 100┘這是測試點數系統時非常值得注意的一件事情。 十、Sequential Test 不等於 Concurrency Test 例如:
for ($i = 0; $i < 10; $i++) {
$this->pointService->redeem(
$this->customer,
20
);
}最後可能得到:
成功 5 次
失敗 5 次
balance = 0這個結果是正確的。 但它只能證明: PointService 在連續執行下可以正確處理餘額。 它不能完全證明: Redis Lock 與 Database Row Lock 在真正的多 Process / 多 Request 競爭下有效。 真正的 concurrency test 必須讓多個 execution context 同時競爭同一個 Customer。 十一、真正值得測的是這個場景 假設會員有:
balance = 100同時送出:
10 個 redeem request
每個扣 20預期結果:
成功:5
失敗:5
最終 balance:
0
total_redeemed:
100
Redeem transaction:
5 筆這個測試真正要證明的是:
10 個 Request
↓
同時競爭
↓
Redis Distributed Lock
↓
DB Transaction
↓
SELECT ... FOR UPDATE
↓
每次取得正確最新餘額
↓
不會出現負數這才是點數系統真正重要的並發測試。 十二、目前測試已經涵蓋哪些能力? 目前的測試已經涵蓋不少重要場景: 正常操作
Earn
Redeem餘額不足
balance = 20
redeem = 30
→ 拒絕
→ balance 不變
→ 不建立 Redeem transactionTransaction Rollback
更新 Account
↓
建立 Transaction
↓
Exception
↓
RollbackRedis Lock
Acquire
↓
Release
↓
再次 AcquirePointAccount 自動建立
Customer
↓
沒有 PointAccount
↓
第一次 Earn
↓
建立 AccountIdempotency
第一次 Request
↓
建立交易
相同 Idempotency-Key
↓
返回既有交易
↓
不重複建立這些都是點數系統應該具備的基本測試。 十三、但測試還需要區分「已驗證」與「尚未驗證」 這是我認為在工程上非常重要的習慣。 不能因為測試名稱叫:
PointTransactionConcurrencyTest就直接認定: 「並發已經測試過了。」 真正應該看測試做了什麼。 目前的:
for (...) {
$this->pointService->earn(...);
}是 Sequential Execution。 因此比較精確的說法應該是: 目前已完成點數交易的原子性、交易回滾、Lock 基礎行為、餘額驗證與連續操作測試;真正的跨 Process / 多 Request 並發測試仍需要額外驗證。 這比單純宣稱「已完成並發測試」更加準確。 十四、點數系統的核心不是「把 balance 加減」 一個成熟的點數系統,真正需要思考的是:
Point System
│
┌──────────────┼──────────────┐
↓ ↓ ↓
Data Model Consistency Reliability
│ │ │
PointAccount Transaction Idempotency
PointTransaction Row Lock Retry
│ │
└──────────────┘
↓
Auditability其中 PointTransaction 不只是紀錄而已。 每一筆交易都保存:
balance_before
amount
balance_after
type
customer_id
point_account_id
tenant_id
description
created_by
reference因此可以回答: 這次交易前有多少點? 操作了多少點? 操作後剩多少? 這對日後查帳、問題追蹤與外部系統整合都很重要。 十五、最後的架構思考 目前這套設計的核心概念可以整理成:
External System
│
│ REST API
↓
Authentication
│
Tenant Resolution
│
↓
Point Transaction API
│
├── Idempotency
│
↓
PointService
│
↓
Redis Distributed Lock
│
↓
DB Transaction
│
↓
SELECT ... FOR UPDATE
│
├── PointAccount
│
└── PointTransaction每一層解決不同問題:
| 機制 | 解決問題 |
| ----------------- | -------------- |
| JWT | API 身份驗證 |
| Tenant | 租戶隔離 |
| Idempotency | 防止同一請求重複執行 |
| Redis Lock | 分散式操作競爭 |
| DB Transaction | 保證資料庫操作 Atomic |
| lockForUpdate() | 保護資料列並發更新 |
| Unique Constraint | 防止重複建立帳戶 |
| PointTransaction | 保留點數操作歷史 |這也是我認為點數系統開發最值得注意的地方: 不要只看「這個 API 能不能成功」,而要看「同一時間、重試、例外、資料庫失敗時,系統會不會留下錯誤狀態」。 結語 點數系統的難度從來不在:
$balance += $amount;真正困難的是:
多個 Request 同時進來怎麼辦?
Request 執行一半失敗怎麼辦?
Client Retry 怎麼辦?
兩個 Application Instance 同時操作怎麼辦?
PointAccount 第一次建立時發生競爭怎麼辦?
如何確認交易前後的餘額?
如何追蹤每一次點數變化?因此,設計點數系統時,我會把重點放在: Atomicity、Concurrency、Idempotency、Tenant Isolation、Auditability。 而測試也必須誠實區分: Sequential Test 能證明什麼,真正 Concurrent Test 又能證明什麼。 只有把這些問題都驗證清楚,點數系統才不只是「功能可以跑」,而是開始具備面對實際 API 流量與外部系統整合的可靠性。
沒有留言:
張貼留言