2026年9月15日 星期二

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

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

原始碼: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,最終餘額錯誤、交易紀錄對不上,甚至出現負點數。

因此點數系統真正要回答的是:

多個請求同時操作同一個會員的點數時,如何保證最終餘額、交易紀錄、重試行為都仍然正確?

這也包含另一個常被忽略、但面試與上線都極關鍵的問題:

鎖會不會失效?會不會死鎖?死鎖之後系統怎麼收斂?


一、點數操作不是改一個欄位,而是一整段交易

單純這樣寫並不夠:

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

一次點數異動通常包含:

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

這些步驟必須被視為同一個業務交易單位。
任一環節失敗,都不該留下「餘額改了、交易沒寫入」或「交易寫了、餘額沒改」的半成品。

因此核心流程應落在:

PHP
DB::transaction(function () {
    // 鎖定並讀取 PointAccount
    // 檢查餘額
    // 更新 PointAccount
    // 建立 PointTransaction
});

這樣當流程中任何一步拋出例外,資料庫可以一起 Rollback。


二、DB Transaction 解決的是 Atomicity,不是全部並發問題

DB::transaction() 很重要,但它主要保證的是:

這一組資料庫操作要嘛全部成功,要嘛全部失敗。

例如:

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

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

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

前面的更新會被 Rollback,避免資料寫到一半。

但 Transaction 本身不自動解決

  • 兩個交易同時根據舊餘額扣點
  • 多實例同時進入同一業務臨界區
  • Client 重試造成重複入帳
  • 死鎖發生後如何恢復

所以「有 Transaction」不等於「並發安全」。


三、Row Lock:SELECT ... FOR UPDATE

為了避免兩個交易同時讀到同一個舊餘額,取得帳戶時應使用:

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

這對應資料庫的 row-level lock:

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

效果是:

text
Account balance = 100

Transaction A
  → SELECT ... FOR UPDATE
  → 取得鎖
  → 讀到 100
  → 扣 80
  → balance = 20
  → commit

Transaction B
  → 等待 A 結束
  → 讀到最新 20
  → 若再扣 80,餘額不足,拒絕

這能避免「兩個交易都以為自己還有 100 點」的經典錯誤。

使用 lockForUpdate 的實務原則

  1. 鎖的範圍要小:只鎖真正會被更新的 row
  2. 交易要短:Transaction 內不要打外部 HTTP、不要做重計算、不要寫大量無關日誌
  3. 先鎖再判斷:先 lockForUpdate,再檢查餘額,再更新
  4. 避免在鎖內做不確定時長的操作

Row lock 持有越久,等待隊列越長,系統越容易出現 lock wait timeout,甚至放大死鎖機率。


四、為什麼有人會再加 Redis Distributed Lock?

如果系統只有單一資料庫、單一帳戶 row,很多場景其實:

DB::transaction + lockForUpdate 就足夠。

但若有以下需求,才值得考慮 Redis Lock:

  • 多個 Application Instance 同時處理請求
  • 臨界區不只是單一 DB row,還包含快取、外部系統、多表協調
  • 希望在進 DB 前先在應用層把同一顧客的操作串行化,降低資料庫鎖競爭

典型寫法:

PHP
$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 生命週期不一致

危險時序:

text
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)

若未來有轉點、合併、批次調整,一次鎖多個帳戶:

text
T1: lock account_1 → lock account_2
T2: lock account_2 → lock account_1

MySQL 可能直接報:

text
SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock

解法:

  • 固定加鎖順序,例如永遠依 point_account_id 由小到大
  • 捕捉 deadlock 後做有限次重試
  • 重試要加 jitter,避免所有 victim 同時重撞

示意:

PHP
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 延遲升高
  • 重試風暴
  • 連線池被佔滿

所以並發安全不只是「有沒有加鎖」,而是:

鎖的粒度、持有時間、失敗重試、超時策略是否合理。


六、三層機制分別解決什麼,不要互相取代

完整保護鏈可以是:

text
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 不存在時,也是並發問題

會員第一次使用點數時,帳戶可能尚未建立:

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

若兩邊都建立,可能撞 Unique Constraint,或產生重複帳戶。

較穩健的做法:

  1. 先查 PointAccount
  2. 不存在就建立
  3. 建立後重新取得並 lockForUpdate
  4. 若觸發 Unique 衝突,改查既有帳戶再鎖

重點是:

Application Lock 降低競爭,Database Unique Constraint 做最後保護。
兩者要搭配,不是二選一。


八、Idempotency 與 Concurrency 是兩件事

即便並發控制完整,仍可能遇到:

text
Client 送出扣點
→ 伺服器已成功
→ 回應因網路遺失
→ Client 重試
→ 又扣一次

這不是 Race Condition,而是重複提交。

解法是 Idempotency-Key:

text
Idempotency-Key: unique-key-12345
  • 第一次:建立交易並回傳結果
  • 相同 Key 再來:直接回傳第一次結果,不重複入帳

對照:

問題類型典型場景解法
Concurrency多個不同請求同時扣同一人點數Lock + Transaction + Row Lock
Idempotency同一業務請求因重試被送多次Idempotency-Key + 唯一約束

完整的點數 API 兩者都要有。


九、Sequential Test 不是 Concurrency Test

很多測試會長這樣:

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

最後驗證:

text
balance = 1000
transaction count = 100

這能證明:

  • 連續執行下計算正確
  • 交易有寫入
  • 基本 Rollback 行為可能正常

但這不是真正的並發測試,因為它是同一個 process 依序執行:

