2026年9月15日 星期二

Laravel 點數系統的並發一致性:從 Redis Lock 到真正的 Concurrency Test

 Laravel 點數系統的並發一致性:從 Redis Lock 到真正的 Concurrency Test

您可以透過以下 GitHub 連結檢閱本專案的原始碼:https://github.com/BpsEason/loyalty-api.git

在點數系統裡,「加點」和「扣點」看起來只是簡單的數字加減:

text
balance + 100
balance - 20

但當系統開始面對真實的 API 流量後,問題就不再只是 CRUD。 例如一個會員目前有 100 點,同時收到多個扣點請求:

text
Request A:扣 20
Request B:扣 20
Request C:扣 20
...

如果多個 Request 同時讀到相同的餘額,再各自更新資料,就可能產生資料競爭(Race Condition)。 因此,點數系統真正需要處理的問題是: 如何確保多個請求同時操作同一個會員的點數時,最終餘額與交易紀錄仍然一致? 這也是我在 Laravel 點數系統中實作並發控制時,實際遇到的一個問題。 一、點數系統真正需要保護的是「整個交易流程」 單純使用:

text
$account->balance += $amount;
$account->save();

並不足以保證資料一致性。 因為一次點數操作其實包含多個步驟:

text
取得 PointAccount
        ↓
讀取目前餘額
        ↓
檢查餘額是否足夠
        ↓
更新 balance
        ↓
更新統計欄位
        ↓
建立 PointTransaction

這些操作必須被視為一個完整的交易單位。 因此目前的 PointService 將核心流程放在:

text
DB::transaction(function () {
    // PointAccount 更新
    // PointTransaction 建立
});

這樣當流程中任何一步發生例外時,資料庫可以一起 Rollback。 例如:

text
balance 更新成功
        ↓
PointTransaction 建立失敗
        ↓
Exception
        ↓
Transaction Rollback
        ↓
balance 恢復

這避免只更新了一半資料。 二、Database Transaction 解決的是 Atomicity DB::transaction() 很重要,但它本身並不代表所有並發問題都解決了。 Transaction 主要解決的是: 這一組資料庫操作要嘛全部成功,要嘛全部失敗。 例如:

text
DB::transaction(function () {
    $account->update([
        'balance' => 50,
    ]);

    PointTransaction::create([
        // ...
    ]);
});

如果後面發生例外:

text
throw new RuntimeException('Something went wrong');

前面的資料庫修改也會被 Rollback。 因此測試中也驗證了:

text
原本 balance = 100

更新 balance → 50
建立交易紀錄
發生 Exception

↓

Rollback

balance = 100
transaction count 沒有增加

但這仍然不是完整的並發控制。 三、Row Lock:SELECT ... FOR UPDATE 因此在取得 PointAccount 時,另外使用:

text
$customer->pointAccount()
    ->lockForUpdate()
    ->first();

Laravel 的 lockForUpdate() 對應到資料庫層的 Row-Level Lock。 概念上相當於:

text
SELECT *
FROM point_accounts
WHERE customer_id = ?
FOR UPDATE;

當 Transaction 還沒有結束時,其他交易如果也想取得相同資料列的寫入鎖,就必須等待。 這對點數扣除非常重要。 例如:

text
Account balance = 100

Transaction A
    ↓
SELECT ... FOR UPDATE
    ↓
取得鎖
    ↓
balance = 100
    ↓
扣 80
    ↓
balance = 20

Transaction B
    ↓
等待 A 完成
    ↓
取得最新 balance = 20
    ↓
發現不足 80
    ↓
拒絕交易

因此可以避免兩個交易同時根據舊餘額進行扣點。 四、為什麼還需要 Redis Distributed Lock? 如果只有資料庫 Row Lock,資料庫本身可以控制同一筆資料的交易競爭。 但目前點數服務的設計又增加了一層:

text
$lock = Cache::lock($lockKey, $this->lockTTL);

$lock->block($this->lockWaitSeconds, function () {
    return DB::transaction(function () {
        // ...
    });
});

Lock Key 使用:

text
point_customer:{customer_id}:tenant:{tenant_id}

也就是: 同一個 Tenant 下的同一個 Customer,使用同一把分散式鎖。 這樣如果系統未來由多個 Application Instance 處理 Request:

text
Application A ─┐
Application B ─┼── Redis Lock ── Customer
Application C ─┘

不同 Application Instance 仍然可以透過 Redis 協調。 這就是 Distributed Lock 的價值。 五、Redis Lock、DB Transaction、Row Lock 是不同層次 這三個東西並不是互相取代。 可以把它理解成:

text
Redis Distributed Lock
        ↓
控制「同一個業務對象」的操作競爭

DB Transaction
        ↓
確保一組資料庫操作 Atomic

