01

總覽與定位

決定用事件重劃服務邊界之後:一則事件帶多少資訊、流程誰指揮、訊息如何可靠地離開交易。

訊息系統的骨幹已由三篇講清楚:訊息佇列基礎篇定義 Queue 與 Log、以及 at-least-once 是預設;Kafka 架構篇把一則紀錄定址成 topic + partition + offset;串流處理篇在那條可重放日誌上做視窗與水位。本篇不再重講那些定義。這裡要答的是另一個問題:服務之間為什麼改用事件說話,以及說出去之後怎樣才算可靠。

同步 RPC 把「我改完自己的狀態」和「你也改完你的狀態」綁在同一次呼叫。呼叫者承擔對方的延遲、重試與容量;兩邊的資料庫卻從來不在同一筆交易裡。事件驅動把這條線拆開:一個服務先把自己的事實寫下來,再以事件通知其他人。耦合從「你必須現在回應我」變成「你何時跟上這則事實」。換來的是最終一致、可獨立擴充的消費者,以及一條新的失敗面——訊息可能沒離開交易、可能重複抵達、可能帶著舊 Schema 撞上新消費者。

1 · 事件帶多少
Notification · Carried State · Sourcing
只帶 ID、帶足夠狀態、或把事件本身當真相來源。耦合度、查詢成本與重建能力從這裡分岔。
2 · 流程誰指揮
Choreography · Orchestration
各服務互相反應,或由流程管理員發下一動。可觀測性、變更成本與失敗處理的折衷在這條軸上。
3 · 離開交易邊界
Outbox · 冪等 · Schema
雙寫必然裂開;Outbox 讓訊息與列同生共死。消費端用去重把 at-least-once 收成效果一次;欄位演進用相容性模式約束。

與 分散式交易手冊 的界線必須畫清。那篇負責 2PC / TCC / Saga 的理論與補償語義——什麼時候該 Try、什麼時候補償、協調者掛了怎麼辦。本篇假設你已經決定改走事件,只談這之後的工程:訊息如何跟本地交易綁在一起離開、重複與亂序抵達時 handler 怎麼不寫壞下游。補償步驟的設計不在這裡重述。

座標本篇站在「發布事實」這一側,不是另一種共識協定

交付語意的定義回訊息佇列基礎;broker 分段、ISR、Rebalance 回 Kafka;Window 與 Watermark 回串流處理。CDC 作為 Outbox 發佈器的替代讀取路徑,機制細節留給 CDC 那篇。本篇把 CDC 只當成「誰把 outbox 列變成主題上的紀錄」的一種實作。

一句話答法事件驅動用發布事實取代同步 RPC 編排;真正要設計的是事件帶多少、流程誰指揮,以及訊息如何與本地交易同生共死。
02

一則事件該攜帶多少資訊

Notification、Event-Carried State Transfer、Event Sourcing:耦合、回查與重建,三者不可同時最好。

「訂單已付款」可以是三則完全不同形狀的事件。差別不在主題名稱,在消費者要不要再回頭問發布者,以及這則事件能不能獨立重現當時的世界。

Event Notification
只帶識別、細節回查
  • 典型 payload:{ orderId }。
  • 消費者收到後打回訂單服務取完整狀態。
  • 發布者 Schema 幾乎不演進;消費者永遠看到最新列。
Carried State Transfer
帶足夠欄位、可離線處理
  • 典型:{ orderId, sku, qty, amount, paidAt }。
  • 消費者不必回查就能入帳、扣庫存、寫搜尋索引。
  • 發布者與消費者共用一份契約,Schema 演進變成主戰場。
Event Sourcing
事件序列即真相
  • 狀態不是一列現況,是從事件重放得到的摺疊。
  • 任何投影(報表、讀模型、稽核)都能從同一條 log 重建。
  • 代價:事件不可變、版本必須可重放、查「現況」要另做投影。

三個指標一起看

Notification Carried State Event Sourcing
發布者—消費者耦合 最低(只約好 ID) 中(契約即欄位) 高(歷史事件都是 API)
查詢成本 每次回查發布者 消費當下自給 讀模型另建;寫路徑只 append
重建能力 幾乎沒有:當時快照已消失 能重放這則攜帶的狀態,看不到沒帶的欄位 從 origin 重放整條歷史
發布者負載 寫入便宜,讀被消費者打爆 寫時多序列化一點,讀壓力外移 寫只 append;讀走投影

沒有「事件就該帶全量」或「事件就該只帶 ID」的教條。看消費者能不能忍受回查、以及你是否真的要把事件當帳本。

