2026年9月15日 星期二

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

 

Laravel 點數系統並發一致性:從 Race Condition、Deadlock 到真正的 Concurrency Test

在點數、錢包與支付系統中,balance += amount 從來不只是單純的 CRUD。

當多個 Request 同時操作同一個會員的點數帳戶時,真正需要處理的是 Lost Update、超額扣點、Deadlock、Lock Timeout、重試造成的重複扣點,以及交易資料與餘額不一致。

本文以 Laravel Multi-Tenant Loyalty / Point API 為例,從資料庫 Transaction、Row Lock、Idempotency、Lock Ordering 到真正的 Concurrency Test,整理一套可以落地的並發控制思路。


一、點數操作不是單純的欄位修改

一筆點數扣除,在業務上其實是一個不可分割的 Atomic Operation。

典型流程如下:

取得 Point Account
        ↓
鎖定共享資料
        ↓
讀取目前餘額
        ↓
檢查餘額是否足夠
        ↓
計算新的餘額
        ↓
更新 Point Account
        ↓
建立 Point Transaction
        ↓
Commit

這些操作不能被拆成彼此獨立的資料庫操作。

例如:

PointAccount.balance = 100

Request A:扣除 80
Request B:扣除 80

如果兩個 Request 都先讀到 100:

A:讀取 100
B:讀取 100

A:計算 100 - 80 = 20
B:計算 100 - 80 = 20

A:寫入 20
B:寫入 20

最後資料庫可能得到:

balance = 20

但實際上已經扣除了:

80 + 80 = 160

這就是典型的 Lost Update / Race Condition。

因此點數系統的核心問題不是:

「如何更新 balance?」

而是:

「如何確保多個 Request 同時操作同一個共享狀態時,結果仍然符合業務不變量?」


二、先釐清五種機制各自負責什麼

在實作並發控制時,很容易把 Redis Lock、DB Transaction、Row Lock、Unique Constraint 與 Idempotency 混在一起。

它們其實解決的是不同問題。

Transaction 管原子性,Row Lock 管共享資料競爭,Constraint 管資料庫最後一道防線,Idempotency 管重試,Distributed Lock 則可以降低應用程式層的競爭。

機制核心責任主要解決問題不能單獨解決
DB TransactionAtomicity多個 DB 操作一起成功或 Rollback不會自動阻止 Race Condition
Row Lock (lockForUpdate)DB 層共享資料互斥Lost Update、超額扣點不負責 Request 重試
Redis Distributed Lock應用程式層協調降低同一資源的 DB 競爭不能取代 DB Transaction / Row Lock
Unique ConstraintDB 最後防線防止重複資料建立不負責一般數值異動
Idempotency Key業務請求去重Network Timeout / Retry不等於 Concurrency Control

這裡最重要的觀念是:

Redis Lock 不是資料一致性的最終保證。

即使沒有 Redis Lock,只要 DB Transaction、Row Lock、Constraint 與業務邏輯正確,資料庫仍然可以維持正確性。

Redis Lock 的價值比較接近:

把部分競爭提前在應用程式層處理,減少大量 Request 同時壓向資料庫的情況。


三、核心原則:Transaction + Row Lock

對單一會員點數帳戶而言,最重要的 DB 層防線是:

DB::transaction(...)

搭配:

->lockForUpdate()

典型流程:

return DB::transaction(function () use (
    $tenantId,
    $customerId,
    $amount,
    $idempotencyKey
) {
    $account = PointAccount::where('tenant_id', $tenantId)
        ->where('customer_id', $customerId)
        ->lockForUpdate()
        ->firstOrFail();

    if ($account->balance < $amount) {
        throw new InsufficientBalanceException(
            'Balance not enough.'
        );
    }

    $balanceBefore = $account->balance;
    $balanceAfter = $balanceBefore - $amount;

    $account->update([
        'balance' => $balanceAfter,
    ]);

    return PointTransaction::create([
        'tenant_id' => $tenantId,
        'point_account_id' => $account->id,
        'customer_id' => $customerId,
        'type' => 'REDEEM',
        'amount' => -$amount,
        'balance_before' => $balanceBefore,
        'balance_after' => $balanceAfter,
        'idempotency_key' => $idempotencyKey,
    ]);
}, 3);