SELECT ... FOR UPDATE
        ↓
確保資料庫 Row 在 Transaction 中受到並發保護

也就是:

text
Request
   ↓
Redis Lock
   ↓
DB Transaction
   ↓
SELECT ... FOR UPDATE
   ↓
更新 PointAccount
   ↓
建立 PointTransaction
   ↓
Commit
   ↓
Release Redis Lock

這樣才能形成完整的保護鏈。 六、PointAccount 不存在時也是一個並發問題 另外一個容易被忽略的情況是: 如果會員第一次使用點數,PointAccount 還不存在呢? 例如:

text
Request A → 發現沒有 PointAccount
Request B → 發現沒有 PointAccount

如果沒有額外處理,兩個 Request 都可能嘗試建立帳戶。 目前的實作會:

  1. 先查詢 PointAccount
  2. 不存在就建立
  3. 建立後重新取得並鎖定
  4. 如果資料庫 Unique Constraint 發生衝突,重新取得已存在的帳戶 概念上:
text
Customer
           ↓
   PointAccount exists?
       /          \
     Yes           No
      ↓             ↓
 lockForUpdate    Create
                    ↓
             Unique Constraint
                    ↓
             重新取得 Account

這裡有一個重要觀念: Application Lock 與 Database Constraint 應該互相搭配,而不是只依賴其中一個。 Redis Lock 可以降低競爭。 Database Unique Constraint 則是最後一層資料完整性保護。 七、Idempotency 解決的是另一種問題 即使並發控制完整,也還有另一種常見問題:

text
外部系統
    ↓
POST /point-transactions
    ↓
伺服器其實已成功
    ↓
Response 因網路問題沒有收到
    ↓
Client Retry
    ↓
又送一次

這時候問題不是 Race Condition,而是: 同一個業務請求被重送。 所以目前 Point Transaction API 已經加入:

text
Idempotency-Key

例如:

text
Idempotency-Key: unique-key-12345

第一次:

text
Idempotency-Key = ABC
amount = 100

→ 建立交易

第二次:

text
Idempotency-Key = ABC
amount = 100

→ 不重新建立交易
→ 返回第一次交易結果

這和 Redis Lock 解決的是不同問題。 八、Concurrency 與 Idempotency 不應該混在一起 這兩個概念很容易被混淆。 Concurrency 解決: 多個不同 Request 同時操作同一筆資料。 例如:

text
Request A → redeem 20
Request B → redeem 20
Request C → redeem 20

Idempotency 解決: 同一個業務 Request 因 Retry 被送多次。 例如:

text
Request A
Idempotency-Key = ABC

Request A Retry
Idempotency-Key = ABC

因此完整的點數 API 需要同時考慮:

text
Point API
                   │
        ┌──────────┴──────────┐
        ↓                     ↓
   Concurrency            Idempotency
        ↓                     ↓
 Redis Lock             Idempotency-Key
 DB Transaction         避免重複交易
 Row Lock

九、測試過程中發現一個很重要的問題 目前測試已經包含:

text
for ($i = 0; $i < 100; $i++) {
    $this->pointService->earn(...);
}

例如測試:

text
100 次 Earn × 10

最後驗證:

text
balance = 1000
total_earned = 1000
transaction count = 100

這個測試可以驗證 PointService 的連續操作沒有出現計算錯誤。 但是後來會發現: 這其實不是真正的 Concurrent Test。 因為這段程式是同一個 Process 依序執行:

text
Request 1
  ↓
完成

Request 2
  ↓
完成

Request 3
  ↓
完成

...

Request 100

而不是:

text
Request 1 ─┐
Request 2 ─┤
Request 3 ─┤
Request 4 ─┤
...        ├── 同時競爭
Request 100┘

這是測試點數系統時非常值得注意的一件事情。 十、Sequential Test 不等於 Concurrency Test 例如:

text
for ($i = 0; $i < 10; $i++) {
    $this->pointService->redeem(
        $this->customer,
        20
    );
}

最後可能得到:

text
成功 5 次
失敗 5 次
balance = 0

這個結果是正確的。 但它只能證明: PointService 在連續執行下可以正確處理餘額。 它不能完全證明: Redis Lock 與 Database Row Lock 在真正的多 Process / 多 Request 競爭下有效。 真正的 concurrency test 必須讓多個 execution context 同時競爭同一個 Customer。 十一、真正值得測的是這個場景 假設會員有:

text
balance = 100

同時送出:

text
10 個 redeem request
每個扣 20

預期結果:

text
成功:5
失敗:5

最終 balance:
0

total_redeemed:
100

Redeem transaction:
5 筆

這個測試真正要證明的是:

text
10 個 Request
      ↓
同時競爭
      ↓
Redis Distributed Lock
      ↓