text
Request 1 完成
→ Request 2 完成
→ Request 3 完成

而不是:

text
Request 1 ─┐
Request 2 ─┼─ 同時競爭同一 customer
Request 3 ─┘

真正該測的場景

假設:

text
balance = 100
同時 10 個 redeem,每個扣 20

預期:

text
成功 5
失敗 5
最終 balance = 0
redeem 交易 5 筆
不會出現負數

這才能驗證:

  • 餘額判斷看到的是最新值
  • 不會超扣
  • 鎖與交易在競爭下仍收斂到正確狀態

若要更接近生產,還應覆蓋:

  • 多 process / 多 worker 同時打同一顧客
  • 刻意製造 deadlock 後是否會重試並最終正確
  • Redis lock 過期或獲取失敗時的行為
  • 第一次建立 PointAccount 的併發建立

十、目前測試應誠實區分「已驗證」與「尚未驗證」

較精準的表述應是:

已完成點數交易的原子性、Rollback、餘額不足拒絕、Lock 基本行為、帳戶自動建立、Idempotency 與連續操作正確性驗證。
真正的跨 process 高併發競爭、死鎖重試收斂,需要另行設計 concurrency test 驗證。

不要因為測試類別名叫 ConcurrencyTest,就宣稱並發已完全驗證。
測試名稱不能代替測試內容。


十一、PointTransaction 不是附屬日誌,而是審計核心

一筆點數交易至少應能回答:

  • 交易前餘額多少
  • 異動多少
  • 交易後餘額多少
  • 誰觸發的
  • 哪個租戶
  • 對應哪筆外部參考號

因此建議保存:

text
balance_before
amount
balance_after
type
customer_id
point_account_id
tenant_id
description
created_by
reference
idempotency_key

點數系統最後一定會走到對帳、客服追查、外部系統爭議處理。
沒有完整交易軌跡,系統只是「當下數字看起來對」,不是「可審計」。


十二、建議的整體架構

text
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讓暫時性衝突可恢復

十三、設計點數系統時真正該問的問題

重點從來不是:

text
$balance += $amount;

而是:

  1. 多個 Request 同時進來怎麼辦?
  2. 執行到一半失敗怎麼辦?
  3. Client Retry 怎麼辦?
  4. 多個 Application Instance 同時操作怎麼辦?
  5. 第一次建立帳戶發生競爭怎麼辦?
  6. Redis Lock 過期但 DB 交易未結束怎麼辦?
  7. 出現 MySQL Deadlock 時怎麼重試、重試幾次、如何避免重試風暴?
  8. 如何證明最終餘額與交易筆數一致?
  9. 如何追蹤每一筆點數變化?

因此,點數系統的核心能力應是:

text
Atomicity
Concurrency Control
Deadlock Resilience
Idempotency
Tenant Isolation
Auditability

結語

一個可靠的 Laravel 點數系統,不是「加了 Redis Lock 和 lockForUpdate 就結束」。

更完整的標準是:

  • 正常路徑正確
  • 競爭路徑正確
  • 失敗路徑可回滾
  • 重試路徑不重複入帳
  • 死鎖路徑可恢復
  • 測試能區分 sequential 與真正 concurrent
  • 每筆異動可審計

並發控制的本質,不是把鎖堆得越多越好,而是:

清楚每一把鎖保護什麼、會在什麼情況下失敗,以及失敗後系統如何回到一致狀態。

只有把這些問題都設計進去,並用對的測試驗證,點數系統才不只是功能可跑,而是開始具備面對真實流量與外部整合的可靠性。

TDD、DDD、SDD:從測試、領域設計到規格驅動開發,建立可維護的軟體工程流程

TDD、DDD、SDD:從測試、領域設計到規格驅動開發,建立可維護的軟體工程流程

1. 開場:為什麼現在又要談 TDD、DDD、SDD?

AI 現在能快速產出大量程式碼。一個 CRUD API、一個 Service、甚至一整套 Feature Test,幾分鐘就能生成。問題是:這些程式碼「能跑」與「正確」是兩回事。

專案越大,修改一個功能就越容易牽動其他地方。工程師常常直接進入 coding,卻沒有先釐清問題邊界。需求、規格、測試、程式碼之間容易脫節——有人腦中的業務規則、有 Jira 上的模糊描述、有 AI 自己猜出來的欄位與 abstraction、有通過但沒有真正驗證業務行為的測試。

結果就是:表面看起來有測試覆蓋、有架構分層,實際上改一個權限規則就要翻半個系統,或者 AI 產出的程式「看起來合理」卻違反既有業務約束。

TDD、DDD、SDD 並不是三個互相競爭的方法,也不是什麼銀彈。它們分別處理不同層次的問題:

  • SDD 回答:「我們到底要做什麼?」
  • DDD 回答:「我們應該如何理解與設計這個問題?」
  • TDD 回答:「我們如何確認實作真的符合預期?」

這不是三種嚴格按照先後順序執行的方法論,而是三個不同關注面的工程實踐。實務上它們會以 feedback loop 的方式互相影響,而不是瀑布式 pipeline。

AI Coding 時代讓這條鏈變得更關鍵,因為 coding 的成本大幅下降,真正昂貴的變成了「釐清問題、建模、驗證」。

2. TDD:先建立「可驗證性」

Test-Driven Development 的核心循環是 Red → Green → Refactor。它真正解決的不是「提高 coverage 數字」,而是建立可執行的行為規格。測試是在描述「系統應該如何回應」,而不是事後補上的檢查清單。