這裡有三個重要設計:

1. DB::transaction

確保:

PointAccount 更新
+
PointTransaction 建立

屬於同一個資料庫交易。

如果其中任何一步失敗:

Rollback

而不是留下:

balance 已扣除
transaction 卻不存在

這種不一致狀態。

2. lockForUpdate()

它讓目前 Transaction 對該 Row 取得排他鎖。

因此第二個 Request 在嘗試取得同一筆 Row 時,需要等待第一個 Transaction 完成。

流程會變成:

Request A
    ↓
FOR UPDATE
    ↓
balance = 100
    ↓
扣除 80
    ↓
balance = 20
    ↓
COMMIT
    ↓
釋放 Lock

Request B
    ↓
取得最新 balance = 20
    ↓
檢查 20 < 80
    ↓
InsufficientBalanceException

因此不會發生兩個 Request 都看到 100 的問題。

3. Transaction Retry

Laravel 的 Transaction 可以針對 Deadlock 等資料庫交易衝突進行有限次數的重試:

DB::transaction($callback, 3);

但這不是萬靈丹。

真正的設計仍然應該優先降低 Deadlock 發生機率,而不是完全依賴 Retry。


四、首次建立 Point Account 也存在 Race Condition

另一個常被忽略的問題是:

如果會員第一次使用點數,PointAccount 還不存在怎麼辦?

例如:

Request A:SELECT account → 不存在
Request B:SELECT account → 不存在

Request A:INSERT
Request B:INSERT

這時不能只依靠:

if (!$account) {
    PointAccount::create(...);
}

因為兩個 Request 都可能同時通過 if。

因此資料庫應該建立唯一約束:

$table->unique(
    ['tenant_id', 'customer_id'],
    'uk_point_account_tenant_customer'
);

讓資料庫成為最後一道防線。

概念上:

Application Check
        ↓
Database Unique Constraint
        ↓
Concurrent INSERT
        ↓
只有一筆成功

如果另一個 Request 收到 Unique Constraint violation,可以重新取得帳戶並進入正常的 Row Lock 流程。

這也是一個很重要的原則:

不要只相信 Application Logic,資料庫 Constraint 必須負責最後一道資料完整性防線。


五、Multi-Tenant 下的 Idempotency

Idempotency 是 Loyalty API 非常重要的一層。

假設 Client 發送:

POST /points/redeem
Idempotency-Key: abc-123

Request 已經在 Laravel 成功扣點,但 Client 因為網路 Timeout 沒收到 Response。

Client 很可能會重新送一次:

POST /points/redeem
Idempotency-Key: abc-123

如果沒有 Idempotency:

第一次:扣 100
第二次:再扣 100

就會造成重複扣點。

因此需要讓相同業務請求只能成功一次。

而在 Multi-Tenant 系統中,Idempotency Key 必須考慮 Tenant Boundary。

例如:

Tenant A + abc-123
Tenant B + abc-123

兩者不應該互相影響。

因此可以使用 Composite Unique Index:

$table->unique(
    ['tenant_id', 'idempotency_key'],
    'uk_tenant_idempotency'
);

概念變成:

Tenant A + abc-123 → 已存在
Tenant B + abc-123 → 可以正常執行

這樣才能維持租戶隔離。


六、Deadlock:真正需要注意的是 Lock Ordering

單一帳戶操作通常只需要鎖定一筆 PointAccount。

但當業務開始出現:

  • 會員轉點
  • 帳戶間 Transfer
  • 批次扣款
  • 多帳戶結算

就可能同時鎖定多筆資料。

例如:

Request A:

Lock Account 1
    ↓
等待 Account 2


Request B:

Lock Account 2
    ↓
等待 Account 1

形成:

A → 等 B
B → 等 A

這就是典型的 AB-BA Deadlock。


七、解法:固定 Lock Order

最簡單且重要的原則是:

所有需要鎖定多筆帳戶的操作,都必須使用一致的 Lock Order。

例如按照 Account ID 升冪:

$accounts = PointAccount::whereIn(
        'customer_id',
        [$fromCustomerId, $toCustomerId]
    )
    ->orderBy('id', 'asc')
    ->lockForUpdate()
    ->get()
    ->keyBy('customer_id');

