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 流量與外部系統整合的可靠性。

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