以 Laravel 實務為例。需求:「建立一個 API,只有當使用者具有某個權限時才能刪除資料。」

以下權限相關範例假設專案使用 Spatie Laravel Permission 或相容的 Permission Model。Laravel 原生並沒有 givePermissionTo() 方法。若使用其他權限套件,請替換成對應 API。

先寫測試(Feature / API Test):

PHP
// tests/Feature/EnterpriseDeleteTest.php
public function test_user_with_delete_permission_can_delete_enterprise(): void
{
    $user = User::factory()->create();
    $user->givePermissionTo('enterprise.delete');
    $enterprise = Enterprise::factory()->create();

    $response = $this->actingAs($user)
        ->deleteJson("/api/enterprises/{$enterprise->id}");

    $response->assertNoContent();
    $this->assertDatabaseMissing('enterprises', ['id' => $enterprise->id]);
}

public function test_user_without_delete_permission_cannot_delete_enterprise(): void
{
    $user = User::factory()->create(); // 沒有 permission
    $enterprise = Enterprise::factory()->create();

    $response = $this->actingAs($user)
        ->deleteJson("/api/enterprises/{$enterprise->id}");

    $response->assertForbidden();
    $this->assertDatabaseHas('enterprises', ['id' => $enterprise->id]);
}

先跑測試 → 失敗(Red)。再寫最小實作讓它通過(Green),然後再重構(Refactor)。不要一開始就寫完整 Service、DTO、Exception 階層。

在企業 Laravel 專案中,測試分工大致如下:

  • Unit Test:純邏輯、Value Object、Domain Service(幾乎不碰 DB、HTTP)。
  • Feature / API Test:從 HTTP 入口驗證完整行為與權限,在 Laravel 後端專案中特別適合作為可執行規格的一部分。
  • Integration Test:涉及多個外部系統或複雜 transaction 時使用。

實際上 Unit / Feature / Integration 的邊界會依專案而異。重點不是名稱,而是「這組測試是否能有效保護我們真正在意的行為」。不要把所有東西都塞進 Unit Test,也不要為了 coverage 而測 trivial getter。

TDD 的價值在於:之後任何人(包括 AI)改程式時,都有一組可執行的行為約束。測試失敗就代表行為被改變了。

3. DDD:先搞清楚「我們到底在解決什麼問題」

Domain-Driven Design 最常被誤解的地方,是把它等同於「把 Model 改成 Entity + 建一堆 Repository + Service」。那不是 DDD,那是 Pattern 堆砌。

DDD 的核心是:讓程式模型反映業務領域與業務規則。Ubiquitous Language 要在團隊與程式碼中一致。Bounded Context 幫你劃分「哪些東西屬於同一個問題空間」。

拿企業官網後台的「投資人/公司/分類/多語系內容管理」當例子。

真正的 Domain Rule 可能是:

  • 一個公司在某個語系下只能有一個「主要投資人」描述。
  • 刪除分類時,如果底下還有已發布內容,必須先處理關聯或禁止刪除。
  • 多語系內容的「發布狀態」是跨語系協調的,而不是單純每個翻譯獨立。

這些規則值得放進 Domain Model(Entity、Value Object、Aggregate 的行為方法,或 Domain Service)。反過來說,單純的 CRUD 欄位編輯、列表篩選、圖片上傳,通常不需要完整 Aggregate 或 Repository 抽象。

Laravel 實務建議:

  • Eloquent Model 可以直接承載部分 Domain 行為(如果規則不複雜)。
  • 真正複雜的規則再抽到 Domain Service 或 Application Service。
  • Repository 只有在你需要真正隔離 persistence 細節、或有多種儲存策略時才值得。
  • 不是每個 Model 都要變成 Entity,也不是每個操作都要 Application Service。

「不是所有 CRUD 都值得 DDD 化。」這句話要反覆提醒自己。DDD 的成本在於認知負擔與程式碼結構複雜度。只有當業務複雜度高到「直接寫在 Controller / Model 會很快失控」時,才值得投入。

4. SDD:規格驅動開發

本文所說的 SDD,是指 Specification-Driven Development / Spec-Driven Development

需要先說明:SDD 並不像 TDD 或 DDD 那樣有高度標準化、單一且被廣泛接受的業界定義。不同團隊可能把 SDD 理解成不同流程與產物。因此本文明確定義為:

先建立明確、可驗證、可供人與 AI 共同理解的規格,再進入實作。

它通常包含:

  • Functional Requirement
  • Acceptance Criteria
  • Business Rules
  • API Contract / Data Contract
  • Permission Rules
  • Edge Cases
  • Error Handling
  • 對應的 Test Cases

SDD 的價值不是「多寫文件」,而是降低這條鏈上的資訊損失:

人腦中的需求 → AI / 工程師理解 → 程式碼

沒有明確規格時,AI 最容易開始「猜」:自己加欄位、自己加 Service、自己決定錯誤碼、自己假設權限模型。工程師也容易直接開始寫,寫到一半才發現需求沒講清楚。

規格要可驗證。最好能直接對應到測試案例或 Acceptance Criteria。模糊的「要支援刪除」不如「只有具備 enterprise.delete 權限的使用者可以刪除;若有關聯資料則禁止刪除並回傳特定錯誤」。

5. TDD、DDD、SDD 三者到底有什麼差別?

方法核心問題關注層次主要產物解決的風險
TDD做出來的東西對不對?ImplementationTestsRegression / Incorrect Behavior
DDD我們應該如何建模?DomainDomain ModelBusiness Complexity
SDD我們到底要做什麼?RequirementSpecificationAmbiguous Requirements

