題幹與適用場景
你要為多租戶帳戶服務設計事件日誌。每次餘額、權限或設定變化都產生事件,消費者可以從歷史重播狀態;實例故障後不能從頭掃描無限歷史。系統需要支援高吞吐追加、按租戶順序、消費者獨立進度、快照恢復和有限儲存成本。回答還要說明刪除事件、亂序事件和 schema 演進。
本題適合高階系統設計、平台和資料基礎設施面試。Martin Fowler 的 Event Sourcing 文章定義以事件序列保存狀態變化的核心思想;Apache Kafka 設計文件說明分割日誌、消費者位置和 log compaction 如何保留每個鍵的最新值;公開系統設計題也把分割追加日誌、複製、保留、壓縮和恢復列為考察點。這些來源支援主題代表性,但不能證明某家公司固定使用該題或具體頻率。本題歸入 system-design,因為核心是端到端一致性、恢復邊界與容量取捨。
面試官考察點
第一,看能否把「事件歷史」和「目前物化狀態」分開。快照是加速重播的衍生物,不能取代不可變事件或讓快照與事件邊界不一致。
第二,看分割鍵是否同時滿足順序和擴展。按租戶或聚合 ID 分割可以保證單聚合順序,但熱點租戶、跨聚合交易和全域順序都需要明確限制。
第三,看是否理解壓縮語義。鍵級壓縮只保留每個鍵的最新記錄,適合狀態變更流,不等同事件歷史;刪除需要 tombstone 和保留視窗,消費者不能在壓縮前後混用任意 offset 假設。
最後,看恢復和運維:快照校驗、事件版本、檢查點原子性、複製確認、保留成本、消費者落後和 schema 相容都應可觀測。
回答前需要釐清的問題
- 事件是不可變稽核事實,還是可重建的目前狀態變更? 稽核需要保留完整歷史,狀態流才適合鍵級壓縮。
- 順序範圍是什麼? 只保證同一聚合順序,還是要求租戶內或全域順序?不同承諾決定分割與吞吐。
- 消費者需要從任意時間重播嗎? 如果需要,不能只保留壓縮後的最新鍵值;要有長期歸檔或獨立稽核日誌。
- 快照由誰產生和校驗? 生產者、消費者和快照服務職責不同,必須綁定日誌位置和 schema 版本。
- 刪除如何表達? 使用帶版本的 tombstone,還是保留業務刪除事件?兩者的壓縮和合規含義不同。
30 秒回答框架
「我把每個聚合的事件按聚合 ID 分割,日誌只追加,副本以已提交 offset 對外確認。事件包含 event ID、聚合版本、schema 版本和時間,消費者用 offset 檢查點實作至少一次重播並用 event ID 去重。快照記錄聚合狀態、最後事件 offset 和 schema 版本,恢復時先校驗快照,再從下一 offset 重播。鍵級壓縮只用於可重建的目前狀態流,並保留 tombstone 視窗;稽核事件走不可壓縮歸檔。監控包括複製落後、消費者 lag、快照年齡、壓縮進度和重播校驗差異。」
分步深入解答
1. 定義事件和順序邊界
事件至少包含 eventId、aggregateId、aggregateVersion、schemaVersion、payload、產生時間和來源。aggregateVersion 在同一聚合內單調遞增;寫入時用條件提交拒絕過時版本,避免兩個並發寫入者靜默覆蓋。
分割鍵使用 aggregate ID,保證同一聚合進入同一有序日誌。系統不承諾跨分割全域順序;如果產品需要跨聚合原子事實,應把交易結果編碼成一個聚合事件或使用交易外盒,而不是依賴時間戳排序。
2. 追加、複製和確認
領導副本先把事件追加到本地日誌,再複製到足夠副本;只有達到配置的提交條件才向生產者回傳已接受。事件 ID 和生產者序號支援重試去重。磁碟段按大小或時間滾動,索引幫助消費者定位 offset。
確認語義要寫清楚:客戶端拿到確認表示事件已進入可恢復的提交日誌,不代表所有消費者已處理,也不代表物化讀模型已更新。消費者失敗不會回滾已追加事件。
3. 消費者檢查點和冪等
每個消費者組別獨立保存 partition offset。處理一個事件並更新自己的讀模型後,再提交檢查點;崩潰可能導致重複處理,因此 handler 必須按 event ID 或聚合版本冪等。檢查點和物化寫入若需要原子關係,可把二者寫入同一交易儲存,或用 outbox 記錄提交結果。
消費者落後時保留日誌,不能為了清理 lag 直接跳過未處理事件。對可重建讀模型,可以從快照 offset 開始重播;對不可重建副作用,要用補償或人工複核,而不是盲目重播。
4. 快照協議
快照保存聚合 ID、序列化狀態、最後應用的 offset、聚合版本、schema 版本、校驗和和產生時間。產生時讀取一個穩定邊界:先記錄目標 offset,再應用到該 offset,最後以同一邊界寫入快照。恢復只接受校驗通過且 offset 屬於該聚合分割的快照。
恢復順序是載入快照狀態,然後從 snapshotOffset + 1 重播事件。若 schema 版本舊,先執行版本化遷移;遷移失敗必須阻止發佈錯誤狀態。快照是快取,刪除快照不會損壞日誌,只會增加恢復時間。
5. 區分保留、壓縮和歸檔
時間保留刪除舊日誌,適合有明確重播視窗的事件。鍵級壓縮在同一分割保留每個 key 的最新值,可讓狀態消費者從較短日誌重建目前狀態。它不能滿足稽核或任意時間點回放,因為中間事件可能已被清掉。
刪除使用 tombstone 表示 key 已刪除;tombstone 必須保留到所有符合契約的消費者都能看見它,之後才可被壓縮清除。稽核事件寫入不可變歸檔,設定存取控制、加密和保留期限,不能把壓縮日誌當完整稽核證據。
6. Schema、亂序和毒事件
事件 schema 使用向後相容規則:新增可選欄位、保留舊含義,消費者遇到未知欄位可忽略。破壞性變更需要新 schema 版本、雙讀或遷移視窗。消費者記錄 schema 版本和解析錯誤,不應把無法解析的事件靜默提交 offset。
同一聚合亂序通常表示生產者或複製鏈路違反分割順序。可用 aggregateVersion 拒絕過時事件,把缺口放入等待佇列;跨聚合事件只能按業務時間或因果 ID 定義補償,不能用伺服器到達時間假裝順序。
7. 容量、恢復和可觀測性
容量模型包括每秒事件數、平均和尾部 payload 大小、複製因子、保留視窗、快照大小、壓縮收益和消費者重播速度。快照間隔越短恢復越快,但寫入放大和儲存成本越高;壓縮越積極,狀態恢復越便宜,但稽核與歷史查詢能力越弱。
監控生產確認延遲、複製 lag、磁碟段、壓縮 backlog、消費者 lag、快照年齡、重播速率、schema 錯誤、重複率和快照校驗差異。定期從快照和事件抽樣重建,與物化狀態做校驗;發現差異時保留 offset 和事件樣本,便於定位而不是繼續覆蓋。
高品質示範回答
「我把帳戶聚合 ID 作為分割鍵,事件只追加,並用聚合版本拒絕並發的過時寫入。生產者得到確認時,事件已進入滿足複製條件的提交日誌;消費者獨立提交 partition offset,處理後再檢查點,因此崩潰只會導致可去重的重複處理。
快照包含聚合狀態、最後事件 offset、聚合和 schema 版本、校驗和。恢復先驗證快照,再從下一 offset 重播;刪除快照只增加恢復時間。鍵級壓縮只應用於可重建的目前狀態流,刪除用 tombstone 並保留到消費者契約允許的視窗;稽核流保留不可變歸檔,不能用壓縮結果代替。
我會把承諾限制為同一聚合有序,不承諾跨分割全域順序。容量模型納入複製、保留、壓縮和快照寫入放大,監控 lag、快照年齡、壓縮 backlog、schema 錯誤和重播校驗差異,定期用快照加事件重建狀態驗證讀模型。」
常見錯誤
- 把快照當事件源 → 刪除或損壞快照就無法稽核和恢復 → 快照是綁定 offset 的衍生加速層。
- 把壓縮日誌當完整歷史 → 中間事件和任意時間點狀態已遺失 → 稽核流歸檔,狀態流才使用鍵級壓縮。
- 用時間戳排序所有事件 → 時鐘偏差會製造錯誤順序 → 按聚合版本和分割順序定義契約。
- 處理後才決定是否提交 offset → 重複不可避免卻未設計冪等 → 用 event ID/版本去重,明確檢查點與讀模型關係。
- tombstone 立即清除 → 遲到消費者會把已刪除鍵復活 → 保留到消費者契約覆蓋後再壓縮。
- 拒絕一個 schema 版本就跳過並提交 → 日誌與讀模型靜默分叉 → 停住、隔離毒事件並保留錯誤證據。
- 不建恢復容量模型 → 快照、壓縮或重播在故障時成為瓶頸 → 計算 lag、恢復時間和寫入放大。
- 聲稱 exactly-once 解決所有重複 → 外部副作用仍可能重複 → 對外操作使用冪等鍵、交易或補償。
追問及應對
為什麼不直接把目前狀態放資料庫?
如果只需要目前讀,資料庫更簡單。事件日誌的價值是可重播、稽核、多個消費者和從歷史重建不同讀模型;它也帶來 schema、重播、容量和副作用冪等成本。應根據這些需求選擇,而不是預設事件溯源更先進。
壓縮後如何支援新消費者從頭建立狀態?
新消費者只能建立壓縮後仍能表達的目前狀態,不能重建被刪除的中間歷史。若業務需要歷史,保留不可壓縮歸檔或獨立 change log,並從歸檔快照和事件開始。文件要標明每個 topic 的重播能力。
快照產生期間有新事件怎麼辦?
為快照選定穩定目標 offset。之後到達的事件繼續追加,但不寫入該快照;恢復載入快照後從下一 offset 重播。若快照寫入失敗,保留舊快照和日誌,不發佈部分寫入檔案。
如何遷移事件 schema?
先定義相容規則和版本欄位,消費者可同時讀取舊、新版本。寫入端在視窗內發佈新欄位,等所有消費者升級後再停止舊欄位;無法相容的變更使用新事件類型或離線遷移,並用重播測試驗證歷史事件。