1. 題目
訂單服務建立訂單時,必須提交訂單狀態並發布 OrderCreated 事件供庫存和通知服務消費。資料庫與訊息代理沒有共同的兩階段提交。請設計一套 Transactional Outbox,要求事件不因程序崩潰而靜默遺失,並能處理重複投遞和中繼積壓。
2. 約束與釐清
- 訂單資料與 Outbox 表位於同一個本地交易資料庫。
- 訊息代理支援至少一次投遞,但不保證全域順序或交易式發送。
- 業務允許最終一致;庫存消費者必須具備冪等性。
- 需要說明單一聚合的順序、跨聚合是否需要順序,以及保留和刪除期限。
3. 核心思路
在同一個資料庫交易中寫入訂單變更和一筆 Outbox 記錄,記錄包含唯一 event_id、聚合鍵、事件類型、序號、載荷、建立時間和發布狀態。交易成功代表業務資料與待發布事件同時存在;交易回滾則兩者都不可見,消除了應用層資料庫寫入與發送之間的雙寫窗口。
獨立訊息中繼輪詢或訂閱 Outbox,發送到代理後再標記已發送。發送確認與標記不在同一交易時,程序崩潰可能讓同一事件再次發送,因此消費者要以 event_id 去重,不能假設「只發送一次」。
4. 參考實作
createOrder(command):
begin transaction
order = insert orders(...)
event = insert outbox(
event_id=uuid(), aggregate_id=order.id,
aggregate_version=order.version, type="OrderCreated",
payload=serialize(order), status="pending"
)
commit
return order.id
relayBatch():
rows = select pending outbox rows
order by aggregate_id, aggregate_version, created_at
for update skip locked limit BATCH_SIZE
for row in rows:
try:
broker.publish(key=row.aggregate_id, id=row.event_id, body=row.payload)
mark_sent(row.event_id) // conditional update
except transient_error:
increment_attempts_and_schedule_retry(row.event_id)
consume(message):
begin transaction
inserted = insert processed_messages(message.id) on conflict do nothing
if inserted:
apply_business_change(message)
commit5. 可靠性與正確性
業務交易成功但中繼在發送前崩潰時,待處理記錄仍會被下一次掃描發現。中繼發送成功後、狀態更新前崩潰時,下一輪會重發;因此端到端語意是至少一次,消費者去重表或業務冪等鍵必須與業務變更放在同一個消費者交易中。
同一聚合的順序可由單調版本號和按聚合鍵分割區保證;不要承諾跨聚合全域順序。SELECT ... FOR UPDATE SKIP LOCKED 或租約欄位可避免多個中繼重複領取,但不能取代消費者冪等。Outbox 表需要索引(狀態、重試時間、建立時間),並透過歸檔或安全刪除控制成長。
6. 追問與陷阱
- 把「發布成功」後立即刪除記錄會在確認遺失時造成不可復原缺口;應先持久化發送狀態或保留可稽核記錄。
- 代理確認逾時不等於代理沒有收到訊息,重試必須接受重複事件。
- 只在應用程式內先寫資料庫、再呼叫代理仍有雙寫競態,不能靠 try/catch 取得原子性。
- 如果 Outbox 與業務表不在同一資料庫交易邊界,需要改用 CDC、交易訊息或重新定義一致性保證。
7. 延伸閱讀
可以比較輪詢發布器與 CDC 中繼:輪詢部署簡單但會產生掃描和延遲,CDC 延遲較低卻增加日誌擷取與維運依賴。也應討論毒性訊息、指數退避、死信佇列、監控 pending age,以及消費者 schema 相容策略。
8. 面試評分點
能準確定位雙寫窗口
應指出資料庫提交與代理發送無法由普通本地交易同時提交,並把訂單變更和 Outbox 記錄放進同一交易。
能解釋至少一次與冪等
應描述中繼崩潰導致重複發送的具體時序,並讓消費者以事件 ID 去重且與業務更新共用交易。
能處理順序和並發
應區分單一聚合順序與全域順序,說明分割區鍵、版本號、鎖或租約如何限制並發領取。
能涵蓋營運邊界
應提出重試退避、死信、積壓告警、歸檔清理和 schema 演進,而不是只給出一張表結構。