簡化來說:

  • SDD 關注「我們到底要做什麼」
  • DDD 關注「我們應該如何理解與建模這個問題」
  • TDD 關注「我們如何確認實作真的符合預期」

但這不是絕對的線性規則。實務上它們會互相回饋,任何一環發現問題,都可能回到其他環節。

6. 三者如何組合?(Feedback Loop,而非瀑布)

實務流程大致如下:

text
需求

SDD(釐清 Business Rules / Acceptance Criteria / Edge Cases)

DDD(建立 Domain Model,決定哪些規則放哪裡)

TDD(寫可驗證的行為測試)

Implementation(最小必要實作)

Refactor

Integration / Acceptance Test

實際專案幾乎從來不是完美線性:

  • TDD 寫到一半發現需求有洞 → 回到 SDD
  • 實作時發現 Domain Model 不合理 → 回到 DDD
  • DDD 過程中發現業務規則根本沒講清楚 → 回到 SDD
  • 測試發現規格與實際業務矛盾 → 回到 Requirement

真正的流程是 feedback loop,而不是瀑布。文件再漂亮,如果無法驅動測試與實作,價值有限。

7. AI Coding 時代,為什麼 SDD + DDD + TDD 特別重要?

這是全文重點。

AI 很擅長:

  • 產生 CRUD
  • 補程式碼
  • 依照既有 Pattern 重構
  • 寫測試骨架
  • 根據既有程式修改

但 AI 不應該被當成「知道需求的 Senior Engineer」。它最容易犯的錯誤包括:

  • 猜需求、自己增加欄位或行為
  • 引入不必要的 abstraction(多一層 Service、DTO、Repository)
  • 忽略既有架構與邊界
  • 只修表面問題
  • 寫出可以執行但不符合業務規則的程式

AI 並不是不會寫程式,而是可以很有效率地實作「錯誤的需求」。

因此:

  • SDD 約束 AI 要做什麼:明確的 Acceptance Criteria、API Contract、Permission Rule、Edge Cases。AI 不該自由發揮。
  • DDD 約束 AI 應該如何理解業務:Domain Model、Ubiquitous Language、Bounded Context。AI 提出的建議要對齊既有領域語言。
  • TDD 約束 AI 不能隨便改變既有行為:測試是行為的護欄。AI 改完程式後必須通過既有測試,新增行為也要有對應測試。

AI + TDD:讓 AI 產生或補齊測試,但測試案例必須反映真正需求,而不是 AI 自己想像的 happy path。
AI + DDD:AI 可以提出 Domain Model 建議,但最終建模決策由工程師負責,避免 AI 為了「看起來專業」而過度抽象。
AI + SDD:最關鍵。沒有規格時,AI 產出的程式碼品質上限很低。有明確規格時,AI 變成強大的實作加速器。

AI 降低了 coding cost,卻沒有降低 requirement clarification、domain understanding 和 verification 的成本。這才是現代後端工程真正昂貴的地方。

8. 一個完整 Laravel 實戰案例

需求:「後台使用者可以刪除某個企業資料,但只有具備對應 Delete Permission 的使用者才能刪除;如果資料具有關聯資料,必須依照業務規則處理。」

SDD

  • Functional Requirement:提供 DELETE /api/enterprises/{id}
  • Permission Rule:使用者必須具備 enterprise.delete 權限。
  • Business Rule:若該企業下仍有已發布的關聯內容(例如投資人報告),禁止刪除,並回傳 422 與明確業務錯誤訊息。
  • Acceptance Criteria:
    • 有權限且無阻礙關聯 → 204 No Content,資料被刪除。
    • 無權限 → 403 Forbidden。
    • 資料不存在 → 404 Not Found。
    • 有阻礙關聯 → 422 Unprocessable Entity,附帶明確訊息。
  • Edge Cases:關聯內容已 soft-deleted、權限中途被撤銷、並發刪除請求等。

DDD

  • Entity:Enterprise(可承載部分業務行為)。
  • Domain Rule:刪除前是否允許,屬於業務規則。實務上可放在 Model 的行為方法中。
  • 實作上的取捨:
PHP
// app/Models/Enterprise.php
public function canBeDeleted(): bool
{
    // 這是 pragmatic 的做法:把「查詢關聯狀態」與「業務判斷」放在一起。
    // 若未來規則變得更複雜(跨 Bounded Context、需要多種資料來源),
    // 再考慮抽出 Domain Service 或把條件透過參數注入,以減少對 Eloquent 的直接依賴。
    return !$this->publishedReports()->exists();
}
  • Application Layer:協調權限檢查 + Domain 行為 + Persistence。
  • Permission Boundary:權限檢查放在 Policy,不要塞進 Domain Entity。
  • 不需要的東西:不需要為了這個功能再建完整 Repository 介面、Command Bus、一堆 DTO,除非既有架構已經這樣做。

重點再次強調:
「能用現有 Laravel Pattern(Policy + Model 行為方法)清楚表達規則,就不要為了追求純正 DDD 而額外引入抽象。」

TDD

PHP
public function test_authorized_user_can_delete_enterprise_without_blocking_relations(): void { /* ... */ }
public function test_unauthorized_user_gets_forbidden(): void { /* ... */ }
public function test_non_existent_enterprise_returns_404(): void { /* ... */ }
public function test_enterprise_with_blocking_relations_cannot_be_deleted(): void { /* ... */ }

Implementation(保持 KISS)

使用 Laravel 常見實務:Policy + Controller + Eloquent 行為方法。