假設:

Account 1
Account 2

無論誰發起 Transfer:

A → 1 → 2
B → 1 → 2

而不是:

A → 1 → 2
B → 2 → 1

如此可以大幅降低 AB-BA Deadlock 的可能性。

這是一個非常值得寫進團隊開發規範的原則:

Multi-row transaction 必須有明確且一致的 Lock Ordering。


八、Sequential Test 不等於 Concurrency Test

這是點數系統測試最容易被忽略的地方。

例如:

for ($i = 0; $i < 10; $i++) {
    $service->redeem(
        $tenantId,
        $customerId,
        20,
        "key-$i"
    );
}

這不是 Concurrency Test。

它只是:

Sequential Test

實際執行順序是:

Request 1
    ↓
Commit

Request 2
    ↓
Commit

Request 3
    ↓
Commit

整個過程沒有真正的 contention。

因此:

測試跑了 10 次,不代表測試了並發。

真正的 Concurrency Test 至少需要驗證:

  1. 多個 Request / Process 同時執行
  2. 同時競爭相同共享資料
  3. 驗證最終資料不變量
  4. 驗證成功交易數量
  5. 驗證餘額
  6. 驗證 Audit Trail
  7. 驗證不會出現負餘額

九、真正的 Concurrency Test

例如:

初始餘額:

100 points

同時:

10 Requests
每次扣除 20 points

理論結果:

成功:5
失敗:5

最終餘額:0

而不是:

成功:10
最終餘額:-100

也不是:

成功:10
最終餘額:20

測試真正應該驗證的是 Invariant。

例如:

$this->assertGreaterThanOrEqual(
    0,
    $account->balance
);

$this->assertEquals(
    0,
    $account->balance
);

$this->assertCount(
    5,
    $successfulTransactions
);

再進一步驗證:

$totalRedeemed = $successfulTransactions->sum('amount');

$this->assertEquals(
    -100,
    $totalRedeemed
);

這比單純驗證:

$this->assertEquals(5, $successCount);

更有價值。

因為真正要保護的是:

資料不變量,而不是單一測試結果。


十、PointTransaction 不只是歷史紀錄

PointAccount.balance 告訴我們:

「現在有多少點?」

但它無法完整回答:

「為什麼現在是這個數字?」

因此:

PointAccount

負責目前狀態。

而:

PointTransaction

負責狀態變化的 Audit Trail。

例如:

14:01:22
EARN +500
balance: 0 → 500

14:02:10
REDEEM -200
balance: 500 → 300

14:03:55
REDEEM -100
balance: 300 → 200

這樣客服、財務或系統管理者才能追蹤:

Balance
    ↓
為什麼變成這個數字?
    ↓
哪些交易造成?
    ↓
哪個業務流程產生?

因此 PointTransaction 應該盡可能保留:

欄位用途
tenant_idMulti-Tenant 隔離
point_account_idPoint Account
customer_idCustomer
typeEARN / REDEEM / EXPIRE
amount本次異動量
balance_before異動前餘額
balance_after異動後餘額
reference_type業務來源
reference_id業務來源 ID
idempotency_key防止重複執行
created_at異動時間

其中最重要的資料不變量之一是:

balance_before + amount = balance_after

例如:

100 + (-20) = 80

如果:

100 + (-20) != 80

就代表 Transaction 資料與餘額狀態存在異常。

這也是非常適合做 Reconciliation 的檢查條件。


十一、完整交易責任鏈

把前面的設計整合起來,一個點數扣除 Request 可以形成以下流程:

Client Request
Idempotency-Key: abc-123
        │
        ▼
[1. Authentication / Tenant Resolution]
        │
        ▼
[2. Idempotency Check]
        │
        ├── 已存在 ──► 回傳既有結果
        │
        ▼
[3. Optional Distributed Lock]
        │
        ▼
[4. DB Transaction]
        │
        ▼
[5. SELECT ... FOR UPDATE]
        │
        ▼
[6. Balance Check]
        │
        ├── 不足 ──► Rollback
        │
        ▼
[7. Update PointAccount]
        │
        ▼
[8. Create PointTransaction]
        │
        ▼
[9. Commit]
        │
        ▼
Response

每一層都有自己的責任:

