Saga Pattern
What — 把跨服務的分散式事務拆成一系列本地事務,每步成功後發布事件/訊息觸發下一步;失敗則觸發補償事務(compensating transaction)。
Why — 微服務架構下無法使用跨 DB 的 ACID 事務;2PC(Two-Phase Commit)雖然存在,但鎖定時間長、協調者單點故障風險高,在高流量系統裡是性能殺手。
When — 訂單處理、付款流程、預訂系統等需要跨多個 bounded context 的業務操作。
- Anti-use-case:單一服務內部操作;需要強一致性的金融核心交易(這種情況考慮單體或分散式鎖);流程步驟少於 2 步(直接用本地事務)。
Where — 跨多個微服務邊界的業務流程層;訊息 broker 驅動的事件流。
Who
- Axon Framework — https://github.com/AxonIQ/AxonFramework(Java,Saga annotations 內建,Choreography 與 Orchestration 兩種模式均支援)
- MassTransit — https://github.com/MassTransit/MassTransit(.NET,SagaStateMachine DSL 清晰)
- Eventuate Tram Sagas — https://github.com/eventuate-tram/eventuate-tram-sagas(多語言,框架無關,搭配 Outbox Pattern 完整配套)
實踐
兩種變體的取捨:Choreography Saga(事件驅動,無中央協調者,耦合低但 debug 難)vs Orchestration Saga(有 Saga orchestrator,流程清晰但多一個元件)。
注意事項與常見坑:
- 補償事務必須冪等——補償本身也可能被重試。
- Saga 日誌需要持久化,crash 後才能從中間步驟回復而不是重頭跑。
- Choreography saga 一定要有全局 correlation ID,否則跨服務 debug 等於看天書。
- 不要把 Saga 當成分散式鎖用——它處理業務一致性,不是並發互斥。
- 補償 != 回滾:補償是「新的正向操作」(退款),不是資料庫層面的 rollback。
Outbox Pattern
What — 把「要發布的事件」和「domain 資料」寫進同一個 DB 事務(outbox table),再由獨立的 relay 程序把 outbox 裡的事件轉發給訊息 broker。
Why — 雙寫問題(dual-write)——先寫 DB 再寫 Kafka,或反之,都有一半失敗的窗口。Outbox 讓「業務資料 + 事件意圖」的一致性由 DB 的 ACID 保證,徹底消除視窗。
When — 任何「DB 操作 + 訊息發布」需要原子性的場景。
- Anti-use-case:事件允許丟失(fire-and-forget metrics);broker 本身提供事務性發布(Kafka 的 transactional producer 可替代部分場景)。
Where — 寫入路徑的持久化層與訊息 broker 之間的橋梁;Domain event 的發布機制。
Who
- Debezium — https://github.com/debezium/debezium(CDC-based outbox relay,官方有 Outbox Event Router connector)
- MassTransit — https://github.com/MassTransit/MassTransit(EntityFramework Outbox 支援)
- Eventuate Tram — https://github.com/eventuate-tram/eventuate-tram
實踐
兩種 relay 策略:
- Polling relay:定期 SELECT 未處理行,簡單但有輪詢延遲與 DB 壓力。
- CDC(Change Data Capture)relay:監聽 binlog/WAL(如 Debezium),延遲低、不侵入業務,但需要 binlog 權限與額外基礎設施。
注意事項與常見坑:
- outbox table 要有
processed欄位或直接刪除已發送行——刪除更簡單,processed欄位需要額外 sweep job。 - relay 是 at-least-once delivery——接收端必須冪等(見冪等性(Idempotency))。
- 別在 outbox 裡存大 payload;只存 event ID + type + aggregate ID,payload 讓接收端從主表查或從 event store 取。
- 多個 relay 實例要做分散式鎖或 leader election,避免重複投遞。
Circuit Breaker
What — 包裝對外部服務的呼叫;偵測連續失敗後「開路(open)」,在 open 狀態下直接 fail-fast 不發出請求;冷卻後進入 half-open 測試是否恢復。
Why — 分散式系統的上游服務崩潰時,下游若持續重試會耗盡執行緒/連線池,引發 cascading failure 讓整個服務群連鎖崩潰。
When — 任何呼叫外部 HTTP API、DB、快取、gRPC 服務的地方。
- Anti-use-case:同一程序內部方法呼叫;對自己完全控制的本地資源(local file system 等)。
Where — 服務之間的呼叫邊界;API gateway 與下游服務之間;gRPC client interceptor。
Who
- resilience4j — https://github.com/resilience4j/resilience4j(Java/Kotlin,annotation-driven,同時涵蓋 Bulkhead、Retry、RateLimiter)
- Netflix Hystrix — https://github.com/Netflix/Hystrix(已停止維護,但概念發源地,值得讀源碼理解設計)
- Polly — https://github.com/App-vNext/Polly(.NET,policy 組合語法清晰)
- Sony gobreaker — https://github.com/sony/gobreaker(Go,輕量,適合嵌入 client 層)
實踐
三狀態轉換:CLOSED(正常)→ OPEN(開路 fail-fast)→ HALF_OPEN(試探)→ 回到 CLOSED 或 OPEN。
Threshold 設計的常見誤解:
- 固定次數閾值在低流量時反應過慢(10 次失敗在高流量是 1 秒,在低流量是 1 小時)。
- 正確做法是時間窗口比例閾值(sliding window,如 60 秒內 50% 失敗率觸發)。
- resilience4j 兩種都支援:
COUNT_BASED與TIME_BASED。
注意事項與常見坑:
- open 狀態要有明確的 fallback(cache 舊資料、降級回應、預設值)——fail-fast 沒有 fallback 等於把問題往上推。
- 不要把 Circuit Breaker 與 Retry 疊加而不調整參數:retry 3 次 × failureThreshold 5 次 = 要 15 次失敗才開路,保護效果大打折扣。
- half-open 狀態只放少量試探請求,別把整個 traffic 一次切回去。
Bulkhead
What — 把資源(執行緒池、連線池、semaphore)依消費者/服務隔離;一個艙壁破損,其他艙壁不受影響。名稱來自船舶的水密艙壁設計。
Why — 共享資源池下,一個慢下游佔滿全部執行緒,其他正常呼叫全部排隊超時。Bulkhead 讓資源競爭侷限在單一隔間,避免單點故障擴散為全局故障。
When — 多租戶系統;一個服務同時呼叫多個不同重要性的下游(核心付款 vs 邊緣推薦服務)。
- Anti-use-case:資源本就富餘的低流量系統(過早優化增加複雜度);所有下游等重要性時(隔離反而造成資源浪費)。
Where — 服務的出口呼叫層;多租戶 API 的入口;istio/linkerd service mesh 的 connection pool 設定。
Who
- resilience4j — https://github.com/resilience4j/resilience4j(ThreadPoolBulkhead 與 SemaphoreBulkhead 兩種實作)
- Akka — https://github.com/akka/akka(dispatcher 隔離,每個 actor system 可以有獨立的 dispatcher 與執行緒池)
- Istio DestinationRule 的
connectionPool設定,在 sidecar 層做隔離,應用無感知。
實踐
兩種 Bulkhead 類型的選擇:
ThreadPoolBulkhead:適合呼叫阻塞式 I/O(傳統 JDBC、同步 HTTP client);隔離性強但執行緒有 context switch 成本。SemaphoreBulkhead:適合 reactive/non-blocking;overhead 低,但所有呼叫共享同一執行緒池,只限制並發數量。
注意事項與常見坑:
- Bulkhead 大小(執行緒數或 semaphore 上限)要靠壓測決定,不是憑感覺——每個下游的延遲特性不同。
- 與 Circuit Breaker 搭配效果最好:Bulkhead 限制並發,Circuit Breaker 偵測失敗模式。
- 別讓 bulkhead queue size 無限大——queue 滿了要 reject,否則 bulkhead 變成一個超大緩衝區,沒有保護效果。
Hexagonal Architecture(Ports & Adapters)
What — 業務核心(application/domain)定義 port(介面);外部世界(DB、HTTP、訊息佇列、CLI)透過 adapter 實作 port;核心不依賴任何外部框架或基礎設施。
Why — 測試困難、框架升級成本高、業務邏輯被框架污染。Hexagonal 讓核心可以獨立測試(用 in-memory adapter),也讓切換基礎設施(換 ORM、換 broker)不需改業務邏輯。
When — 預期長達數年的應用程式;業務邏輯複雜的服務;需要多種測試層次(unit/integration/e2e)的系統。
- Anti-use-case:CRUD-only 服務;短生命週期腳本;prototype 階段——這些場景下 Hexagonal 是過度設計。
Where — 整個應用的架構層次劃分;特別是 domain logic 與 infrastructure 的邊界。
Who
- Alistair Cockburn 原始論文(2005)— https://alistair.cockburn.us/hexagonal-architecture/
- Spring + DDD 社群範例 — https://github.com/hirannor/springboot-hexagonal-ddd(Java/Spring 落地參考)
- Netflix Conductor — 介面與實作分離的設計哲學,workflow engine 層與執行層的 port 設計。
實踐
Port 的方向性:
- Primary port(driving port)= 外部驅動服務的入口,如 HTTP controller、CLI、gRPC server。
- Secondary port(driven port)= 服務驅動外部資源的出口,如
UserRepository、EventPublisher。
注意事項與常見坑:
- Port 命名要用 domain 語言,不是 DB 語言:
OrderRepository(domain)而不是OrderJpaRepository(infrastructure 洩漏)。 - Hexagonal 不等於強制三層(presentation/application/domain)——可以更扁,重點是核心不依賴外部。
- Adapter 測試要分兩種:unit test 用 in-memory adapter 測 domain logic;integration test 才啟動真實基礎設施測 adapter 本身。
- AI 生成程式碼更容易混淆 port/adapter 邊界——考慮用 ArchUnit(Java)或 dependency-cruiser(Node)加 linting 規則強制邊界。
Strangler Fig
What — 在舊系統前加一個 facade(通常是 API gateway 或 proxy);把請求逐步路由到新系統,直到舊系統被完全「絞殺」替換。命名來自絞殺榕——先寄生舊樹,最終取而代之。
Why — 大爆炸式重寫(big-bang rewrite)歷史上失敗率極高(Netscape 6、Google 多個放棄的重寫項目);Strangler Fig 讓新舊並行、增量遷移、每步可驗證、可回退。
When — 任何需要替換遺留系統的場景。
- Anti-use-case:系統太小,直接重寫更快;遺留系統無法被 HTTP/event 層攔截(緊耦合批次作業、無 API 層的直連 DB 系統)。
Where — 系統遷移的整個生命週期;facade 通常在 API gateway 或 reverse proxy 層。
Who
- Martin Fowler 2004 命名 — https://martinfowler.com/bliki/StranglerFigApplication.html(概念來源)
- AWS Migration Hub patterns — 雲端遷移場景的具體建議。
- LinkedIn 從 Leo 單體到微服務的演進——公開 Engineering Blog 有詳細敘述,是業界最有名的 Strangler Fig 案例之一。
實踐
Facade 路由策略:
- Feature flag 路由:同一個 endpoint 可以 A/B 路由到新舊系統,方便灰度放量。
- 流量百分比路由:先 1%,驗證後 10%、50%、100%。
- 特定用戶/租戶優先遷移(canary 用戶)。
注意事項與常見坑:
- 設定明確的完成里程碑——防止「永遠在 strangling」,舊系統殭屍化但沒人負責下線。
- DB 遷移是最難的環節:要考慮雙寫(write to both)+ 漸進切讀;資料格式轉換要在 facade 層或 adapter 層處理。
- 要有 metrics 追蹤新系統流量佔比,可視化遷移進度。
- 別忽略舊系統的 batch job、定時任務、non-HTTP 入口——這些往往是最後被遷移的長尾。
冪等性(Idempotency)
What — 相同操作執行多次,結果與執行一次相同。對冪等 API 的重試是安全的,不會產生重複副作用。
Why — 網路不可靠,訊息佇列是 at-least-once delivery——接收端必須處理重複;支付、訂單建立等操作若不冪等,重試就是災難。
When — 任何暴露給外部的 mutation API;任何 message consumer;任何定時任務。
- Anti-use-case:純讀取操作(GET 天生冪等)——額外的冪等機制反而增加複雜度;純日誌記錄(append-only,重複是期望行為)。
Where — API 層(HTTP mutation endpoints);message consumer 層;事件處理器;定時批次任務。
Who
- Stripe — https://stripe.com/docs/api/idempotent_requests(`Idempotency-Key` header,DB-backed 存儲 key→response 映射,業界標準範例,有 24h TTL)
- AWS SQS FIFO — at-least-once +
MessageDeduplicationId,broker 層去重。 - Kafka consumers — consumer offset commit 本身就是 idempotent read progress 的設計。
實踐
幾種常見的冪等實作策略:
- DB unique constraint — 最簡單,
INSERT IF NOT EXISTS(Postgres 用ON CONFLICT DO NOTHING),讓 DB 保證唯一性。 - Idempotency key 映射表 — 服務端存儲
key → response,key 由呼叫方生成(UUID v4),有 TTL 自動清理。 - 狀態機冪等 — 操作前檢查當前狀態,已到目標狀態則直接回傳成功(訂單已付款,重複付款請求直接回傳已付款)。
- 事件消費冪等 — 用
processed_events表,收到事件前先查event_id是否已處理。
注意事項與常見坑:
- Idempotency key 必須由呼叫方生成(不是服務端),這樣重試時 key 相同。
- Key 的 TTL 設計要考慮最長合理重試窗口(Stripe 24h 是業界常見參考值)。
- 冪等 key 存儲要和業務操作在同一個 DB 事務——否則 key 寫入成功但業務操作失敗,下次重試被誤判為已處理。
- 分散式快取(Redis)做冪等 key 存儲要考慮 cache eviction——TTL 到期前 key 被驅逐,重試會被當新請求處理。
Actor Model
What — 計算的基本單元是 Actor;Actor 只能透過訊息通訊,沒有共享記憶體;每個 Actor 有獨立的 mailbox,訊息按序處理;Actor 可以建立子 Actor、監督(supervise)子 Actor 的失敗。
Why — 多執行緒共享記憶體的並發問題(race condition、deadlock)在高並發系統中難以正確實作。Actor 用訊息隔離狀態,讓並發推理回歸線性——每個 Actor 的邏輯是單執行緒的。
When — 高並發、分散式狀態管理(IoT device 管理、遊戲伺服器、即時協作、電信系統)。
- Anti-use-case:簡單的 request-response 服務(Actor overhead 不值得);CPU-bound 批次計算(shared-memory 並發更快,避免訊息序列化成本)。
Where — 有狀態服務的並發控制層;分散式系統的節點狀態管理;事件驅動的 domain object 生命週期管理。
Who
- Erlang OTP — https://github.com/erlang/otp(Actor model 的工業原始實作,Ericsson 電信系統,99.9999999% uptime 的傳說背後是 supervisor tree 設計)
- Akka — https://github.com/akka/akka(JVM,涵蓋 actor + cluster + persistence)
- Microsoft Orleans — https://github.com/dotnet/orleans(.NET,Virtual Actor(grain)概念,不需手動 actor 生命週期管理)
- Pekko — https://github.com/apache/incubator-pekko(Akka 的 Apache fork)
實踐
Actor supervision tree 設計是核心架構決策:
- 誰監督誰(supervisor hierarchy)決定故障隔離的邊界。
- 失敗策略:
restart(重啟 actor,清空狀態)、stop(停止,讓父 actor 決定)、escalate(向上傳遞)。 restart會丟失 actor 記憶體狀態——需要持久化的用 Akka Persistence(event sourcing)。
Orleans vs 傳統 Akka:
- Orleans virtual actor(grain)的優勢是不需手動 activate/deactivate,框架自動管理——適合雲原生、大量 actor 實例(百萬級)。
- Akka 更底層,控制更細緻,適合需要精確控制 actor 生命週期的系統。
注意事項與常見坑:
- 訊息要設計成不可變(immutable)——共享可變訊息會把 Actor model 的安全性保證破壞殆盡。
- Actor mailbox 滿了要有 backpressure 或 drop 策略(Akka 的 bounded mailbox)。
- Actor model 不是萬靈丹——event sourcing 配 Actor(Akka Persistence)才能做持久化狀態,否則 actor restart 後狀態全失。
- 避免在 Actor 的訊息處理中做阻塞 I/O——這會讓 actor dispatcher 的執行緒耗盡,效果比不用 Actor 更糟。
Materialized View
What — 預先計算並持久化查詢結果(把複雜 join/聚合結果存成一張「假表」);讀取直接命中預計算結果,寫入時更新 materialized view。
Why — 讀寫比懸殊的系統裡,每次讀取都跑複雜 join 是資源浪費;Materialized View 是「用儲存空間換查詢時間」的典型取捨,讀取複雜度從 O(join) 降至 O(1)。
When — CQRS read side;dashboard;報表;全文搜索索引——任何「查詢複雜但更新頻率可接受延遲」的場景。
- Anti-use-case:資料高頻更新且需要強一致性(materialized view 通常 eventually consistent);view 的計算代價比查詢本身更高(反而增加總成本)。
Where — CQRS 的 read model 層;報表與 analytics 資料庫;搜索引擎索引;Redis 作為預計算快取。
Who
- PostgreSQL Materialized Views — https://www.postgresql.org/docs/current/rules-materializedviews.html(DB 原生支援,
REFRESH MATERIALIZED VIEW CONCURRENTLY) - Redis — https://github.com/redis/redis(作為 materialized cache 的事實標準)
- Apache Kafka + ksqlDB — https://github.com/confluentinc/ksql(stream processing 產出 materialized table,即時 stream→view 轉換)
- Elasticsearch — https://github.com/elastic/elasticsearch(作為 search materialized view,document index 本身就是主表的 denormalized 映射)
實踐
三種更新策略的取捨:
| 策略 | 一致性 | 寫入延遲 | 適用場景 |
|---|---|---|---|
| Eager(同步更新) | 強 | 高 | 讀一致性要求高的核心功能 |
| Lazy(讀時更新) | 弱 | 低(寫),高(首次讀) | 讀頻率低的冷資料 |
| Scheduled(定期批次刷新) | 最終一致 | 低 | 報表、dashboard |
注意事項與常見坑:
- Invalidation 邏輯比 cache 更複雜——要追蹤 source table 與 view 的依賴關係,多表 join 的 view 需要監聽多個來源。
- PostgreSQL 的
REFRESH MATERIALIZED VIEW非 CONCURRENTLY 版本會鎖表,生產環境要用 CONCURRENTLY 版本(需要 unique index)。 - 在 CQRS 架構裡,materialized view 就是 projection;Event Sourcing 天然支援重播所有事件重建 view(見 event-sourcing-cqrs-後端落地)。
- 多個 materialized view 之間若有依賴(view A 依賴 view B 的結果),刷新順序要正確管理,否則產生不一致的中間態。
與既有專欄的關係
CQRS 與 Event Sourcing 的深度討論(Event Store 設計、Aggregate 狀態機、schema evolution)收錄在 Event Storming + Event Sourcing + Pattern Language 專欄,本文不重複。
本文聚焦在上述九個 pattern 的進階取捨與實際落地,與既有事件驅動專欄可交叉對照。
待解問題 / 持續追蹤
- Saga orchestration vs choreography 的選擇標準業界沒有定論——有無可量化的複雜度或可維護性指標?
- Circuit Breaker 的 threshold 參數業界如何自動調整?有 adaptive circuit breaker 的成熟實作嗎?
- Hexagonal Architecture 在 AI coding 時代的意義:AI 生成的 code 更容易把邊界混淆——是否需要 linting 工具強制 port/adapter 分離?
- Actor model 的 Orleans virtual actor 和傳統 Akka actor 的性能邊界在哪裡?有公開 benchmark 嗎?
更新紀錄
- 2026-07-03 — 建立。涵蓋 Saga、Outbox、Circuit Breaker、Bulkhead、Hexagonal、Strangler Fig、冪等性、Actor Model、Materialized View 九個後端 pattern。