PHP
// app/Policies/EnterprisePolicy.php
public function delete(User $user, Enterprise $enterprise): bool
{
    return $user->can('enterprise.delete');
}

// Controller
public function destroy(Enterprise $enterprise)
{
    $this->authorize('delete', $enterprise);

    if (!$enterprise->canBeDeleted()) {
        return response()->json([
            'message' => 'Cannot delete enterprise with published related reports.',
        ], 422);
    }

    $enterprise->delete();

    return response()->noContent();
}

對應的錯誤回應契約建議保持一致:

  • 403:無權限
  • 404:資源不存在
  • 422:違反業務規則(有阻礙關聯)

為什麼這樣設計?因為這個功能的複雜度還沒到需要完整 Aggregate Root + Domain Service + Application Service 的程度。能用 Policy + Model 行為解決,就不要為了 Pattern 而 Pattern。如果之後規則變得非常複雜(多種刪除策略、事件溯源、跨 Bounded Context),再演化。

9. 什麼情況不需要 TDD / DDD / SDD?

  • 非常簡單的 CRUD、內部一次性 script、原型驗證:不需要完整 DDD,也不一定值得建立完整測試架構。
  • 純 UI 調整、沒有複雜業務規則的前端頁面:完整 SDD 可能過重。
  • 探索性程式或 throw-away code:過度規格與測試是浪費。

但對以下情況,應該提高規格、建模與測試的要求:

  • 金流、權限、訂單、多租戶
  • 複雜工作流程、高風險資料操作
  • 複雜業務規則
  • AI Agent 會執行的重要操作

原則是:風險與複雜度越高,SDD + DDD + TDD 的投入越值得。

10. 常見誤區

  1. TDD = 測試覆蓋率越高越好
    修正:關注行為覆蓋與回歸防護,而不是數字。Trivial 測試沒有價值。
  2. DDD = Repository Pattern
    修正:Repository 只是可能的工具。核心是 Domain Model 與業務規則表達。
  3. DDD = 每個 Model 都要 Entity
    修正:只有真正有身份與生命週期的概念才需要。很多東西就是 Data。
  4. Service 越多代表架構越好
    修正:Service 過多常常是缺少 Domain 行為或過度分層的徵兆。
  5. SDD = 寫更多文件
    修正:目標是可驗證、可驅動實作與測試的規格,而不是文件堆積。
  6. AI 寫測試就等於有 TDD
    修正:AI 產生的測試常常只測 happy path 或自己想像的行為。測試必須反映真正需求。
  7. 有規格就代表需求一定正確
    修正:規格本身也需要驗證與迭代。錯誤的規格會被高效實作出來。
  8. 測試通過就代表商業需求正確
    修正:測試只驗證「符合既有規格」。規格錯了,測試也會一起錯。
  9. 所有專案都應該使用完整 DDD
    修正:多數 CRUD 不值得。業務複雜度才是觸發條件。
  10. AI 產生的程式碼只要能跑就可以
    修正:能跑只是最低標準。還要符合業務規則、既有架構、可維護性與測試約束。

11. 最後提出一套適合現代後端工程師的工作方式

簡潔可落地的流程:

  1. Understand — 先理解問題與未知之處。AI 幫忙整理資訊、列出問題,但不能自行猜需求。
  2. Specify — 產出可驗證的 Acceptance Criteria、Business Rules、Edge Cases、API Contract。AI 協助轉寫與補全,工程師負責確認。
  3. Model — 決定 Domain 邊界與關鍵規則放哪裡。AI 可提出建議,工程師做最終決策。
  4. Test — 寫可執行的行為測試。AI 可產生測試骨架,但案例必須對齊規格。
  5. Implement — 最小必要修改。AI 寫實作,但範圍受規格與測試約束。
  6. Verify — 跑測試、看 diff、檢查 regression。AI 協助執行與初步分析。
  7. Refactor — 在測試保護下整理。AI 可協助,但不能自行擴大修改範圍。

AI 在每個階段都是加速器與建議者,而不是決策者。工程師對需求正確性、領域模型、行為驗證負最終責任。

12. 結論

真正成熟的工程流程,不是讓工程師寫更多程式碼,而是在寫程式碼之前,把問題、規則與驗證方式變得足夠清楚。

AI 讓 Coding 的成本下降,因此真正昂貴的反而變成 Requirement、Domain Understanding、Verification。TDD、DDD、SDD 不是讓你變得更「正統」,而是幫你在 AI 加速的時代,仍然能把「正確」與「可維護」守住。


TDD / DDD / SDD + AI 一頁式心智模型

text
┌─────────────────────────────────────────────────────────────┐
│                    Problem Space                             │
│  需求模糊 / 業務複雜 / 行為易回歸 / AI 容易亂猜               │
└──────────────────────────┬──────────────────────────────────┘

           ┌───────────────┼───────────────┐
           ▼               ▼               ▼
     ┌─────────┐     ┌─────────┐     ┌─────────┐
     │   SDD   │     │   DDD   │     │   TDD   │
     │ 要做什麼 │     │ 如何理解 │     │ 如何驗證 │
     │ Spec    │     │ Model   │     │ Tests   │
     └────┬────┘     └────┬────┘     └────┬────┘
          │               │               │
          └───────────────┼───────────────┘

                 Feedback Loop
          (規格 ↔ 模型 ↔ 測試 ↔ 實作)


              ┌───────────────────────┐
              │   AI as Accelerator   │
              │  - 受規格約束          │
              │  - 受模型語言約束      │
              │  - 受測試護欄約束      │
              │  - 工程師做最終決策    │
              └───────────────────────┘


              可維護、可驗證、可演化的系統