Authentication
    → 誰在操作?

Tenant Resolution
    → 操作哪個租戶?

Idempotency
    → 這個業務請求是否已經執行?

Distributed Lock
    → 是否需要降低應用層競爭?

Transaction
    → 多個 DB 操作是否一起成功?

Row Lock
    → 多個 Request 是否同時修改同一筆共享狀態?

Constraint
    → 資料庫是否阻止非法重複資料?

Audit Trail
    → 最終結果是否可以被追溯?

這才是完整的責任分離。


十二、Redis Lock 不是萬能解

很多系統在加入 Redis Lock 後,會產生一個錯誤觀念:

「有 Redis Lock,所以 DB 就安全了。」

這是不正確的。

Redis Lock 與 DB Lock 的責任不同。

即使 Redis 發生:

  • Crash
  • Network Partition
  • Lock Expiration
  • Client Timeout
  • Redis 暫時不可用

DB 層仍然應該具備自己的資料一致性保護。

因此真正的底線應該是:

Application Lock
        ↓
降低競爭

DB Transaction
        +
DB Row Lock
        +
DB Constraint
        ↓
真正的資料一致性

換句話說:

Redis Lock 可以是效能與流量控制策略,但不應該成為資料一致性的唯一防線。


十三、真正值得測試的是 Invariant

一個成熟的 Concurrency Test,不應該只問:

「這次有幾個 Request 成功?」

更應該問:

1. 餘額是否永遠不能小於零?

balance >= 0

2. 所有成功交易的總和是否正確?

initial_balance + sum(transaction.amount)
=
final_balance

3. 每筆 Transaction 是否一致?

balance_before + amount
=
balance_after

4. Idempotency 是否有效?

same tenant + same key
=
same business operation

5. Tenant 是否隔離?

Tenant A 的 Idempotency Key
≠
Tenant B 的 Idempotency Key

6. PointAccount 是否只有一筆?

tenant_id + customer_id
=
unique

這些才是點數系統真正需要長期守住的不變量。


十四、從 Code Review 角度看並發設計

如果在 Code Review 看到:

$account = PointAccount::find($id);

if ($account->balance >= $amount) {
    $account->balance -= $amount;
    $account->save();
}

第一個問題不是:

「這段 PHP 能不能執行?」

而是:

「兩個 Request 同時執行時會發生什麼?」

如果答案不清楚,就代表這段程式碼還沒有真正處理並發。

同樣地,如果看到:

Cache::lock(...)

也不應該直接認為:

「已經有並發控制。」

還要繼續問:

DB Transaction 在哪裡?

Row Lock 在哪裡?

Unique Constraint 在哪裡?

Idempotency 在哪裡?

Deadlock 怎麼處理?

Concurrency Test 在哪裡?

最終 Invariant 怎麼驗證?

這才是比較完整的 Code Review 思維。


十五、真正的資深設計不是堆更多 Lock

點數系統很容易陷入一種迷思:

Redis Lock
+
DB Lock
+
Transaction
+
Retry
+
Queue
+
Distributed Lock
+
更多 Middleware

最後程式碼看起來非常「高級」,但沒有人能回答:

「每一層到底在保護什麼?」

真正成熟的設計應該反過來:

先定義業務不變量
        ↓
找出共享狀態
        ↓
找出 Race Condition
        ↓
決定 DB 層保護
        ↓
決定是否需要 Application Lock
        ↓
加入 Idempotency
        ↓
處理 Deadlock / Retry
        ↓
用 Concurrency Test 驗證

而不是:

先加 Redis Lock
↓
再加 DB Lock
↓
再加 Retry
↓
希望它安全

十六、結語

設計一個可靠的 Laravel Loyalty / Point API,真正困難的從來不是:

$balance -= $amount;

而是要回答:

當 10 個、100 個甚至更多 Request 同時執行這行程式碼時,系統是否仍然能維持正確結果?

因此一套完整的點數並發控制,應該至少考慮:

Transaction
    ↓
Row Lock
    ↓
Unique Constraint
    ↓
Idempotency
    ↓
Lock Ordering
    ↓
Deadlock Retry
    ↓
Audit Trail
    ↓
Concurrency Test

其中每一層都有自己的責任。

