總覽與定位
決定用事件重劃服務邊界之後:一則事件帶多少資訊、流程誰指揮、訊息如何可靠地離開交易。
訊息系統的骨幹已由三篇講清楚:訊息佇列基礎篇定義 Queue 與 Log、以及 at-least-once 是預設;Kafka 架構篇把一則紀錄定址成 topic + partition + offset;串流處理篇在那條可重放日誌上做視窗與水位。本篇不再重講那些定義。這裡要答的是另一個問題:服務之間為什麼改用事件說話,以及說出去之後怎樣才算可靠。
同步 RPC 把「我改完自己的狀態」和「你也改完你的狀態」綁在同一次呼叫。呼叫者承擔對方的延遲、重試與容量;兩邊的資料庫卻從來不在同一筆交易裡。事件驅動把這條線拆開:一個服務先把自己的事實寫下來,再以事件通知其他人。耦合從「你必須現在回應我」變成「你何時跟上這則事實」。換來的是最終一致、可獨立擴充的消費者,以及一條新的失敗面——訊息可能沒離開交易、可能重複抵達、可能帶著舊 Schema 撞上新消費者。
與 分散式交易手冊 的界線必須畫清。那篇負責 2PC / TCC / Saga 的理論與補償語義——什麼時候該 Try、什麼時候補償、協調者掛了怎麼辦。本篇假設你已經決定改走事件,只談這之後的工程:訊息如何跟本地交易綁在一起離開、重複與亂序抵達時 handler 怎麼不寫壞下游。補償步驟的設計不在這裡重述。
交付語意的定義回訊息佇列基礎;broker 分段、ISR、Rebalance 回 Kafka;Window 與 Watermark 回串流處理。CDC 作為 Outbox 發佈器的替代讀取路徑,機制細節留給 CDC 那篇。本篇把 CDC 只當成「誰把 outbox 列變成主題上的紀錄」的一種實作。
一則事件該攜帶多少資訊
Notification、Event-Carried State Transfer、Event Sourcing:耦合、回查與重建,三者不可同時最好。
「訂單已付款」可以是三則完全不同形狀的事件。差別不在主題名稱,在消費者要不要再回頭問發布者,以及這則事件能不能獨立重現當時的世界。
- 典型 payload:
{ orderId }。 - 消費者收到後打回訂單服務取完整狀態。
- 發布者 Schema 幾乎不演進;消費者永遠看到最新列。
- 典型:
{ orderId, sku, qty, amount, paidAt }。 - 消費者不必回查就能入帳、扣庫存、寫搜尋索引。
- 發布者與消費者共用一份契約,Schema 演進變成主戰場。
- 狀態不是一列現況,是從事件重放得到的摺疊。
- 任何投影(報表、讀模型、稽核)都能從同一條 log 重建。
- 代價:事件不可變、版本必須可重放、查「現況」要另做投影。
三個指標一起看
| Notification | Carried State | Event Sourcing | |
|---|---|---|---|
| 發布者—消費者耦合 | 最低(只約好 ID) | 中(契約即欄位) | 高(歷史事件都是 API) |
| 查詢成本 | 每次回查發布者 | 消費當下自給 | 讀模型另建;寫路徑只 append |
| 重建能力 | 幾乎沒有:當時快照已消失 | 能重放這則攜帶的狀態,看不到沒帶的欄位 | 從 origin 重放整條歷史 |
| 發布者負載 | 寫入便宜,讀被消費者打爆 | 寫時多序列化一點,讀壓力外移 | 寫只 append;讀走投影 |
沒有「事件就該帶全量」或「事件就該只帶 ID」的教條。看消費者能不能忍受回查、以及你是否真的要把事件當帳本。
Notification 的隱藏耦合常被低估:消費者回查時,發布者可能已經走到下一狀態。「已付款」通知抵達時訂單已退款,回查看到的是 refunded。你以為在訂閱一個事實,其實在輪詢一個會變的列。Carried State 把「付款那一瞬」凍在 payload 裡,換來的是契約要一起演進。Event Sourcing 再往前一步:連「現況列」都不當真相,任何讀模型都是可丟可重建的投影。
把一則信封攤開來看
上面三種風格的差別,落到程式裡就是「信封裡放哪幾個欄位」。下面這台拆解器把一則真的 OrderPaid 攤成逐欄台帳:每一欄屬於哪一類、佔幾個 byte、少了它下游會怎樣。信封與舊契約都可以直接編輯,最下面那張卡會即時算出這次欄位改動在四種 Registry 相容性模式下會不會被拒登——相容性模式本身的定義留給本篇談 Schema 演進的那一節,這裡先看「改了欄位會發生什麼」。
三個值得停下來比對的地方:(1) 預設信封 309 bytes,但業務事實只占 31.4%——68.6% 是信封稅,這是 Carried State 的真實重量。(2) 把冪等鍵換成 aggregateId + occurredAt,四種孿生事件的答對數從 4/4 掉到 1/4;換成 payload 內容雜湊是 3/4,而且錯的那一種是「誤擋」——它會把合法的第二筆同金額付款當成重投丟掉。(3) 把台帳上七個新欄位全部點成「選填」,相容性從「只有 FORWARD 成立」變成 FULL,拒登的 Registry 模式從 2 個降到 0 個。bytes 由規範化序列化算出,與你在框裡怎麼縮排無關;「選填」在本模型裡同時代表 optional 與「有預設值」,兩者對 BACKWARD 的效果相同。
OrderUpdated 帶整列 diff,消費者無法判斷「這次是付款、改地址還是客服備註」。下游會開始解析欄位差值,等於把發布者的表結構焊進每一個訂閱者。事件名應該是領域裡發生的事實(OrderPaid、AddressChanged),不是資料庫動詞。
實務上大多數服務間整合走 Carried State:帶消費者做完這一步所需的欄位,不多帶「以後也許用得到」的整張聚合。要稽核或時間旅行再對同一條 log 做 Event Sourcing 投影,而不是要求每個消費者都重放全歷史。Notification 留給「欄位極大、回查很便宜、且消費者本來就要打 API」的內部後台。
Choreography 與 Orchestration
沒有中央指揮時流程長在訂閱關係上;有指揮時流程長在管理員的狀態機上。
一筆訂單要經過付款、扣庫存、開出貨單。用事件串起來有兩種拓撲。補償「失敗了怎麼退」的語義在分散式交易那篇;這裡只問:下一步由誰決定、出了差錯你在哪裡看見整條鏈。
- 付款服務發
OrderPaid;庫存聽了扣量並發StockReserved;出貨再聽。 - 沒有單一端點握著「這筆訂單現在走到哪」。
- 加一個積分服務:新訂閱現有主題即可,不必改舊服務。
- 管理員發
ReserveStock,等StockReserved,再發CreateShipment。 - 流程狀態(進行中、卡住、已完成)集中可查。
- 加一步或改順序:改管理員,參與者保持「接到命令就做事」。
sequenceDiagram
participant P as 付款服務
participant T as 事件主題
participant I as 庫存服務
participant S as 出貨服務
P->>T: OrderPaid
T->>I: 訂閱送達
I->>T: StockReserved
T->>S: 訂閱送達
Note over I,S: 下一步由訂閱者自己決定
Choreography:沒有人握著整條鏈——每個服務只看見自己訂閱的那則事件,下一步長在訂閱關係上,中央的主題只負責轉送、不做決定。
sequenceDiagram
participant M as 流程管理員
participant I as 庫存服務
participant S as 出貨服務
M->>I: ReserveStock
I-->>M: StockReserved
M->>S: CreateShipment
Note over M: 流程狀態集中在這裡
Orchestration:三條訊息都從管理員發出或回到管理員,它既是發起者也是回覆的收件人,所以「現在卡在哪一步」只要查它一處。
三個常被混為一談的成本
可觀測性。 Choreography 的「這筆訂單為什麼卡在出貨前」要沿主題把相關事件撈齊,或另做關聯追蹤。沒有單一行程表。Orchestration 的管理員列就是行程表:卡在哪一步、等哪個回覆、超時幾次,打開就看見。代價是管理員成為查詢熱點,也成為部署時你必須保住的狀態。
變更成本。 在既有鏈上插入「風控檢查」,Choreography 往往要改某個服務的訂閱與發出——你得找到「誰該在付款之後、扣庫存之前」插一腳,訂閱關係變成隱式工作流。Orchestration 改狀態機:舊參與者繼續聽自己的命令即可。反過來,加一個完全旁路的讀模型(搜尋索引、稽核),Choreography 只是多一個訂閱者,Orchestration 卻可能要管理員多發一次通知,否則新服務看不到命令通道裡的流量。
失敗處理。 這裡不重講補償理論。只標工程後果:Choreography 的失敗是「某服務沒發出下一則事件」,超時與補救散落在各消費者;Orchestration 的失敗是「管理員沒等到某則完成事件」,重試、放棄、轉人工可以寫在同一處。兩者都還是 at-least-once:管理員重發命令、或消費者重放事件,下游都必須把效果做成可重入。那是第 5 節的工作,不是選拓撲就能省略的。
ReserveStock 是有人要求你做一件事,允許拒絕。StockReserved 是已經發生的事實,不能改口。Choreography 的通道上主要是後者;Orchestration 的通道上兩者都有。把命令做成「看起來像事件」的 StockReservationRequested 而沒人處理拒絕,除錯時會以為事實已經成立。
選法可以很具體:流程短、旁路讀者多、步驟很少回頭——Choreography。流程長、要給客服一個「現在卡在哪」的畫面、步驟常改——Orchestration。同一系統可以混用:核心履約走管理員,搜尋與通知走旁路訂閱。
雙寫問題與 Transactional Outbox
資料庫與訊息代理不是同一筆交易。Outbox 把「要說出去的話」先寫進同一顆資料庫。
服務剛改成事件驅動時,最常見的實作是兩行:先 UPDATE orders SET status='paid' 並 commit,再 producer.send(OrderPaid)。這叫雙寫(Dual Write)。兩個系統各自有自己的提交,中間沒有共同的 commit 點。崩潰、行程被殺、broker 暫時拒收,都會讓兩邊的事實分開。
- Commit 成功、send 前進程消失:訂單已付款,庫存永遠聽不到。
- API 已回 200,呼叫端以為世界一致,下游卻缺一則事件。
- 補救只能靠對帳或人工重放,不是重試那次 HTTP 能修的。
- send 成功、本地交易 rollback:主題上有
OrderPaid,訂單仍是 pending。 - 下游扣了庫存,來源卻能被使用者再付一次。
- 「至少下游有聽到」不是安慰,是另一種不一致。
兩種順序都解不了,因為問題不在順序,在你要求兩個非原子的提交表現得像一個。分散式交易那篇用 2PC 硬把它們焊在一起;本篇走的是另一條:不把 broker 拉進同一筆交易,只保證「本地列」與「打算發出的訊息」同生共死。
Outbox 表長什麼樣子
同一筆本地交易裡寫兩件事:業務列,以及一列 outbox。交易 commit 之後,業務狀態與「有一則話等著說」要嘛一起留下,要嘛一起消失。發佈器是交易外的另一個程序,負責把留下的話送到主題。
| 欄位 | 用途 | 為什麼需要 |
|---|---|---|
id |
outbox 主鍵,常當事件 ID | 發佈重試與消費去重共用同一把鍵 |
aggregate_type / aggregate_id |
例如 Order / O-1001 | 除錯、分區鍵、按聚合重放 |
event_type |
OrderPaid |
消費者路由;不要用表名當事件名 |
payload |
序列化後的契約(JSON / Avro / Protobuf) | 當時的事實,不是之後回查的列 |
created_at |
寫入時間 | 輪詢排序、延遲監控 |
published_at |
NULL = 待發 | 發佈器的游標;成功後才填 |
最小可用集合。實際還會加 schema_version、headers、以及發佈失敗次數。不要在發佈器裡再回查業務表拼 payload——當時的事實已經在 payload 裡。
INSERT outbox
outbox 多一列 PENDING
重試會再出現一次
分界在 COMMIT:t1–t2 兩筆寫入共用一次提交,t3–t4 的投遞與標記全發生在交易外——所以主題上可能重複,但意圖不會遺失。
誰把 outbox 列變成主題上的紀錄
兩種發佈器,契約相同:只讀 published_at IS NULL 的列,送到 broker,再標已發。
- 輪詢。 背景行程
SELECT … FOR UPDATE SKIP LOCKED一批、送出、更新published_at。實作簡單,延遲等於輪詢間隔,資料庫多一條熱查詢。多實例要用鎖或租約,避免同一列被送兩次——即便送了兩次,也只是把 at-least-once 提前暴露,消費端本來就要去重。 - CDC。 訂閱資料庫的變更日誌,outbox 的 INSERT 出現在 WAL 裡就轉成主題紀錄。延遲通常比輪詢低,也不占那條熱查詢。WAL 格式、解碼器、快照與增量切換,留給 CDC 那篇;此處只需記住:CDC 沒有取消 Outbox 這張表,它只是換一種讀取方式。若直接把整張
orders的 row change 當領域事件,你又回到「CRUD 當事件」的陷阱。
交易 commit 之後,outbox 列就是耐久的意圖。發佈器崩潰、broker 拒收、標 published 之前再崩潰,列都還在,重試即可。主題上仍可能出現同一 id 兩次。Outbox 把「資料庫有、主題沒有」這條裂縫補上;「主題有兩次」交給第 5 節的去重表。
裂縫到底有多寬
上面那張 swimlane 說的是「步驟是什麼」。下面這台模擬器問的是另一個問題:在哪一步斷掉會怎樣。它對每一個故障點各跑一次完整模擬(開交易、提交、崩潰回滾、送出、重啟後補送、消費),再依最終的耐久狀態分類結局,所以你看到的不是一次播放,是整條結局光譜——雙寫的 bug 因此有了「多寬」這個量。
四個值得停下來比對的位置:(1) 預設(先寫庫、broker 接受、天真 handler)——雙寫只有「t2 之後」這一格不一致(裂縫 1/6),窄得剛好能通過測試、卻會在線上咬人;而 Outbox 在「t4 之後」那一格是重複扣量(送出成功、published_at 還沒回填就崩潰,重啟後再送一次,主題上 2 則、qty 掉到 6)。(2) handler 換成去重表:Outbox 那一格變回一致、qty 回到 8,雙寫那一列一個字都沒變——去重表補的是重複,補不了遺失。(3) 順序換成「先發訊息再寫庫」:裂縫變成 2/6,而且失敗的性質從「遺失」變成「幽靈」——兩種順序都解不了,只是裂法不同。(4) broker 改成首次拒收:雙寫的裂縫從 1/6 撐到 5/6,而 Outbox 那一列連重複格都消失了——沒送成功,就沒有東西可以重複。
刪了就失去「這則 id 曾經發過」的證據。發佈器在 send 成功、delete 失敗時會再送一次,消費端若只靠「outbox 還在不在」去重,對不上。保留列並寫 published_at,或把 id 另存已發集合,比當場刪除安全。
消費端:把 at-least-once 收成效果一次
交付語意已經定義過。這裡只談 handler 如何讓同一則事件做兩次,世界仍只變一次。
訊息佇列基礎篇把 at-least-once 講成預設:broker 在 ACK 前可以再投一次,消費者在處理後、ACK 前崩潰也會再收到。本篇不重定義那三個詞。事件驅動落地時,你要設計的是效果:庫存不該被扣兩次、通知不該寄兩封、讀模型不該把同一筆付款加兩次。
冪等鍵與去重表
每一則離開 Outbox 的事件帶一個穩定 id(outbox 主鍵、或業務側的 orderId + eventType + version)。消費者在同一筆本地交易裡做兩件事:
以及真正的副作用(扣庫存、寫出貨單)。衝突代表「這則我做過了」:這是保護成功,不是業務失敗。不要把去重表做成「先查再做」的兩步——兩步中間崩潰,第二個實例會以為沒做過。必須用唯一鍵把「我要做」宣告成插入本身。
- 去重表 + 同一交易寫業務列。
- 把寫入做成 upsert:
SET qty = 8而不是qty = qty - 2,若你帶的是目標值。 - 外部副作用(寄信、扣款 API)要對方也認同一把冪等鍵。
UPDATE inventory SET qty = qty - 2沒有去重。- 先做副作用再寫去重列:中間崩潰會再做一次。
- 用「我記得上一則」的記憶體集合當去重——重啟即失憶。
亂序比重複更安靜
同一聚合的兩則事件不必依發生順序抵達:網路重試、多分割區、或 Choreography 上兩個發布者並發,都會讓 AddressChanged(v2) 先於 AddressChanged(v1) 到。去重表只擋「同一 id」,擋不住「舊版本覆寫新版本」。
- 帶單調版本。 寫入時
WHERE version < incoming;擋下的舊事件 ACK 即可,不要當錯誤重試到爆。 - 不要對事實事件做 last-write-wins 卻不記版本。 牆鐘可比歪,LWW 會讓先發生的改寫後來的。
- 真正的因果(必須先付款再開出貨) 用 Orchestration 等完成事件,或在消費者緩衝並檢查前置條件;不要假設主題全域有序。Kafka 只保證單一 partition 內有序——跨聚合、跨主題沒有這份保證,細節回 Kafka 那篇。
Kafka 的冪等 Producer 消的是寫入路徑重試;串流作業的 EOS 是 checkpoint 加交易性 Sink。兩者都不替你的庫存 UPDATE 簽名。事件驅動裡「效果一次」寫在去重表與可重入 handler,不寫在代理的設定頁。
Schema 演進與相容性
生產者與消費者不會同一秒升級。相容性模式決定誰可以先走、誰必須等。
Carried State 把契約變成跨團隊的 API。欄位一改,舊進程與新進程會在同一條主題上共存數小時到數週。Schema Registry 不做序列化,它做的是攔下不相容的新版本,讓這段共存期有規則可守。
本節這張模式表就是前面那台事件信封拆解器裡「相容性判定」那張卡實作的規則:它逐欄比對新舊契約,把每一項改動歸到下表的某一格,再摺疊成「哪個方向成立」。想邊讀邊試的話,回去改那則信封的欄位,看下面四種模式各自的反應。
四種相容性模式
| 模式 | 保證 | 誰先升級 | 典型允許的變更 |
|---|---|---|---|
| BACKWARD | 新消費者讀得懂舊資料 | 消費者先、生產者後 | 新增帶預設的欄位;刪除欄位(新程式不再讀它) |
| FORWARD | 舊消費者讀得懂新資料 | 生產者先、消費者後 | 新增舊程式會忽略的欄位;不可刪舊程式還在讀的欄位 |
| FULL | 兩個方向都成立 | 任意順序 | 只剩「加可選欄位 / 加帶預設的欄位」這類窄門 |
| NONE | Registry 不擋 | 無人保護 | 任何變更都能登錄;執行期再爆 |
BACKWARD / FORWARD 有時帶 _TRANSITIVE:不只對上一版,對歷史每一版都要成立。多版本長期共存時用它。
一個具體走法。v1 是 { orderId, amount }。v2 加上可選的 currency,預設 TWD:
- 新消費者讀 v1 資料:缺
currency,填預設 → BACKWARD 成立。 - 舊消費者讀 v2 資料:多一個不認識的欄位,若執行期「忽略未知欄位」→ FORWARD 成立。
v3 把 amount 改成 amountCents。這是重新命名,兩個方向都破:舊消費者找不到 amount,新消費者在舊資料裡找不到 amountCents。FULL 會在登錄時拒絕;NONE 會讓一半進程在半夜反序列化失敗。
- 加欄位:給預設或標 optional;消費者必須忽略未知欄位。
- 刪欄位:等所有消費者都不再讀,再從契約拿掉——通常分兩次發佈。
- 改語意(同一欄改單位):當新欄位加,舊欄位留,雙寫一段時間再刪。
- 改名、改型別、把必填改成另一個必填。
- 同一個欄位先表示元再表示分。
- 靠「我們會同時發佈所有服務」——你做不到。
Registry 只能在登錄時比對契約。若舊消費者用嚴格解碼,多一個欄位就丟例外,FORWARD 名存實亡。語言層的 struct 要能略過未知 key;Avro / Protobuf 預設較友善,手寫 JSON 映射最容易踩雷。
Notification 風格幾乎不需要 Registry:契約只有 ID。Event Sourcing 相反,歷史每一版都可能在重放時再被讀一次——相容性必須是 transitive 的,舊事件不能因為你今天改了欄位就變成垃圾。Carried State 落在中間:為「現在這條主題上的活躍消費者」守 FULL 或 BACKWARD,通常就夠用。
面試折衷與邊界
先答事件帶多少、流程誰指揮、訊息怎麼離開交易。補償理論、broker 內部、視窗運算各有自己的篇。
| 面試常問 | 先答這句 | 不要滑去 |
|---|---|---|
| 事件該帶什麼? | Notification 只帶 ID;Carried State 帶當下事實;Sourcing 以序列為帳本 | CRUD 的 Updated 當領域事件,或堅持「一定要帶全量」 |
| Choreography 還是 Orchestration? | 旁路讀者多用前者;要查進度、常改步驟用後者 | 把補償狀態機在這題重講一遍 |
| 為什麼不能先寫庫再發 MQ? | 兩個提交不是原子的;中間崩潰會只剩一邊 | 用「我們重試 send」假裝有共同 commit |
| Outbox 怎麼做? | 同一交易寫業務列與 outbox;發佈器輪詢或 CDC 再投遞 | 展開 WAL 解碼,或重講 2PC |
| at-least-once 怎麼不變雙花? | 穩定事件 id 插入去重表,與副作用同一交易 | 重定義交付語意,或把 broker EOS checkbox 當答案 |
| 事件亂序怎麼辦? | 單調版本拒絕過期寫入;跨主題不要假設全域序 | 用牆鐘 LWW,或把 Kafka 全域有序當前提 |
| Schema 怎麼升級? | BACKWARD / FORWARD / FULL 決定誰先發佈;加可選欄、忽略未知欄 | 改名當小改,或假設所有服務同時上線 |
| 跟 Saga / 2PC 差在哪? | 那些管跨服務決策與補償;本篇管事實如何可靠發出與被安全套用 | 在這題推演 Try / Confirm / Cancel |
下一站:把資料庫變更日誌變成事件流,見 CDC。交付語意回訊息佇列基礎;分段與 ISR 回 Kafka;視窗與水位回串流處理;補償語義回分散式交易。
Notification 把最新列的讀壓力留給發布者。Carried State 把契約演進留給雙方。Choreography 把行程表拆散到各服務。Outbox 用本地交易換「意圖不丟」,把 at-least-once 暴露到主題上。去重表用唯一鍵換效果一次。Schema 模式用拒登換共存期。講得出「誰承擔後果」,比背一個框架的 Outbox starter 更像做過線上整合的人。