DB Transaction
      ↓
SELECT ... FOR UPDATE
      ↓
每次取得正確最新餘額
      ↓
不會出現負數

這才是點數系統真正重要的並發測試。 十二、目前測試已經涵蓋哪些能力? 目前的測試已經涵蓋不少重要場景: 正常操作

text
Earn
Redeem

餘額不足

text
balance = 20
redeem = 30

→ 拒絕
→ balance 不變
→ 不建立 Redeem transaction

Transaction Rollback

text
更新 Account
↓
建立 Transaction
↓
Exception
↓
Rollback

Redis Lock

text
Acquire
↓
Release
↓
再次 Acquire

PointAccount 自動建立

text
Customer
↓
沒有 PointAccount
↓
第一次 Earn
↓
建立 Account

Idempotency

text
第一次 Request
↓
建立交易

相同 Idempotency-Key
↓
返回既有交易
↓
不重複建立

這些都是點數系統應該具備的基本測試。 十三、但測試還需要區分「已驗證」與「尚未驗證」 這是我認為在工程上非常重要的習慣。 不能因為測試名稱叫:

text
PointTransactionConcurrencyTest

就直接認定: 「並發已經測試過了。」 真正應該看測試做了什麼。 目前的:

text
for (...) {
    $this->pointService->earn(...);
}

是 Sequential Execution。 因此比較精確的說法應該是: 目前已完成點數交易的原子性、交易回滾、Lock 基礎行為、餘額驗證與連續操作測試;真正的跨 Process / 多 Request 並發測試仍需要額外驗證。 這比單純宣稱「已完成並發測試」更加準確。 十四、點數系統的核心不是「把 balance 加減」 一個成熟的點數系統,真正需要思考的是:

text
Point System
                      │
       ┌──────────────┼──────────────┐
       ↓              ↓              ↓
   Data Model     Consistency     Reliability
       │              │              │
 PointAccount    Transaction      Idempotency
 PointTransaction  Row Lock       Retry
       │              │
       └──────────────┘
              ↓
       Auditability

其中 PointTransaction 不只是紀錄而已。 每一筆交易都保存:

text
balance_before
amount
balance_after
type
customer_id
point_account_id
tenant_id
description
created_by
reference

因此可以回答: 這次交易前有多少點? 操作了多少點? 操作後剩多少? 這對日後查帳、問題追蹤與外部系統整合都很重要。 十五、最後的架構思考 目前這套設計的核心概念可以整理成:

text
External System
      │
      │ REST API
      ↓
Authentication
      │
Tenant Resolution
      │
      ↓
Point Transaction API
      │
      ├── Idempotency
      │
      ↓
PointService
      │
      ↓
Redis Distributed Lock
      │
      ↓
DB Transaction
      │
      ↓
SELECT ... FOR UPDATE
      │
      ├── PointAccount
      │
      └── PointTransaction

每一層解決不同問題:

text
| 機制                | 解決問題           |
| ----------------- | -------------- |
| JWT               | API 身份驗證       |
| Tenant            | 租戶隔離           |
| Idempotency       | 防止同一請求重複執行     |
| Redis Lock        | 分散式操作競爭        |
| DB Transaction    | 保證資料庫操作 Atomic |
| lockForUpdate()   | 保護資料列並發更新      |
| Unique Constraint | 防止重複建立帳戶       |
| PointTransaction  | 保留點數操作歷史       |

這也是我認為點數系統開發最值得注意的地方: 不要只看「這個 API 能不能成功」,而要看「同一時間、重試、例外、資料庫失敗時,系統會不會留下錯誤狀態」。 結語 點數系統的難度從來不在:

text
$balance += $amount;

真正困難的是:

text
多個 Request 同時進來怎麼辦?

Request 執行一半失敗怎麼辦?

Client Retry 怎麼辦?

兩個 Application Instance 同時操作怎麼辦?

PointAccount 第一次建立時發生競爭怎麼辦?

如何確認交易前後的餘額?

如何追蹤每一次點數變化?

因此,設計點數系統時,我會把重點放在: Atomicity、Concurrency、Idempotency、Tenant Isolation、Auditability。 而測試也必須誠實區分: Sequential Test 能證明什麼,真正 Concurrent Test 又能證明什麼。 只有把這些問題都驗證清楚,點數系統才不只是「功能可以跑」,而是開始具備面對實際 API 流量與外部系統整合的可靠性。

沒有留言:

張貼留言

Laravel 點數系統的並發一致性:從 Redis Lock 到真正的 Concurrency Test

  Laravel 點數系統的並發一致性:從 Redis Lock 到真正的 Concurrency Test 您可以透過以下  GitHub  連結檢閱本專案的原始碼: https://github.com/BpsEason/loyalty-api.git 在點數系統裡,「加點...