而真正能證明架構可靠的,不是 README 裡寫了多少:

Redis
Lock
Transaction
Concurrency
Distributed System

而是你能不能用實際測試證明:

100 points
10 concurrent requests
20 points / request
        ↓
最多成功 5 次
        ↓
最終 balance = 0
        ↓
絕不出現負數
        ↓
Transaction 數量正確
        ↓
Audit Trail 與 Balance 一致

這才是從「可以運作」走向「可以承受並發」的關鍵。

用 FastAPI 模擬 POS/CRM 外部系統

多租戶會員點數管理 REST API

資深工程師不是把系統加得越複雜越好,而是能清楚說明每一道防線為什麼存在,以及拿掉它之後會發生什麼。



【Laravel 實戰】高併發交易系統下的分層防衛、死鎖防範與狀態一致性架構

在電商與高頻交易系統中,最令人夜不能寐的莫過於「超賣」、「負餘額」或「同一張優惠券被重複使用」。當萬級併發(High Concurrency)湧入時,單純寫 DB::transaction 或簡單加個 Redis Lock,往往會在極端情境(如 Redis TTL 過期、網路超時、DB 死鎖)下潰敗。

本文將透過我們團隊在 Laravel 生態系(PostgreSQL/MySQL + Redis) 中重構點數與優惠券模組的實戰經驗,分享如何設計出一套高可靠、具備分層防衛(Defense-in-Depth)且防死鎖的高併發交易架構。

1. 核心哲學:永遠保護「Domain Invariants(業務不變量)」

在設計任何併發控制前,我們先寫下了 5 個永遠不能打破的底線:

  1. 點數餘額永遠 $\ge 0$(絕不可以扣成負數)。

  2. 同一張優惠券只能被使用一次(No Double Spend)。

  3. 領取上限嚴格控管(總量不超發、每人限領量不突破)。

  4. 所有資金/點數變更必須留在不可變的審計軌跡(Auditable Ledger)。

  5. 租戶資料絕對隔離(Multi-Tenancy Isolation)。

2. 併發防禦架構:三層遞進式防護網

為了保證極端狀況下的正確性,我們設計了三層防衛機制:

Plaintext
┌─────────────────────────────────────────────────────────┐
│ Layer 1: Application Distributed Lock (Redis Lock)      │
│ 職責:檔掉 90% 的短時間併發流量,降低資料庫壓力         │
└────────────────────────────┬────────────────────────────┘
                             ↓
┌─────────────────────────────────────────────────────────┐
│ Layer 2: Database Transaction + DB Row Lock             │
│ 職責:真正的一致性邊界,即使 Redis 鎖失效仍保證 ACID     │
└────────────────────────────┬────────────────────────────┘
                             ↓
┌─────────────────────────────────────────────────────────┐
│ Layer 3: Defense-in-depth SQL Constraints               │
│ 職責:最後一道防線,SQL 原子的 WHERE balance >= amount  │
└─────────────────────────────────────────────────────────┘

第一層:Redis 流量排隊與 Fallback 容錯

Redis Lock 用於應用層排隊,避免所有請求同時打向資料庫。但在分散式系統中,Redis 永遠不能作為唯一的防禦手段。

PHP
protected function executeWithCustomerPointStateLock(Customer $customer, callable $callback): mixed
{
    $lockKey = sprintf('point_customer:%d:tenant:%d', $customer->id, $customer->tenant_id);

    try {
        // 嘗試取得 Redis Lock,TTL 設為 20s,最多等待 10s
        return Cache::lock($lockKey, 20)->block(10, function () use ($customer, $callback) {
            return DB::transaction(function () use ($customer, $callback) {
                $account = $this->lockOrCreatePointAccount($customer);
                return $callback($account);
            }, 3); // 3 次死鎖自動重試
        });
    } catch (LockTimeoutException $e) {
        throw new RuntimeException('系統繁忙,請稍後再試', 0, $e);
    } catch (\Exception $e) {
        // 【靈魂設計】Redis 降級 (Fallback):
        // 當 Redis 叢集掛掉或網路異常時,Fallback 到純 DB 行鎖保護,確保業務不中斷且正確性不降低!
        report($e);
        return DB::transaction(function () use ($customer, $callback) {
            $account = $this->lockOrCreatePointAccount($customer);
            return $callback($account);
        }, 3);
    }
}