用這套思路,而不是追求完美 Pattern 數量,會更接近實務中真正能長期維護的後端系統。

2026年9月14日 星期一

高流量媒體系統的 Laravel 實戰課題:從 Legacy 重構到金流、快取與 AI 開發

 高流量媒體系統的 Laravel 實戰課題:從 Legacy 重構到金流、快取與 AI 開發(進階架構版)

在內容媒體與訂閱平台的開發中,後端團隊通常面對的是「在營運不中斷的前提下,邊換輪胎邊開車」的架構演進課題。

以媒體事業群的數位系統為例,常見的技術痛點包含:新舊系統並存時的 Session 與驗證同步、流量爆發時的快取防線、訂閱金流的絕不重扣與狀態衝突、千萬級巨量資料庫效能瓶頸、多節點零停機部署,以及導入 AI 工具時的程式碼資安防線。

本文拆解這六大核心場景,提供實務上的系統架構設計、程式碼實作與落地解法。

一、 Legacy Code 重構:新舊系統共存與 SSO / Session 一致性

1. 舊系統痛點與架構解耦

舊版 CodeIgniter (CI3) 系統常見的問題在於 Fat Controller原生 SQL 散落同步 Blocking 第三方呼叫。重構的第一步是透過 Service / Repository Pattern 將業務邏輯與資料存取抽離,並以抽象層封裝外部 HTTP 呼叫。

2. CI 與 Laravel 雙系統共存的 SSO / Session 挑戰

在逐步重構(Strangler Fig Pattern)的過渡期,使用者會在舊 CI 頁面與新 Laravel 頁面之間無縫切換。此時最大的挑戰是:如何維持單一登入(SSO)與 Session 狀態一致性?

┌────────────────────────────────────────────────────────┐
│                     Client Browser                     │
└───────────┬────────────────────────────────┬───────────┘
            │ Request (Cookie: app_session)  │ Request (Cookie: app_session)
            ▼                                ▼
┌───────────────────────┐        ┌───────────────────────┐
│ CodeIgniter 3 (舊系統) │        │   Laravel 11 (新系統) │
└───────────┬───────────┘        └───────────┬───────────┘
            │                                │
            │   ┌────────────────────────┐   │
            └──►│ Central Redis Session  │◄──┘
                │ Store (JSON / Custom)  │
                └────────────────────────┘

實務解法:中央化 Redis Session 與客製化 Handler

  1. 共享 Cookie 網域:將 Session Cookie 設定在頂層網域(如 .yourdomain.com),確保兩邊都能讀取相同的 Cookie Key。

  2. Session 序列化格式統一:CI3 預設使用 PHP 原生 serialize,而 Laravel 預設使用自己的 Encrypter 與 Serializer。解法是建立統一的 Redis Session Handler

    • 方案 A(推薦):在 Laravel 端建立客製化的 Session Driver,讓它解讀 CI3 儲存於 Redis 中的 JSON 格式 Session 資料。

    • 方案 B(JWT / Central SSO):引入輕量級 JWT 簽章機制。當使用者在舊系統登入後,核發包含 user_id 的 Signed Cookie,Laravel 與 CI3 分別建立 Middleware 驗證該 Token。

PHP
// Laravel 端的 Custom Session Handler 示意
namespace App\Extensions;

use Illuminate\Support\Facades\Redis;

class SharedCiSessionHandler implements \SessionHandlerInterface
{
    public function read($sessionId): string|false
    {
        // 讀取 CI3 寫入 Redis 的 Session 格式
        $data = Redis::connection('session')->get("ci_session:" . $sessionId);
        if (!$data) return '';
        
        $unserialized = unserialize($data);
        // 轉換為 Laravel 可識別的 Session 結構
        return json_encode([
            'auth_user_id' => $unserialized['user_id'] ?? null,
            'user_role'    => $unserialized['role'] ?? 'guest',
        ]);
    }
    // ... 實作其餘 SessionHandler 介面方法
}
🧠 思考題 1.1
當使用者在舊 CI 系統點擊「登出」時,如何確保 Laravel 系統的 Session 即時失效,且不會產生競態條件(Race Condition)?

二、 高流量文章頁的多層快取與 Edge 防禦

當重大新聞爆發時,資料庫最大的敵人不是常態流量,而是 Cache Miss 瞬間的驚群效應(Cache Stampede)。完整防線應延伸至邊緣節點(CDN Edge)。

[ User Request ]
       │
       ▼
┌─────────────────────────────────┐
│ CDN Edge (Cloudflare / Fastly)  ├─► Hit: 回傳 Stale / Cached Page (0ms)
│ Cache-Control + SWR Header      │
└────────────────┬────────────────┘
                 │ Miss / Stale Revalidate
                 ▼
┌─────────────────────────────────┐
│ Redis Cluster (L2 Cache)        ├─► Hit: 回傳 JSON Data
│ (帶 Redis Lock 防驚群機制)       │
└────────────────┬────────────────┘
                 │ Miss (搶到 Lock 者)
                 ▼
┌─────────────────────────────────┐
│ MySQL Database                  │
└─────────────────────────────────┘

1. CDN Edge 與 Stale-While-Revalidate (SWR) 模式

在文章 Response Header 中注入 Cache-Control 指令,啟用 SWR 機制:

HTTP
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
  • max-age=60:60 秒內直接由 CDN 邊緣快取回應。

  • stale-while-revalidate=300:60 秒~360 秒之間,CDN 會先回傳舊頁面給使用者,並在背景非同步發送一個 Request 回 Laravel 重新整理快取

  • stale-if-error=86400:若源站(Laravel/MySQL)崩潰,CDN 可繼續提供長達 24 小時的過期快取頁面。

2. L2 Redis 快取與 Lock 防禦實作

PHP
public function getArticleDetail(int $articleId): array 
{
    $cacheKey = "article:v1:detail:{$articleId}";
    
    // 1. 讀取快取
    $data = Cache::get($cacheKey);
    if ($data) return $data;

    // 2. 使用 Atomic Lock 防禦 Cache Stampede
    $lock = Cache::lock("lock:{$cacheKey}", 5);

    if ($lock->get()) {
        try {
            $data = Article::with(['category', 'tags'])->findOrFail($articleId)->toArray();
            Cache::put($cacheKey, $data, now()->addMinutes(10));
        } finally {
            $lock->release();
        }
        return $data;
    }

    // 3. 未搶到鎖者:微幅等待後讀取,或走 Fallback
    usleep(100000); // 100ms
    return Cache::get($cacheKey) ?? $this->getFallbackArticleData($articleId);
}
🧠 思考題 2.1
如果 Redis Cluster 發生整體故障(Outage),系統應該如何實施優雅降級(Graceful Degradation),避免全站請求直接刷爆 MySQL?

三、 訂閱制與金流 Webhook 的可靠性與狀態衝突處理

訂閱制的核心原則為:絕對不重複開通、不遺漏權限、能處理非同步狀態不一致

[ 金流 Webhook ]
       │
       ▼
[ Signature 驗證 ] ──► (失敗則 400 Abort)
       │
       ▼
[ DB Unique Index 鎖定 ] ──► (重複則 200 OK 跳過)
       │
       ▼
[ 狀態機 State Machine 檢查 ]
       │
       ├─► (Webhook 狀態 == API 查詢狀態) ──► 執行權限開通 DB Transaction
       │
       └─► (狀態衝突 / 延遲) ───────────────► 觸發二次查詢與衝突事件監聽

1. 處理 Webhook 延遲與 API 查詢不一致的實務案例

在真實場景中,金流平台重送的 Webhook 狀態(如 PendingPaid)可能與我們主動透過 API 查詢的結果(如 FailedExpired)發生衝突。

解法:有限狀態機(Finite State Machine, FSM)與雙向校驗

  1. 嚴格控制訂單狀態流轉:訂單狀態只允許單向演進:Pending $\rightarrow$ Paid / Failed $\rightarrow$ Refunded。不允許已進入 Paid 的訂單受延遲到的 Pending Webhook 覆蓋。

  2. 強制主動主動主查 (Active Query Verification):當收到 Webhook 通知扣款成功時,不完全信任 Payload,後端主動發起一次 TLS 對向 API 查詢金流端,雙重驗證為 Success 後才發放權限。

PHP
public function handleSpGatewayWebhook(Request $request)
{
    if (!$this->verifySignature($request)) {
        return response()->json(['message' => 'Invalid signature'], 400);
    }

    $tradeNo = $request->input('TradeNo');
    $orderId = $request->input('MerchantOrderNo');

    return DB::transaction(function () use ($tradeNo, $orderId, $request) {
        // 1. 利用 DB 排他鎖 (Pessimistic Lock) 鎖定訂單
        $order = Order::where('id', $orderId)->lockForUpdate()->firstOrFail();

        // 2. 狀態機防禦:已完成的訂單直接回傳,防止重複處理
        if ($order->status === OrderStatus::PAID) {
            return response()->json(['message' => 'Already processed'], 200);
        }

        // 3. 雙重校驗:向金流 API 發起 Secondary Check
        $apiResult = $this->paymentGatewayClient->queryOrder($tradeNo);
        if ($apiResult->isPaid() && $request->input('Status') === 'SUCCESS') {
            $order->update(['status' => OrderStatus::PAID, 'paid_at' => now()]);
            UserSubscription::grantAccess($order->user_id, $order->plan_id);
            return response()->json(['message' => 'Success'], 200);
        }

        // 情況:狀態衝突 (Webhook 成功但 API 查無結果或失敗)
        Log::critical("Payment mismatch for Order {$orderId}", ['webhook' => $request->all(), 'api' => $apiResult]);
        $order->update(['status' => OrderStatus::FLAGGED_FOR_REVIEW]);
        return response()->json(['message' => 'State mismatch recorded'], 200);
    });
}
🧠 思考題 3.1
如果金流平台系統異常,延遲了 30 分鐘才回傳交易結果,而使用者在前端頁面不斷重整並提示「付款處理中」,後端應如何設計權限預發放與過期補償機制?

四、 千萬級閱讀紀錄的大表優化與替代架構

user_article_views 資料表達到數千萬層級時,在 OLTP 資料庫(MySQL)執行 GROUP BYORDER BY 會嚴重消耗 CPU 資源。

1. 替代方案選型分析

方案適用情境優點缺點 / 成本
Redis ZSET即時熱門排行榜 (前 100 名)極速 ($O(\log N)$)、實時更新記憶體成本高,資料持久化需謹慎
ClickHouse巨量行為 Log 分析、多維度統計欄位式儲存,百億級資料毫秒響應需維護獨立 OLAP 叢集,不支援高頻單筆更新
TimescaleDB時間序列分析 (按時間區段統計)相容 PostgreSQL,具自動分區 (Hypertables)需由 MySQL 轉移至 PostgreSQL 生態系

