題幹與適用場景
訂單服務收到一個命令後,需要完成兩件事:
- 把訂單寫入 PostgreSQL;
- 向訊息佇列發布
OrderCreated事件。
業務約束不能只是「兩個呼叫都試一下」。資料庫交易回滾時,不能發出一筆描述不存在訂單的 事件;交易提交後,即使程序馬上崩潰,發布意圖也必須保留下來,並最終到達訊息佇列。同一訂單 的事件需要有序,不同訂單之間不要求全域順序。中繼和消費者可能在任意位置崩潰,訊息佇列也 可能重新投遞,而且系統不能使用分散式兩階段提交。
這就是交易型寄件匣問題。只要一次請求既修改交易資料,又要可靠觸發另一個系統,就會遇到 同類場景,例如預留庫存、計費、更新搜尋索引、寄送郵件、呼叫 Webhook 或記錄分析事件。目標 不是憑空獲得「恰好一次投遞」,而是找出現有的原子邊界,把跨邊界意圖可靠保存,並讓重試安全。
面試官考察點
第一項是能否準確分析失敗窗口。「先寫資料庫,再發訊息」會在資料庫提交後、訊息發送前的崩潰 中遺失事件;「先發訊息,再提交資料庫」會讓消費者看到一筆後來回滾的訂單;記憶體中的提交後 回呼也會隨程序一起消失。高品質回答會先證明這些方案為何失敗,再引出設計。
第二項是能否說清保證邊界。業務列和寄件匣列可以在同一個本地資料庫交易中原子提交,訊息 發布則在之後非同步進行。這樣能保證每個已提交變更都有持久的事件意圖,但沒有把資料庫與訊息 佇列變成一個交易,也沒有承諾恰好一次投遞。
第三項是端到端的重試安全。如果訊息佇列已接收事件,中繼卻在記錄成功前崩潰,恢復後會再次 發布。因此,中繼提供的是至少一次發布,消費者必須讓業務效果冪等。還要區分消費者的本地 資料庫效果與扣款等外部副作用,兩者的原子邊界不同。
最後是順序與可維運性:依聚合分配序號、選擇分割區鍵、中繼併發領取、毒訊息、重試、清理、 重播保留期、延遲指標和故障注入測試。只說出模式名稱還不夠。
回答前需要釐清的問題
- 到底需要什麼保證? 至少一次發布加業務效果只執行一次是否足夠,還是必須在回應呼叫方前
同步確認下游已收到?
- 哪個變更與哪些事件綁定? 一次訂單變更可能產生一個事件,也可能在一個交易裡產生多個
事件,後者需要連續的訂單內序號。
- 順序範圍是什麼? 本題假設只要求同一訂單內有序,不要求所有訂單形成一個全域總序。
- 訊息佇列能保證什麼? 需要確認確認機制、重新投遞、分割區順序、保留期和生產者冪等;這些
能力本身都無法消除資料庫到訊息佇列的交接間隙。
- 事件最晚多久可見? 延遲目標會影響輪詢間隔、資料庫負載,以及是否值得引入變更資料擷取。
- 消費者具體做什麼? 本地資料庫更新可以和收件記錄共用一個交易;外部扣款或寄信需要
下游冪等鍵,或者再增加一次可靠交接。
- 需要支援多久的重播? 寄件匣與消費去重記錄的清理策略必須涵蓋要求的重試和重播窗口。
- 兩種資源是否能參加兩階段提交? 本題明確不能。真實系統若必須同步跨資源原子提交,且
兩邊確實支援,仍應評估它的可用性和耦合成本,不能籠統斷言永遠不可用。
30 秒回答框架
「我會在同一個 PostgreSQL 交易裡寫訂單和一筆不可變的寄件匣事件。獨立中繼讀取已提交記錄, 先領取、再發布,只有收到訊息佇列確認後才標記為已發布。中繼在發布前崩潰,記錄仍待處理; 訊息佇列接收後、中繼標記前崩潰,則會重複發布,所以保證是至少一次。每個事件使用穩定 ID, 消費者把該 ID 的去重記錄和業務更新放進同一個交易。順序方面,我會分配訂單內單調序號,以 訂單 ID 作為分割區鍵,並阻止後續事件越過尚未發布的前序事件。最後監控最舊待發布事件的年齡, 並在提交、發布、確認和消費的每個邊界注入崩潰來驗證不變量。」
分步深入解答
先證明直接呼叫為何不成立。在資料庫優先的流程中,資料庫可以在 T1 提交,程序卻在訊息 佇列於 T2 接收前退出,於是訂單存在而事件不存在。重試 HTTP 請求也不是完整修復:客戶端 可能不重試,而且命令本身若不冪等,重試還會建立重複訂單。訊息佇列優先則會讓消費者先看到 事件,隨後訂單交易卻失敗。交換呼叫順序只是交換不一致的方向。
接著把事件意圖放進服務真正擁有的原子邊界。在一個 PostgreSQL 交易中驗證命令、修改訂單、 分配該訂單的下一個序號,並插入不可變寄件匣記錄。兩列一起提交或一起回滾。一個代表性資料表 結構如下:
CREATE TABLE outbox_events (
event_id uuid PRIMARY KEY,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
aggregate_sequence bigint NOT NULL,
event_type text NOT NULL,
schema_version integer NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
available_at timestamptz NOT NULL DEFAULT now(),
claimed_by text,
claim_until timestamptz,
published_at timestamptz,
attempt_count integer NOT NULL DEFAULT 0,
last_error text,
UNIQUE (aggregate_type, aggregate_id, aggregate_sequence)
);
CREATE INDEX outbox_dispatch_idx
ON outbox_events (available_at, occurred_at)
WHERE published_at IS NULL;eventid 在每次重試中保持不變,schemaversion 明確酬載演進規則,聚合序號唯一約束防止兩筆 事件占用同一個邏輯位置。序號必須在聚合記錄相同的交易與鎖定規則下分配,時間戳或中繼處理 順序不能取代它。若一個交易產生多筆事件,就依預期順序分配連續序號。
輪詢中繼應在一個短交易裡領取小批量記錄:用 FOR UPDATE SKIP LOCKED 選中記錄,更新 claimedby 與 claimuntil,然後提交。列鎖釋放後,已持久化的租約可以阻止其他工作程序主動 處理同一列。中繼在長資料庫鎖之外發布訊息,收到訊息佇列確認後再把記錄標記為已發布。工作 程序崩潰後,租約到期即可恢復。退避與 available_at 可以避免下游故障時形成緊密重試迴圈。 讓資料庫交易跨越網路發布既會增加競爭,也不會讓訊息佇列參與同一個原子提交。
確認間隙無法消除。訊息佇列可能已持久接收事件 E,中繼卻在寫入 published_at 前崩潰; 恢復後 E 會再次發布。如果提前標記,又會產生永久遺失事件的間隙。因此中繼應選擇安全的一側: 允許重複,不能遺失,並要求消費者去重。
如果消費者的業務效果寫入資料庫,可把已處理事件 ID 與業務效果放進同一個交易:
CREATE TABLE processed_events (
consumer_name text NOT NULL,
event_id uuid NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);消費者開始交易,對 (consumername, eventid) 執行 INSERT ... ON CONFLICT DO NOTHING RETURNING,只有插入傳回記錄時才執行業務更新,然後提交。 沒有傳回記錄表示該事件已經生效,重複訊息可以直接確認而不再執行更新。若去重記錄和業務效果 分屬兩個交易,就重新製造了雙寫問題。去重記錄的保留時間至少要涵蓋舊事件仍可能重播的時間。
本地收件資料表不能原子涵蓋非交易型外部副作用。呼叫支付 API 時,應把 event_id 作為服務商 的冪等鍵。下游若不支援冪等,就需要另一個持久命令或寄件匣加對帳,或者明確接受重複風險。 寄信、Webhook 等不可逆呼叫也有相同邊界。
順序要貼合業務範圍。為每個訂單分配單調序號,不允許序號 k + 1 越過仍未發布的 k,並以 aggregate_id 作為訊息佇列分割區鍵。領取查詢可以只選擇每個聚合尚未發布的最小序號,也可以 依 aggregate_id 的穩定雜湊劃分中繼所有權;兩種做法都必須讓每個聚合只有一條有序發布通道。 消費者發現序號缺口時,可依業務選擇拒絕、緩衝或對帳。強行建立全域序號會把無關訂單序列化, 降低可用性,卻無助於訂單內不變量。
輪詢中繼最簡單、可移植,所有權在應用資料庫中也清晰,但輪詢間隔需要在延遲和查詢負載之間 權衡。資料量增長後,需要待處理索引、小批次、租約和有界清理。變更資料擷取可以追蹤資料庫 日誌並轉發新增的寄件匣記錄,通常能降低輪詢壓力與延遲,但會把連接器位移、資料庫日誌保留、 部署和恢復納入維運邊界。直接擷取任意業務資料表還會暴露儲存層變更,而不是明確的領域事件; 顯式寄件匣能保持契約穩定。
維運是設計的一部分。至少監控待發布數量、最舊待發布記錄年齡、發布吞吐與失敗、嘗試次數、 過期領取、訊息確認延遲、消費者去重數量、隔離事件和資料表增長。只有跨過重播與稽核窗口後, 才分批封存或刪除已發布記錄。毒訊息需要明確選擇重試、隔離還是修復;跳過它可能破壞訂單內 順序,所以同一聚合的後續事件不能無聲繼續。
驗證必須針對邊界,而不只是成功路徑。分別在資料庫提交前、提交後回應前、中繼領取時、發布 前、訊息佇列接收後但寫 published_at 前、消費者業務提交後但確認前,以及清理過程中注入 故障。測試要建立四個不變量:
- 每個已提交業務變更恰好有一個持久寄件匣意圖;
- 每個已回滾變更沒有寄件匣意圖;
- 每個持久意圖在系統恢復後最終至少發布一次;
- 無論重複投遞多少次,消費者可見的業務效果只套用一次。
還應暫停中繼製造積壓,重新啟動後驗證延遲恢復、訂單內順序、資料庫負載邊界和警示;同時涵蓋 毒訊息、酬載版本升級、舊事件重播及保留期邊緣的清理。
高品質示範回答
「資料庫與訊息佇列不能原子提交,所以我會先把事件意圖納入資料庫交易。訂單列和不可變寄件匣 列一起提交。交易回滾時兩者都不存在;交易提交後程序立刻崩潰,其他程序仍能看到寄件匣記錄。
中繼使用短資料庫交易和會到期的租約領取待處理記錄,在網路發布期間不持有資料庫鎖,收到 訊息佇列確認後才寫入 published_at。訊息佇列接收後、狀態更新前仍有崩潰窗口,所以中繼可能 重複發布。這個取捨是有意的:重複可以用冪等恢復,事件遺失卻無法自動恢復。
每筆事件使用穩定 UUID。資料庫消費者在同一個交易中,把 UUID 寫入依消費者命名的去重表, 並完成業務更新;重複事件觸發唯一鍵衝突,成為無操作。如果消費者呼叫支付或郵件服務,還要 把事件 UUID 作為下游冪等鍵或增加另一次可靠交接,因為本地去重交易無法包含遠端副作用。
順序方面,我在訂單交易中分配序號,以訂單 ID 作為分割區鍵,並阻止後續序號越過尚未發布的 前序事件,不強求全域順序。若延遲和負載目標沒有證明需要 CDC,我會先用輪詢,然後監控最舊 待發布年齡、重試、過期租約、重複率、毒訊息和資料表增長。
最後,我會在每個邊界終止程序。要求的結果是:回滾不產生意圖,提交一定留下意圖,系統恢復 後每個意圖至少發布一次,重複投遞只改變一次消費者狀態。寄件匣解決可靠交接;請求冪等、消費 冪等、結構演進和對帳仍是系統的明確組成部分。」
常見錯誤
- 依序呼叫資料庫與訊息佇列 → 任一呼叫都可能單獨成功 → **把業務變更和事件意圖放入一個
本地資料庫交易。**
- 提交後呼叫記憶體發布器 → 崩潰會連同回呼狀態一起遺失 → 回應前持久保存發布意圖。
- 聲稱寄件匣保證恰好一次投遞 → 訊息已接收但狀態未記錄的間隙會產生重複 → **明確至少一次
發布,並設計業務效果只執行一次。**
- 在訊息佇列確認前標記已發布 → 崩潰可能永久遺失事件 → 確認後才記錄成功,並容忍再次發布。
- 把消費去重與業務效果分開寫 → 消費者重現同一個雙寫間隙 → 兩者放在同一本地交易。
- 認為本地收件資料表能保護遠端扣款 → 遠端效果不能加入本地交易 → **使用下游冪等鍵、可靠
交接與對帳。**
- 用時間戳表示順序 → 時鐘和併發無法分配唯一因果位置 → **交易內分配聚合序號,並以聚合 ID
作為分割區鍵。**
- 多個輪詢器沒有領取或租約 → 工作程序會主動爭搶同一列 → **使用短領取、到期機制、小批量
與待處理索引。**
- 立即刪除已發布與去重記錄 → 延遲重試和重播可能重複舊效果 → 依明確的重播與稽核窗口清理。
- 只測發布成功 → 保證都體現在崩潰窗口 → 在每個持久邊界前後注入故障並斷言不變量。
追問及應對
追問 1:目標是外部 API,而不是訊息佇列怎麼辦?
來源交易仍可寫入一筆寄件匣命令。工作程序呼叫 API 時,以 event_id 作為冪等鍵,並保存回應。 逾時是歧義狀態,遠端服務可能已經完成操作,所以重試必須沿用同一個鍵。若 API 既不支援冪等, 也不能查詢操作狀態,就無法保證業務效果恰好一次;需要增加對帳,或把重複風險寫進業務契約。
追問 2:工作流程跨越多個服務和資料庫怎麼辦?
寄件匣能可靠發布每個服務的本地狀態變化,但不能原子提交整個多服務工作流程。應把流程建模為 Saga,明確正向步驟、冪等、持久狀態與補償操作。每個 Saga 步驟都可使用自己的本地交易加寄件匣。 還要定義補償本身失敗時如何處理,不能把它描述成所有資料庫一起回滾。
追問 3:面試官要求嚴格全域事件順序怎麼辦?
先釐清無關聚合為何需要同一順序,以及願意犧牲多少吞吐或可用性。單一序號產生器或訊息佇列的 單分割區能建立總序,但也會成為序列化與故障瓶頸。多數訂單流程只需要訂單內因果順序,用交易型 聚合序號與聚合分割區鍵成本更低。
追問 4:CDC 連接器停機後如何恢復?
已提交寄件匣列仍是事實來源。需要監控連接器延遲和資料庫日誌保留餘量,可靠保存連接器位移, 並測試從最後確認位移重新啟動。資料庫日誌必須涵蓋故障恢復目標;超出後需要快照或受控回填。 消費去重讓重播一段重疊範圍仍然安全。
追問 5:哪些情況不應使用交易型寄件匣?
事件明確允許盡力而為,例如可丟棄遙測;或者下游能安全輪詢事實來源,且延遲目標允許時,可以 選擇更簡單的設計。若兩種資源確實都支援兩階段提交,且同步原子性是硬要求,可以結合耦合與 可用性成本評估它。事件溯源也是替代方案,但它會改變事實來源模型,不應只為繞過一次交接而引入。