第二與第三層:DB Row Lock + SQL 原子保險

進入 DB 事務後,立即對核心帳戶施加 lockForUpdate()(悲觀鎖),確保讀取到的是最新的餘額。最後在更新帳戶時,加上 SQL 條件限制:

PHP
// SQL 原子層級的最後保險
$updated = $account->where('id', $account->id)
    ->where('balance', '>=', $amount) // 最後一道防線:SQL WHERE 條件
    ->update([
        'balance' => $balanceAfter,
        'total_redeemed' => $account->total_redeemed + $amount,
    ]);

if ($updated === 0) {
    throw new RuntimeException('點數餘額不足,交易失敗');
}
即使 Redis 發生 GC 停頓導致 TTL 過期,或前面的程式碼邏輯有盲點,SQL 層級的 WHERE balance >= $amount 也會強制阻止餘額變成負數。

3. 避免死鎖(Deadlock)的黃金法則:固定加鎖順序

高併發下最容易出現 MySQL 1213 Deadlock 錯誤。死鎖發生的根本原因在於:不同交易以不同的順序爭奪多個資源鎖。

以「混合支付(點數 + 優惠券)」為例,如果請求 A 先鎖優惠券再鎖點數,請求 B 先鎖點數再鎖優惠券,死鎖就會發生。

我們在全系統中強制規範了 Lock Ordering 協定:

$$\text{Customer Lock} \longrightarrow \text{PointAccount Row Lock} \longrightarrow \text{UserCoupon Row Lock}$$
PHP
// 混合支付的核心加鎖順序實作
if ($pointsAmount > 0) {
    // 1. 先鎖 PointAccount
    $pointAccount = $customer->pointAccount()->lockForUpdate()->firstOrFail();
}

if ($userCoupon) {
    // 2. 再鎖 UserCoupon
    $userCoupon = UserCoupon::where('id', $userCoupon->id)->lockForUpdate()->firstOrFail();
}
由於全系統所有點數與優惠券操作都嚴格遵循此順序,循環等待的鏈條被徹底切斷。

4. 游標式消費(Cursor-based FIFO):最小化鎖的持有時間

在點數扣減時,我們採用了 Point Lot FIFO(先進先出) 批次扣除機制。

如果一次性 SELECT ... FOR UPDATE 把客戶所有的點數批次全部鎖定,鎖持有時間會極長,併發效能大幅下降。

我們採用了游標式(Cursor-based)逐筆鎖定消費:

PHP
protected function consumeAvailablePointLotsFIFO(PointAccount $account, int $amount): void
{
    $remainingToDeduct = $amount;

    while ($remainingToDeduct > 0) {
        // 每次只鎖定當前最早過期/最早獲得的單一批次(earned_at ASC, id ASC 作為 deterministic tie-breaker)
        $lot = $account->pointLots()
            ->where('remaining_points', '>', 0)
            ->where(function ($q) {
                $q->whereNull('expired_at')->orWhere('expired_at', '>', now());
            })
            ->orderBy('earned_at', 'asc')
            ->orderBy('id', 'asc') // 確定性排序,防止同毫秒產生的批次順序不一
            ->lockForUpdate()
            ->first();

        if (!$lot) {
            throw new RuntimeException('點數批次不足,無法完成兌換');
        }

        $deductFromLot = min($lot->remaining_points, $remainingToDeduct);
        $lot->update(['remaining_points' => $lot->remaining_points - $deductFromLot]);
        
        $remainingToDeduct -= $deductFromLot;
    }
}

5. 兩層鎖機制(Two-Level Lock):解決限時限量搶購

在搶優惠券(Flash Sale)場景中,需要同時滿足兩個條件:

  1. 全域總量不超發(如:限量 1,000 張)。

  2. 每人限領量不突破(如:每人限領 1 張)。

我們採用了兩層分散式鎖鏈(Template Lock → Customer-Template Lock):

PHP
// 第一層:Template Lock(控制全域總發行量串行化)
$templateLockKey = sprintf('coupon_template:%d:tenant:%d', $template->id, $template->tenant_id);