2. Redis ZSET 與 ClickHouse 雙軌架構

[ User Article View Event ]
             │
             ├──► (即時) ──► Redis ZSET (ZINCRBY leaderboard:24h 1 article_id)
             │
             └──► (異步/Log) ─► Vector / Kafka ──► ClickHouse (OLAP 巨量分析)

ClickHouse 數據導流設計

對於千萬級點擊日誌,利用 Laravel Event 非同步發送至 Kafka/S3,再批次匯入 ClickHouse 做多維度統計(例如:按地區、裝置、作者統計 30 天熱門度)。

🧠 思考題 4.1
當採用 ClickHouse 作為分析資料庫時,為了避免每筆閱讀都直接寫入 ClickHouse 導致小檔案過多 (Too many parts),Laravel 應如何設計雙發 (Dual Write) 或 Buffer 批次寫入機制?

五、 CI/CD 零停機部署與多節點同步

在多節點(Multi-Node / ECS Cluster)架構下,滾動更新(Rolling Update)期間會同時存在舊版本程式碼新版本程式碼的 Container。

                       [ Load Balancer ]
                               │
            ┌──────────────────┴──────────────────┐
            ▼                                     ▼
┌──────────────────────┐               ┌──────────────────────┐
│  Node A (Old Code)   │               │  Node B (New Code)   │
└───────────┬──────────┘               └───────────┬──────────┘
            │                                     │
            └──────────────────┬──────────────────┘
                               ▼
                   ┌──────────────────────┐
                   │   Shared MySQL DB    │
                   └──────────────────────┘

1. 解決多節點競態 Migration

若 5 個 Node 同時啟動並執行 php artisan migrate,會造成 DB Table Lock 競爭甚至部署失敗。

  • 解法:使用 Laravel 11 的 migrate --isolated 旗標(底層使用 Atomic Lock 機制),確保整個 Cluster 同一時間只有一個 Node 能執行 Migration。

Bash
php artisan migrate --force --isolated

2. 多節點 Cache 不一致防禦

部署時若執行 php artisan config:cachecache:clear,可能導致 Node A 刷除了 Redis 快取,但 Node B 仍在使用舊版序列化類別,造成 UnserializeException

  • 版本化快取 Key (Versioned Cache Prefix):在 config/app.php.env 中定義 APP_VERSION(例如 Git Commit Hash)。

  • 快取 Key 統一帶上版本字串:article:v_{APP_VERSION}:detail:{id}。部署升級後,新舊版本的 Code 各自讀寫獨立版本號的 Cache,升級完成後再讓舊 Cache 自然過期。

🧠 思考題 5.1
在藍綠部署(Blue-Green Deployment)過程中,若新的 Migration 包含刪除舊欄位(DROP COLUMN),應該在部署的哪一個階段執行?為什麼?

六、 AI 開發工作流與 CI/CD 安全防線

團隊導入 AI(如 Cursor, GitHub Copilot)能大幅提升開發效率,但 AI 生成的程式碼常帶有 N+1 SQL 查詢Mass Assignment 風險弱型別漏洞

1. 安全自動化檢查工具鏈 (Security Pipeline)

必須在 GitHub Actions CI Pipeline 中設置自動化靜態分析閘門,攔截不合規的 AI 程式碼:

YAML
# .github/workflows/ai-code-quality.yml 範例片段
name: AI Code Guardrails

on: [push, pull_request]

jobs:
  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          tools: phpstan, composer-normalize

      # 1. 靜態分析:設定嚴格層級 (Level 8) 捕捉型別與潛在 Bug
      - name: Run PHPStan / Larastan
        run: vendor/bin/phpstan analyse --level=8

      # 2. 資安掃描:檢查第三方套件 CVE 漏洞
      - name: Security Vulnerability Audit
        run: composer audit

      # 3. 程式碼風格與架構規則約束
      - name: Check Code Architecture
        run: vendor/bin/deptrac analyse

2. 定義 .cursorrules 限制 AI 產出

在專案根目錄設定 .cursorrules,強制要求 AI 遵循團隊的最佳實踐:

Plaintext
- 所有的 DB 查詢必須使用 Eloquent 或 Query Builder,嚴禁產生 RAW SQL 拼接。
- 在產生 Controller 時,必須搭配 FormRequest 處理驗證,嚴禁在 Controller 內撰寫 $request->validate()。
- 查詢包含關聯模型時,必須預先加載 (Eager Loading with()),嚴禁在 foreach 內觸發 N+1 查詢。
- 所有敏感欄位寫入必須經過 $fillable 白名單保護。
🧠 思考題 6.1
如何在 CI 流程中整合 Larastan 與 Static Analysis 工具,自動偵測 AI 程式碼中未被 with() 預載入的潛在 N+1 查詢?

結語

維運與設計高流量媒體系統,關鍵在於深諳系統的防禦邊界與降級機制

從 Legacy 重構的 Session 共存,到邊緣快取的 SWR 模式、金流狀態機的雙向校驗、巨量資料的 OLAP 轉型,以及 CI/CD 與 AI 工作流的自動化防線——將複雜的業務情境拆解成具備「可觀測」、「可恢復」與「高容錯」的架構步驟,才是系統長期穩健演進的真正基石。

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

Laravel 點數系統的並發一致性:從 Race Condition、Deadlock 到真正的 Concurrency Test 原始碼: https://github.com/BpsEason/loyalty-api.git 在點數系統裡,「加點」和「扣點」看起來只是簡單的...