Notification 的隱藏耦合常被低估:消費者回查時,發布者可能已經走到下一狀態。「已付款」通知抵達時訂單已退款,回查看到的是 refunded。你以為在訂閱一個事實,其實在輪詢一個會變的列。Carried State 把「付款那一瞬」凍在 payload 裡,換來的是契約要一起演進。Event Sourcing 再往前一步:連「現況列」都不當真相,任何讀模型都是可丟可重建的投影。

把一則信封攤開來看

上面三種風格的差別,落到程式裡就是「信封裡放哪幾個欄位」。下面這台拆解器把一則真的 OrderPaid 攤成逐欄台帳:每一欄屬於哪一類、佔幾個 byte、少了它下游會怎樣。信封與舊契約都可以直接編輯,最下面那張卡會即時算出這次欄位改動在四種 Registry 相容性模式下會不會被拒登——相容性模式本身的定義留給本篇談 Schema 演進的那一節,這裡先看「改了欄位會發生什麼」。

互動 · 事件信封拆解器 改 JSON、改舊契約、點必填/選填,三張卡即時重算
新舊契約一致、無演進 有改動、兩個方向都不破 破一個方向 兩個方向都破
載入預設信封中。

三個值得停下來比對的地方:(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 的效果相同。

陷阱把 CRUD 列變更直接當領域事件

OrderUpdated 帶整列 diff,消費者無法判斷「這次是付款、改地址還是客服備註」。下游會開始解析欄位差值,等於把發布者的表結構焊進每一個訂閱者。事件名應該是領域裡發生的事實(OrderPaid、AddressChanged),不是資料庫動詞。

實務上大多數服務間整合走 Carried State:帶消費者做完這一步所需的欄位,不多帶「以後也許用得到」的整張聚合。要稽核或時間旅行再對同一條 log 做 Event Sourcing 投影,而不是要求每個消費者都重放全歷史。Notification 留給「欄位極大、回查很便宜、且消費者本來就要打 API」的內部後台。

一句話答法Notification 只帶 ID、靠回查看最新列;Carried State 把當下事實凍進 payload;Event Sourcing 讓事件序列本身成為真相。三者交換的是耦合、回查與重建。
03

Choreography 與 Orchestration

沒有中央指揮時流程長在訂閱關係上;有指揮時流程長在管理員的狀態機上。

一筆訂單要經過付款、扣庫存、開出貨單。用事件串起來有兩種拓撲。補償「失敗了怎麼退」的語義在分散式交易那篇;這裡只問:下一步由誰決定、出了差錯你在哪裡看見整條鏈。

Choreography
各服務聽事件、自己決定下一動
  • 付款服務發 OrderPaid;庫存聽了扣量並發 StockReserved;出貨再聽。
  • 沒有單一端點握著「這筆訂單現在走到哪」。
  • 加一個積分服務:新訂閱現有主題即可,不必改舊服務。
Orchestration
流程管理員發命令、收完成事件
  • 管理員發 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。同一系統可以混用:核心履約走管理員,搜尋與通知走旁路訂閱。

一句話答法Choreography 把下一步寫進各服務的訂閱;Orchestration 把下一步寫進管理員。前者利於加旁路讀者,後者利於查進度與改順序。
04

雙寫問題與 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 裡。

階段
訂單服務
DB(orders + outbox)
發佈器
Broker
T1–T2|同一筆本地交易:兩筆寫入共用一次 COMMIT
t1
UPDATE orders
INSERT outbox
status=paid(未提交)
outbox 多一列 PENDING
—
—
t2
COMMIT
兩筆一起生效;崩潰則一起不存在
—
—
T3–T4|交易外投遞:失敗只會重試,不會讓訂單回頭
t3
已回 200,請求結束
outbox 列仍是 published_at IS NULL
輪詢或 CDC 撈出這列
—
t4
—
回填 published_at
send(OrderPaid)
主題上出現紀錄
重試會再出現一次

分界在 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 當事件」的陷阱。
關鍵Outbox 保證的是「意圖不丟」,不是「效果一次」

交易 commit 之後,outbox 列就是耐久的意圖。發佈器崩潰、broker 拒收、標 published 之前再崩潰,列都還在,重試即可。主題上仍可能出現同一 id 兩次。Outbox 把「資料庫有、主題沒有」這條裂縫補上;「主題有兩次」交給第 5 節的去重表。

裂縫到底有多寬

上面那張 swimlane 說的是「步驟是什麼」。下面這台模擬器問的是另一個問題:在哪一步斷掉會怎樣。它對每一個故障點各跑一次完整模擬(開交易、提交、崩潰回滾、送出、重啟後補送、消費),再依最終的耐久狀態分類結局,所以你看到的不是一次播放,是整條結局光譜——雙寫的 bug 因此有了「多寬」這個量。

互動 · 故障點結局光譜 拖故障點、換寫入順序與 broker 行為,兩列光譜同時重算
一致 整筆未發生(也一致) 不一致:遺失/幽靈 重複扣量
拖動「進程消失點」,兩條泳道的結局同時重算。
—

四個值得停下來比對的位置:(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 那一列連重複格都消失了——沒送成功,就沒有東西可以重複。

陷阱發佈成功後立刻 DELETE outbox 列

刪了就失去「這則 id 曾經發過」的證據。發佈器在 send 成功、delete 失敗時會再送一次,消費端若只靠「outbox 還在不在」去重,對不上。保留列並寫 published_at,或把 id 另存已發集合,比當場刪除安全。

一句話答法雙寫裂在兩個提交中間;Outbox 讓業務列與待發訊息進同一筆本地交易,發佈器再在交易外投遞。CDC 只是發佈器的讀取路徑,不是另一種一致性模型。
05

消費端:把 at-least-once 收成效果一次

交付語意已經定義過。這裡只談 handler 如何讓同一則事件做兩次,世界仍只變一次。

訊息佇列基礎篇把 at-least-once 講成預設:broker 在 ACK 前可以再投一次,消費者在處理後、ACK 前崩潰也會再收到。本篇不重定義那三個詞。事件驅動落地時,你要設計的是效果:庫存不該被扣兩次、通知不該寄兩封、讀模型不該把同一筆付款加兩次。

冪等鍵與去重表

每一則離開 Outbox 的事件帶一個穩定 id(outbox 主鍵、或業務側的 orderId + eventType + version)。消費者在同一筆本地交易裡做兩件事:

INSERT INTO processed (event_id) — 主鍵衝突則整筆 rollback,改 ACK

以及真正的副作用(扣庫存、寫出貨單)。衝突代表「這則我做過了」:這是保護成功,不是業務失敗。不要把去重表做成「先查再做」的兩步——兩步中間崩潰,第二個實例會以為沒做過。必須用唯一鍵把「我要做」宣告成插入本身。

效果可重入
同一 id 做兩次 = 做一次
  • 去重表 + 同一交易寫業務列。
  • 把寫入做成 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 那篇。
陷阱把 broker 的「exactly-once」checkbox 當成 handler 可以不冪等

Kafka 的冪等 Producer 消的是寫入路徑重試;串流作業的 EOS 是 checkpoint 加交易性 Sink。兩者都不替你的庫存 UPDATE 簽名。事件驅動裡「效果一次」寫在去重表與可重入 handler,不寫在代理的設定頁。

一句話答法at-least-once 用去重表的唯一鍵收成效果一次;亂序另用單調版本拒絕過期寫入。兩者都是保護,不是消費失敗。
06

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;消費者必須忽略未知欄位。
  • 刪欄位:等所有消費者都不再讀,再從契約拿掉——通常分兩次發佈。
  • 改語意(同一欄改單位):當新欄位加,舊欄位留,雙寫一段時間再刪。
一次到位的破壞
看起來乾淨,切換是懸崖
  • 改名、改型別、把必填改成另一個必填。
  • 同一個欄位先表示元再表示分。
  • 靠「我們會同時發佈所有服務」——你做不到。
關鍵忽略未知欄位是 FORWARD 的執行期前提

Registry 只能在登錄時比對契約。若舊消費者用嚴格解碼,多一個欄位就丟例外,FORWARD 名存實亡。語言層的 struct 要能略過未知 key;Avro / Protobuf 預設較友善,手寫 JSON 映射最容易踩雷。

Notification 風格幾乎不需要 Registry:契約只有 ID。Event Sourcing 相反,歷史每一版都可能在重放時再被讀一次——相容性必須是 transitive 的,舊事件不能因為你今天改了欄位就變成垃圾。Carried State 落在中間:為「現在這條主題上的活躍消費者」守 FULL 或 BACKWARD,通常就夠用。

一句話答法BACKWARD 讓新消費者讀舊資料,FORWARD 讓舊消費者讀新資料;加可選欄位、忽略未知欄位,是多版本共存的最低紀律。改名等於發新欄、留舊欄,不要原地改語意。
07

面試折衷與邊界

先答事件帶多少、流程誰指揮、訊息怎麼離開交易。補償理論、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 更像做過線上整合的人。

一句話答法事件帶多少決定耦合,拓撲決定誰看見進度,Outbox 決定意圖會不會丟,去重與版本決定重複和亂序會不會寫壞下游。