return Cache::lock($templateLockKey, 10)->block(10, function () use ($customer, $template) {
    // 第二層:Customer-Template Lock(控制個人領取上限串行化)
    $customerTemplateLockKey = sprintf('coupon_customer:%d:template:%d:tenant:%d', $customer->id, $template->id, $template->tenant_id);

    return Cache::lock($customerTemplateLockKey, 10)->block(10, function () use ($customer, $template) {
        return DB::transaction(function () use ($customer, $template) {
            // 資料庫層二次確認 (Double Check under Lock)
            $template = CouponTemplate::where('id', $template->id)->lockForUpdate()->firstOrFail();
            
            // 檢查每人限領數量
            $claimedCount = UserCoupon::where('customer_id', $customer->id)
                ->where('coupon_template_id', $template->id)
                ->lockForUpdate()->count();

            if ($claimedCount >= $template->per_customer_limit) {
                throw new RuntimeException('已達領取上限');
            }

            $template->increment('issued_quantity');
            return UserCoupon::create([...]);
        }, 3);
    });
});
配合資料庫層級的 Unique Constraint(唯一索引),達到雙重防禦:即便程式邏輯有閃失,資料庫也會直接拋出 UniqueConstraintViolationException 阻止重複領取。

6. 可審計的狀態機(Auditable Lifecycle)

在發放活動獎勵(RewardService)時,系統可能會因為異步超時或外部 API 失敗而中斷。

為了保證失敗可追溯,我們將記錄建立放在資料庫事務之外,實現了 PENDING → GRANTED / FAILED 的完整生命週期:

PHP
public function grantRewardToCustomer(Customer $customer, CampaignReward $campaignReward): RewardGrant
{
    // 1. 先在事務外建立 PENDING 狀態記錄,確保嘗試軌跡不隨 DB Rollback 消失
    $rewardGrant = RewardGrant::create([
        'tenant_id' => $customer->tenant_id,
        'customer_id' => $customer->id,
        'status' => RewardGrant::STATUS_PENDING,
    ]);

    try {
        return DB::transaction(function () use ($customer, $campaignReward, $rewardGrant) {
            // 2. 執行業務發放邏輯...
            $this->pointService->earn($customer, $campaignReward->points, ...);

            // 3. 成功時更新為 GRANTED
            $rewardGrant->update(['status' => RewardGrant::STATUS_GRANTED, 'granted_at' => now()]);
            return $rewardGrant;
        });
    } catch (\Exception $e) {
        // 4. 任何階段失敗,將持久化的 PENDING 記錄標記為 FAILED 並保留原因
        $rewardGrant->update([
            'status' => RewardGrant::STATUS_FAILED,
            'failure_reason' => $e->getMessage(),
        ]);
        throw $e;
    }
}

7. 經驗總結與架構 Checklist

經歷過高併發洗禮後,我們整理出這份高併發交易開發 Checklist:

  • [x] Redis Lock + DB Lock 雙重防護:Redis 做流量剪峰,DB 行鎖做最終一致性邊界。

  • [x] Redis 降級機制:確保 Redis 故障時系統自動 Fallback 到 DB Lock,服務不中斷。

  • [x] 固定加鎖順序:跨資源操作嚴格規範鎖順序,徹底消除死鎖。

  • [x] 游標式細粒度鎖:避免全表或全批次加鎖,縮短鎖持有時間。

  • [x] SQL 條件保險:更新時帶上 WHERE balance >= amount,阻止負餘額。

  • [x] DB-First / Unique Constraint 冪等:關鍵操作依賴 DB Unique Constraint 拋出異常作為最後防線。

  • [x] 完整審計軌跡:所有點數異動均有相對應的 Ledger Transaction 記錄,且歷史累計指標(如 total_redeemed)只增不減。

希望這套架構思路與 Laravel 實作細節,能為大家在設計高併發交易或紅利點數系統時提供實用的參考! 

沒有留言:

張貼留言

【2026 最新】VS Code 導入 pstack AI Skills 完整教學:用 Copilot 打造嚴謹的 Agent 工作流

在 VS Code 使用 AI Skills 打造嚴謹工程工作流:從 pstack 到原生 AGENTS.md 實戰指南 「If you want to go fast, go deep first.」 — Lauren Tan(@poteto) 進入 2026 年, AI Co...