故事,要從一個燈火通明,卻混亂不堪的夜晚說起。
今天是 Codetopia 蒼螺夜市的開幕夜。河灣停車場被改造成一片美食的海洋,空氣中瀰漫著烤香腸與炙燒牛肉的香氣。然而,市民們臉上洋溢的,卻不是幸福,而是「怎麼又是你」的厭世表情。
晚上 7 點半,入口處,志工的掃描器對著市民手機上的 QR Code,發出了一連串無情的「嗶嗶嗶」失敗音。供應商 A 的票務系統還活在 auth_v1
的舊時代,而我們熱心的志工們,手上的設備早已是 auth_v2
的新銳。兩邊雞同鴨講,直接把入口變成了大型停車場。
鏡頭轉到攤位區,4G 訊號在這裡彷彿進入了異次元,時有時無。市民們的行動支付請求在空中飄蕩,攤商們急得滿頭大汗,只能瘋狂點擊「重試」。好消息是,錢最終都付了;壞消息是,有時候付了兩三次。(市民的錢包在哭泣啊!)
砰! 一聲悶響,高耗電的章魚燒攤位電力跳脫,結果整排攤商跟著陷入黑暗,上演了一場「我們與餓的距離」。
更糟的是,消防局的例行巡檢、周邊住戶的噪音投訴電話,像潮水般同時湧入市民服務專線。專線瞬間被塞爆,變成一條名副其實的「斷線」。
晚上 10 點 45 分,夜市營運指揮 Helena 站在指揮帳篷裡,看著眼前這場華麗的災難,臉色比停電的攤位還黑。這時,事件平台架構師 Theo 默默地拿出了一塊白板,寫下幾個大字:
「把排隊換成事件,把緊耦合拆成自治。」
一場關於 Codetopia 未來的緊急對策會議,就此展開。
EDA(Event-Driven Architecture):一種以「事件」作為核心驅動力的架構,服務之間不再直接呼叫,而是透過發布與訂閱事件來溝通,達成鬆耦合。
Choreography vs Orchestration:編舞 (Choreography) 就像舞會,大家聽到音樂(事件)各自起舞;編排 (Orchestration) 則像交響樂,需要一位指揮家(Orchestrator)來協調複雜的跨服務流程。
Outbox Pattern:一種確保「資料更新」與「事件發送」這兩件事要嘛都成功、要嘛都失敗的模式,避免系統精神分裂。
Idempotency Key(冪等鍵):一個獨特的識別碼,用來告訴系統:「嘿,這個操作我做過了,別再重複執行了!」是處理網路抖動的救星。
Saga(長交易):當一個跨多個服務的交易無法用傳統資料庫 ACID 搞定時,就用一系列「本地交易+補償動作」來維持最終的資料一致性。
DLQ(Dead-Letter Queue,死信佇列):處理失敗的事件一個有尊嚴的「安息之地」,而不是讓它們在系統裡像孤魂野鬼一樣到處亂竄。
讓我們倒帶回到災難現場,看看那些「經典」的壞味道設計是如何把開幕夜變成災難片的:
排隊直連地獄:入口的 GateApp
天真地以為,只要不斷直接呼叫 TicketingHTTP.verify()
,總有一次會成功。結果 API 一逾時,它就更賣力地同步重試,像個執著的恐怖情人,最終把整個入口活活卡死。
支付重扣悲劇:POST /pay
的請求裡,根本沒有冪等鍵這個概念。於是,在 4G 訊號的抖動下,市民每重送一次,系統就開心地再扣一次款。攤商老闆的笑容,逐漸母湯。
把資料庫當匯流排:有人覺得在 Orders
資料表上掛一個 AFTER INSERT
觸發器來「順便」發通知很聰明。結果呢?資料庫交易一回滾 (Rollback),觸發器也跟著惦惦,全城靜悄悄,彷彿什麼事都沒發生過。
一神控全域的恐懼:一個萬能的 NightMarketController
試圖掌控一切。無論收到什麼錯誤,它的 try/finally
區塊都只會一招:notify_everyone()
。結果就是訊息亂序、重複、雪崩三合一大放送,天下大亂。
事件的濫用:把查詢訂單狀態這種需要即時回饋的操作,也包成事件丟進去排隊。「先生,您的訂單狀態我們正在處理中,請稍候五秒...」客人聽完,轉身就去隔壁攤了。
在 Helena 幾乎要拍桌的眼神壓力下,Theo 在白板上迅速勾勒出了解決方案的藍圖:
打造城市事件總線 (City Event Bus):我們不再私下打電話!所有溝通都透過標準化的主題 (Topic) 進行,版本號直接包含在 Topic 中,例如 pay.txn.v1
。每一則事件都必須攜帶冪等鍵、關聯 ID (Correlation ID),以及明確的事件類型 (Event Type)。
協作優先,編排兜底:預設情況下,各個服務都是獨立自主的專家(「編舞」)。只有在處理像「實際發生重複扣款」這種需要跨服務補償的長交易時,才交給 Ivy|事件編排師 的 Orchestrator 服務擔任總指揮。
Outbox + Inbox 雙保險:發布端(Producer)採用 Outbox Pattern 確保交易與事件的原子性。消費端(Consumer)則採用 Inbox Pattern 進行去重,避免因為上游重播事件而導致副作用重複執行。
優雅的失敗治理:面對失敗,我們不再手忙腳亂。統一的指數退避 (Exponential Backoff) + 抖動 (Jitter) 策略避免重試風暴。無法處理的事件會進入 DLQ,並觸發告警,等待人工介入。
嚴謹的版本治理:Topic 包含版本號。事件 Schema 的演進規則是:「只許新增欄位、不許刪改舊欄位、新欄位必須有預設值或為可選」。舊的消費者可以繼續運行,不受影響。
安全與隱私:冪等鍵與關聯 ID 嚴禁包含個資 (PII)。事件的 payload 應奉行最小化原則,並對敏感資料進行脫敏。
✅ 當你需要解耦:當系統由多個獨立的服務組成,你希望它們能各自演化、部署,而不會因為一個服務的變動就導致其他服務跟著修改。
✅ 追求高彈性與擴展性:當業務流量有明顯的波峰波谷,或未來可能新增許多未知的事件消費者時,事件驅動能讓系統像樂高一樣輕鬆擴展。
✅ 處理非同步與長時間運行的任務:例如下單後的庫存、物流、通知流程,這些不需要立即同步完成的工作,最適合用事件來驅動。
⛔ 簡單的 CRUD 或請求-回應場景:如果你的業務就是一個簡單的「讀取資料 -> 回傳」,硬要套用 EDA 反而會把事情搞複雜。(殺雞焉用牛刀!)
⛔ 需要強一致性的原子交易:如果一個操作橫跨多個服務,而且必須「瞬間」完成,不可分割,傳統的分散式交易所可能更適合。EDA 追求的是「最終一致性」。
⛔ 團隊對非async與最終一致性還不熟悉時:EDA 的除錯與監控相對複雜,貿然導入可能會帶來比解決的問題更多的麻煩。
導播,鏡頭拉一下!讓我們從三個不同的尺度,來看看這個新架構的樣貌。
閱讀順序:先看 ① 微觀的物件協作,再到 ② 中觀的事件流程,最後是 ③ 宏觀的角色協定。
視角 | 觀念/模式 | 在城市的說法 |
---|---|---|
微觀 (GoF) | Observer / State / Mediator | 城市廣播台 / 交通號誌 / 調度中心 |
中觀 (EIP/EDA) | Topic / Queue / Router / Saga | 事件總線 / 訊息佇列 / 內容路由 / 長交易 |
宏觀 (MAS) | DF / ACL | 黃頁服務 / 代理通訊語言 |
5.1 微觀 (GoF)|事件觀察與調度 (Class Diagram)
5.2 中觀 (EDA 流程)|「重複扣款補償」編排兜底 (Sequence Diagram)
5.3 宏觀 (MAS)|「黃頁 + 協作協定」
在宏觀的城市治理層面,市府的「黃頁服務 (DF)」會登錄所有具備處理 pay.*
、gate.*
等事件能力的代理(微服務)。各局處之間,則嚴格遵守「代理通訊語言 (ACL)」的協定來進行協作。
Talk is cheap, show me the code. 讓我們看看這套精修後的機制,如何區分「重送請求」與「重複扣款」。
from dataclasses import dataclass, field
from typing import Callable, Dict, Any
import uuid, time, random
# --- Helper functions for readability ---
def new_id() -> str: return str(uuid.uuid4())
def now() -> float: return time.time()
# 神經串連:版本號併入 Topic,Event 結構更精簡
@dataclass(frozen=True)
class Event:
id: str
topic: str # e.g. "pay.txn.v1"
ts: float
key: str
type: str # 'requested', 'captured', 'duplicate_captured', etc.
payload: Dict[str, Any]
correlationId: str | None = None
causationId: str | None = None
# ... Bus 和 IdempotencyStore 的實作與前一版相同 ...
class Bus:
def __init__(self): self.subs: Dict[str, list] = {}
def publish(self, e: Event): print(f"🚌 BUS ('{e.topic}'): {e.type} | key={e.key}"); [h(e) for h in self.subs.get(e.topic, [])]
def subscribe(self, topic: str, handler): self.subs.setdefault(topic, []).append(handler)
class IdempotencyStore:
def __init__(self): self._seen = {}
def seen(self, key: str) -> bool: return key in self._seen
def mark(self, key: str, result: Any): print(f"멱 Idempotency: Marked '{key}'."); self._seen[key] = result
def get_result(self, key: str): return self._seen.get(key)
bus, idem = Bus(), IdempotencyStore()
# --- 核心邏輯:區分「請求重送」與「重複扣款」 ---
def on_pay_requested(e: Event):
"""處理支付請求的 Handler,現在更穩健了"""
# 補丁B:Handler 型別守門,避免處理非預期事件
if e.type != "requested":
return
order_key = e.key # 以 orderId 作為冪等鍵
# 情況一:重送請求 (Idempotency Key 已存在)
if idem.seen(order_key):
print(f"🔄 PAYMENT: Replayed request for key '{order_key}'.")
last_result = idem.get_result(order_key)
bus.publish(Event(new_id(), "pay.txn.v1", now(), order_key, "result_replayed",
{"result": last_result}, e.correlationId, e.id))
return
# 情況二:全新請求
is_real_duplicate, txn_id, orig_txn_id = check_and_capture_payment(order_key)
if is_real_duplicate:
# 情況三:真的發生了「重複扣款」
print(f"‼️ PAYMENT: Duplicate CAPTURE detected for key '{order_key}'.")
idem.mark(order_key, {"status": "duplicate_captured", "dupTxnId": txn_id})
bus.publish(Event(new_id(), "pay.txn.v1", now(), order_key, "duplicate_captured",
{"dupTxnId": txn_id, "origTxnId": orig_txn_id},
e.correlationId, e.id))
else:
# 情況四:首次成功扣款
print(f"✅ PAYMENT: Payment captured for key '{order_key}'.")
idem.mark(order_key, {"status": "captured", "txnId": txn_id})
bus.publish(Event(new_id(), "pay.txn.v1", now(), order_key, "captured",
{"txnId": txn_id}, e.correlationId, e.id))
# --- 模擬外部支付閘道與資料庫檢查 ---
captured_txns = {}
def check_and_capture_payment(order_key):
if order_key in captured_txns:
new_txn_id = f"TXN_{random.randint(9000,9999)}"
return True, new_txn_id, captured_txns[order_key]
else:
new_txn_id = f"TXN_{random.randint(1000,8999)}"
captured_txns[order_key] = new_txn_id
return False, new_txn_id, None
# 補丁A:訂閱時 Topic 已包含版本號
bus.subscribe("pay.txn.v1", on_pay_requested)
# ... Orchestrator 訂閱 duplicate_captured 的邏輯在此省略 ...
為了達到工業級的可靠性,光靠上面的程式碼還不夠。我們還需要 Outbox 和 Inbox 模式來保證事件的傳遞。
# --- 在 Producer (如 PaymentService) 的交易中 ---
def capture_payment_with_outbox(order_id, amount):
with db.transaction(): # 在同一個資料庫交易內
# 1. 執行核心業務邏輯
orders.update_status(order_id, "CAPTURED")
# 2. 將要發送的事件寫入 outbox 資料表
outbox.insert({
"id": new_id(), # 補丁C:明確事件主鍵,可以是 UUID 或 DB 自增
"topic": "pay.txn.v1",
"key": order_id,
"type": "captured", "payload": {"amount": amount},
"correlationId": get_current_correlation_id()
})
# 交易提交後,DB 和 outbox 的內容才會被確認
# --- 獨立的 Relay 服務:負責把 Outbox 的事件發出去 ---
def relay_process():
while True:
events_to_send = outbox.poll(batch=100)
for event in events_to_send:
try:
bus.publish(to_event(event))
outbox.mark_sent(event.id) # 發送成功後標記
except TransientError:
backoff_with_jitter(event.id) # 網路問題就稍後重試
time.sleep(1)
# --- 在 Consumer (消費者) 端的 Handler ---
def payment_projection_handler(e: Event):
# 1. 先檢查 Inbox,避免重複處理同一個事件
if inbox.already_handled(e.id):
return
with db.transaction(): # 在消費者這邊也用交易包裹
# 2. 標記事件已處理
inbox.mark_handled(e.id)
# 3. 才執行真正的副作用 (更新報表、通知...等)
reporting_dashboard.update(e)
在推動事件驅動架構時,請務必留意這些飄揚的紅色警示旗:
把同步 RPC 塗成非同步的樣子:只是把一個 HTTP 呼叫包在事件裡,然後焦急地等待回應事件,這不是 EDA,這是自欺欺人。
沒有冪等鍵就敢談重放:在沒有冪等設計的情況下重播死信佇列裡的事件,不是在解決問題,是在重演災難。
一隻「中央編排神獸」:如果所有事件都要經過一個中央 Orchestrator,恭喜你,你只是把緊耦合換了個地方塞回去而已。
以資料庫觸發器代替真正的事件匯流排:交易一回滾,事件就人間蒸發,這會讓你的系統失去最重要的「事實根據」。
對事件版本放任不管:今天改個欄位,明天換個格式,卻不升級版本號。很快,你的舊服務就會像看不懂新公文一樣,集體失語。
指望 exactly-once:補丁D:在分散式系統中是神話;請用 at-least-once + 冪等鍵 + 補償,並以 DLQ/觀測守住邊界。
從今天的夜市混戰中,我們可以把視野拉得更高更遠:
與六角架構的完美結合:事件的發布與接收,正是發生在六角架構的「邊界」上。事件驅動的 Adapter 讓我們的核心業務邏輯(Domain)保持純淨,這完美地銜接了 Day 28 的「邊界保護」精神。
從微服務到多代理系統:如果我們把每個微服務看作一個具備特定「能力」的「代理 (Agent)」,那麼事件就是它們之間溝通的「語言 (ACL)」。整個 Codetopia 就演變成一個大型的多代理協作網路。
補丁E:事件進出都在 Adapter 層,Domain 維持純淨(六角依賴只指向核心),Day 29 的事件語言才不會滲進 Day 28 的核心領域。
會議結束,天已微亮。在 Theo 的架構指導與團隊的連夜奮戰後,蒼螺夜市第二天,迎來了新生:
入口不相容?解決! gate.auth.v2
事件被順利發布。一個 Adapter 服務負責轉譯,確保新舊掃描器都能訂閱到自己看得懂的格式。
重複扣款?解決! 支付服務現在有了 Idempotency-Key 和 Outbox/Inbox 護體,能從容應對。一旦偵測到真正的「重複扣款」,就會觸發 Saga 流程完成退款。
用電跳脫?解決! 電力負載調度員 Sable 能監聽到 ops.power.v1
事件,並發出 Power.Shed
指令,實施智慧電力調度。
市服壅塞?解決! 市服專線隊長 Yuan 將所有通報轉換成標準化事件,由系統自動路由給權責單位。
蒼螺夜市,終於迎來了它應有的秩序與繁華。
要確保這套複雜的系統能穩定運行,測試策略至關重要:
合約測試 (Contract Testing):事件的 Schema 就是服務之間的「契約」。任何會破壞這個契約的變更,都應該在 CI/CD 階段就被攔截下來。
冪等性測試:寫個測試案例,用同一個 key 調用你的服務 N 次,然後斷言:最終的狀態只應該被改變一次。
重試/退避策略測試:利用測試時鐘 (Test Clock) 來驗證你的指數退避策略(包含抖動)是否如預期般,一次比一次等待更久。
DLQ 驗證:在測試中故意注入一個「有毒」的事件,驗證它在重試幾次後,是否真的被送進了死信佇列,並且觸發了監控告警。
契約測試(Schema/Topic 版本):對 pay.txn.v1
的 Schema 做「只增不改」的相容性檢查;任何破壞性變更(刪/改欄位)的 Pull Request 都會自動失敗。
退避階梯可觀測性:以假時鐘驗證 backoff 序列(含 jitter)符合預期;並驗證有毒訊息在達到重試上限後,必定落入 DLQ 且產生告警。
Partition 有序性測試:同一 key 重放 N 次,驗證處理順序與冪等。
身為總設計師的你,來動動腦吧!
回顧一下「笑中帶淚」章節那個用資料庫觸發器來發事件的「天才」設計。如果你是當時的英雄,你會如何利用 Outbox Pattern 來重構它?請用幾句話描述你的改造步驟。
(二選一) 假設資源有限,你必須在夜市重新開張前,決定優先上線的策略。你會選哪個?為什麼?(請在 30 字內說明)
A. 只上 Outbox + 冪等鍵,確保支付不丟失、不重複,暫時不做自動退款的 Orchestrator。
B. 先上 Orchestrator,用它來攔截並處理所有重複扣款的爛攤子。
摘要:把排隊換成事件,讓自治服務以冪等鍵和補償舞出秩序。
明日預告(Day 30):AI 多代理華麗登場,我們將把今天的事件,升格為更智慧的協作協定與能力路線圖!
為了確保在不支援 Mermaid 渲染的環境中也能正常閱讀,以下提供文中圖表的 ASCII 替代版本:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Ticketing │ │ Payments │ │ Power Ops │ │ Civic Hotline│
│ BC │ │ BC │ │ BC │ │ BC │
├──────────────┤ ├──────────────┤ ├──────────────┤ ├──────────────┤
│ GateService │ │PaymentService│ │ PowerService │ │ CivicRouter │
│ │ │ Ivy │ │ │ │ │
│ │ │ Orchestrator │ │ │ │ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
└───────────────────┼───────────────────┼───────────────────┘
│ │
┌──────▼───────────────────▼──────┐
│ City Event Bus │
├─────────────────────────────────┤
│ gate.auth.v2 │
│ pay.txn.v1 │
│ ops.power.v1 │
│ civic.events.v1 │
└─────────────────────────────────┘
GateService Bus PaymentService Orchestrator
│ │ │ │
├─ publish ─┤ │ │
│ gate. │ │ │
│ auth.v2 │ │ │
│ │◄─subscribe─┤ │
│ │ │ │
│ │ ├─ publish ─────┤
│ │ │ pay.txn.v1 │
│ │ │ "requested" │
│ │ │ │
│ │ ├─ publish ─────┤
│ │ │ pay.txn.v1 │
│ │ │ "captured" │
│ │ │ │
│ │ ├─ publish ─────┤
│ │ │ pay.txn.v1 │
│ │ │"duplicate_ │
│ │ │ captured" │
│ │ │ │◄─ monitor
│ │ │ │
│ │ │◄─ command ────┤
│ │ │ refund │
│ │ │ │
│ │ ├─ publish ─────┤
│ │ │ pay.txn.v1 │
│ │ │ "refunded" │
Producer Service Event Bus Consumer Service
┌─────────────────┐ ┌──────────┐ ┌─────────────────┐
│ Domain Tx │ │ Topic: │ │ Inbox Table │
│ │ │pay.txn.v1│ │ │
│ │ │ │ │ │ │ │
│ ▼ │ │ ┌─────┤ │ ▼ │
│ ┌─────────────┐ │ │ │ │ │ ┌─────────┐ │
│ │ Outbox │ │ ────── │ │ │ ──────────▶│ │Handler │ │
│ │ Table │ │ publish │ │ │ consume │ │ │ │
│ └─────────────┘ │ │ │ │ │ └─────────┘ │
│ │ │ │ └─────┤ │ │ │
│ ▼ │ │ │ │ ▼ │
│ ┌─────────────┐ │ │ ┌────┐ │ │ ┌─────────────┐ │
│ │Outbox Relay │ │ │ │DLQ │ │ │ │Side Effects │ │
│ └─────────────┘ │ │ └────┘ │ │ └─────────────┘ │
└─────────────────┘ └──────────┘ └─────────────────┘
[*]
│
▼
┌─────────────┐
│ Requested │
└─────┬───────┘
│
├──capture ok──┐
│ ▼
│ ┌─────────────┐
│ │ Captured │ ──────── [*]
│ └─────────────┘
│
└──detected by gateway──┐
▼
┌─────────────────────┐
│ DuplicateCaptured │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ RefundCommanded │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ Refunded │ ──────── [*]
└─────────────────────┘
Event Sources Event Bus (Partitioned) Consumers
orderId=A1 ──hash(A1)──┐ ┌─────────────┐ ┌──────────────┐
orderId=A2 ──hash(A2)──┼───▶│ Partition 0 │ ───────────────▶│ A1,A2 事件 │
orderId=A3 ──hash(A3)──┘ └─────────────┘ │ 保證局部順序 │
└──────────────┘
orderId=B1 ──hash(B1)──┐ ┌─────────────┐ ┌──────────────┐
orderId=B2 ──hash(B2)──┼───▶│ Partition 1 │ ───────────────▶│ B1,B2 事件 │
orderId=B3 ──hash(B3)──┘ └─────────────┘ │ 保證局部順序 │
└──────────────┘
orderId=C1 ──hash(C1)──┐ ┌─────────────┐ ┌──────────────┐
orderId=C2 ──hash(C2)──┼───▶│ Partition 2 │ ───────────────▶│ C1,C2 事件 │
orderId=C3 ──hash(C3)──┘ └─────────────┘ │ 保證局部順序 │
└──────────────┘
[*]
│
▼
┌──────────┐
│ Handling │
└────┬─────┘
│
┌─────────┼─────────┐
│ │ │
success transient non-retryable
│ error or max retry
▼ │ │
┌──────────┐ ▼ ▼
│ Acked │ ┌──────────┐ ┌──────────┐
└──────────┘ │RetryWait │ │ DLQ │
│ └────┬─────┘ └────┬─────┘
│ │ │
│ backoff+jitter │
│ │ │
▼ └─────────────┘▼
[*] [*]
PayTxnV1 (pay.txn.v1)
┌─────────────────────────────┐
│ + topic: "pay.txn.v1" │
│ + type: requested|captured| │
│ duplicate_captured| │
│ refunded │
│ + key: orderId │
│ + correlationId: UUID │
│ + causationId: UUID │
│ + payload.txnId?: string │
│ + payload.origTxnId?: str │
│ + payload.dupTxnId?: str │
└─────────────────────────────┘
│
│ backward compatible
│ (add-only)
▼
PayTxnV2 (pay.txn.v2)
┌─────────────────────────────┐
│ (繼承所有 V1 欄位) │
│ + payload.amount?: number │ ◄── 只增欄位
│ + payload.currency?: string │ ◄── 向後相容
└─────────────────────────────┘