題目與適用情境
orders.events 是一條使用 Avro 編碼的訂單事件串流,Schema 由 Registry 管理。生產服務滾動發布時,新舊 writer 會同時存在;12 個獨立消費方的發布節奏不同,其中最慢的團隊每週只能發布一次。流量平均每秒 8000 筆、尖峰每秒 25000 筆,訊息保留 90 天,消費者可以從任意保留位置重播。
現有 OrderPlaced 事件把訂單金額放在必填欄位 totalcents 中,所有舊消費者都把它解釋為美元分。業務現在要支援多幣別,並希望改成 amountminor 與 currency。請設計從提案、相容性檢查、發布順序、資料驗證到舊版本下線的完整流程,並處理以下故障:
- 新 writer 已上線,但一個舊消費者尚未升級;
- 新消費者從 60 天前開始重播舊訊息;
- Registry 認為結構相容,但欄位含義已經改變;
- 雙串流遷移期間,同一業務事件同時出現在 v1 與 v2;
- 發布後發現新欄位填值錯誤,需要停止或回復。
吞吐、消費者數量、發布週期和保留時間都是面試假設,不是任何平台的公開效能上限。本題聚焦事件契約、演進協定和消費資料正確性,因此歸入 data。Kafka、Avro 與某個 Registry 只是具體情境;高品質答案應先解釋 writer/reader 關係,再說明工具如何執行這份契約。
面試官評估重點
第一,候選人能否把「向後相容」說清楚。相容性必須指明誰讀誰寫:新 reader 能否讀取舊 writer 的資料,與舊 reader 能否讀取新 writer 的資料是兩個方向。只背 BACKWARD、FORWARD 和 FULL 名稱,無法指導發布順序。
第二,候選人能否區分結構相容與語意相容。Registry 可以檢查欄位、型別、預設值和格式規則,卻不知道 total_cents 原本隱含美元。加入一個可為空的 currency 可能通過結構檢查,但讓舊消費者把歐元分繼續當美元,資料仍然錯誤。
第三,候選人能否把滾動發布、獨立消費者和歷史重播放進同一條時間軸。只驗證最新兩個 Schema 版本,會漏掉 90 天歷史和長期未升級的消費者;只做編譯期檢查,也無法證明生產者真的正確填入新欄位。
第四,候選人是否知道破壞性變更需要新的相容邊界。刪除欄位、改變含義、變更主鍵或枚舉行為,通常無法靠一次原地 Schema 更新安全完成。強回答會說明何時保留舊欄位、何時建立 major 事件串流、如何橋接,以及如何避免重複消費。
最後要看發布是否可操作。每一階段都應有進入條件、觀察指標、中止動作和可回復狀態。消費者清單、Schema 版本、訊息填值率、反序列化錯誤、隔離量、消費 lag 與新舊結果差異都應可查詢,而不是靠群組詢問「大家升級了嗎」。
回答前需要釐清的問題
- 使用什麼編碼格式和 Registry? Avro、Protobuf 與 JSON Schema 的相容細節不同。本文用 Avro 推導主流程,再指出 Protobuf 欄位編號等格式專屬限制。
- 生產者和消費者能否獨立發布? 本題存在滾動 writer 和 12 個獨立消費者,不能假設原子升級。需要知道最慢發布週期、可回復版本和是否存在無人維護的消費方。
- 歷史資料會保留和重播多久? 新 reader 要讀取 90 天內所有 writer Schema,相容檢查和 Schema 保留政策必須涵蓋這段歷史,而不只是最新版本。
- 變更只是結構調整,還是改變業務含義? 欄位改名可以雙寫過渡;美元分擴充為任意幣別會改變舊消費者的解釋,需要新的事件 major 版本或嚴格路由。
- 哪些消費方必須收到多幣別訂單? 如果舊鏈路只服務美元,可以讓 v1 繼續只承載美元,同時把全部訂單寫入 v2;不能把舊消費者無法理解的資料悄悄塞進 v1。
- 是否有穩定的事件 ID? 雙串流、重試和橋接都需要穩定
event_id。消費方切換訂閱時必須依此 ID 去重,不能把兩個 topic offset 當成同一身分。 - 遇到不相容訊息時能否隔離並重播? 隔離區要保存原始位元組、writer Schema ID、來源位置和原因;否則修復 parser 後無法補回資料缺口。
- 誰擁有契約和下線權? 生產方負責不代表可以單方面刪除欄位。需要明確契約負責人、消費方登記方式、例外核准、棄用期限和最終下線證據。
30 秒回答架構
「我會先把每個變更寫成 writer/reader 相容矩陣,並把 90 天重播和最慢消費者納入支援期間。Registry 在 CI 阻止結構不相容 Schema,主題採用涵蓋歷史版本的相容政策;另外用契約樣例、影子消費和欄位品質指標檢查 Registry 看不到的語意變化。
這個金額變更不能在原串流上直接改含義。我會先加入帶預設值的新欄位,讓新消費者先上線並在舊訊息上退回讀取 totalcents,生產者再同時填入新舊表示。多幣別屬於破壞性語意變更,因此建立 orders.v2,用同一 eventid 雙發或橋接,v1 只保留舊語意。消費方逐一切換並核對,完成 90 天重播驗證和棄用閘門後才停止 v1。任何閘門失敗都停留在仍相容的階段;不要用回復程式碼來重新傳送含義不同的事件。」
分步深入解析
第一步:建立消費者清單與不變條件
先登記每種事件的 owner、writer、consumer、Schema 格式、支援版本、最大 lag、重播範圍和發布聯絡人。自動收集消費者群組、Schema ID、反序列化錯誤和最近讀取時間,但不要把「最近沒流量」直接等同於「已經下線」;批次工作和災備工作可能長期靜默。
這次遷移至少有四條不變條件:
- 任一已登記 reader 都不能因收到支援期間內的訊息而反序列化失敗。
- 同一個
event_id在 v1 與 v2 表示同一筆業務事實,橋接或重試不得建立第二筆訂單。 - v1 的
total_cents始終維持「美元分」語意,不能在舊欄位名稱下放入其他幣別的次級單位。 - 新 reader 重播 90 天歷史時,必須能找到當時的 writer Schema,並得到明確的金額解釋。
這些條件比「Schema 註冊成功」更接近業務正確性,也為測試和下線提供可驗證標準。
第二步:用 writer/reader 矩陣判斷相容方向
先寫四格矩陣,不依賴術語猜測:
| Reader | Writer | 必須支援的原因 | |---|---|---| | 舊 reader | 舊 writer | 目前線上基準 | | 新 reader | 舊 writer | 歷史重播與 producer 尚未升級 | | 舊 reader | 新 writer | 滾動發布與慢消費者 | | 新 reader | 新 writer | 目標狀態 |
Avro 讀取時會同時使用 writer Schema 與 reader Schema。writer 多出的欄位可以被 reader 忽略;reader 需要但 writer 缺少的欄位,必須由 reader Schema 提供預設值,否則解析失敗。預設值只在讀取缺少欄位時使用,並不會讓 writer 在編碼時可以省略必填欄位。
因此第一階段可以加入帶預設值的新欄位,但不能原地刪除 total_cents。示意 Schema 只顯示本題相關欄位:
{
"type": "record",
"name": "OrderPlaced",
"fields": [
{ "name": "event_id", "type": "string" },
{ "name": "total_cents", "type": "long" },
{ "name": "amount_minor", "type": ["null", "long"], "default": null },
{ "name": "currency", "type": ["null", "string"], "default": null }
]
}null 排在 union 第一項,與預設值一致。新 reader 讀取舊訊息時得到 null,再執行明確的舊版退回邏輯;新 writer 仍寫入 total_cents,所以舊 reader 繼續運作。這個結構可以支援過渡,卻沒有讓多幣別語意自動變安全。
第三步:把相容閘門放進提案和 CI
每個事件變更先提交機器可讀 Schema、相容方向、語意說明、樣例訊息、消費方影響、遷移步驟、棄用日期和回復點。CI 對目標 subject 執行格式驗證與相容檢查,禁止開發環境以 NONE 註冊後直接進入正式環境。
本題同時存在混合版本和 90 天重播,選擇 FULLTRANSITIVE 作為該 Avro subject 的練習設定:新 reader 要讀取所有保留 writer 版本,支援期間內的舊 reader 也要能讀取新 writer。TRANSITIVE 表示與所有歷史版本比較,而不是只比較最新版本。這個選擇不是通用答案;如果系統嚴格遵循先升級 consumer、再升級 producer,且只要求新 reader 讀取舊資料,BACKWARDTRANSITIVE 可能更合適。
Registry 閘門之外再執行三類契約測試:
- 用每個保留 writer Schema 的真實樣例餵給新 reader;
- 用新 writer 樣例餵給仍受支援的舊 reader;
- 對金額、幣別、事件 ID、枚舉未知值和空值執行業務斷言。
Schema ID、相容設定和產生程式碼版本都固定到發布產物。執行期間禁止 writer 靜默自動註冊一個未經評審的新 Schema。
第四步:先擴充 reader,再讓 writer 雙填值
先發布新 reader。它依以下順序讀取金額:若 amountminor 與 currency 都存在,就使用新表示;若兩者都缺少,就把 totalcents 解釋為 USD;若只出現一個新欄位,或新舊美元金額不一致,就隔離而不是猜測。
新 reader 在影子模式重播樣本和一段正式流量,比較舊邏輯與新邏輯對美元訂單的結果。通過後再發布 writer,讓美元事件同時填入:
event_id = stable business event identity
total_cents = 2599
amount_minor = 2599
currency = "USD"上線閘門應包含新欄位成對填值率、美元金額一致率、Schema ID 分布、舊 reader 錯誤、隔離量和端到端消費 lag。先 canary 單一 writer,再逐步擴大;不要在第一批新欄位剛出現時就刪除舊讀取路徑。
即使 FULL_TRANSITIVE 允許某些發布順序,先 reader 後 writer 仍較容易觀察語意和回復。Schema 相容描述「能解析」,發布順序還要保障「能正確處理」。
第五步:為破壞性語意建立 v2 邊界
多幣別事件不能繼續放進所有舊 reader 都會消費的 v1。舊 reader 不認識 currency,會把 2599 日圓錯誤解釋為 25.99 美元。Registry 無法從位元組結構發現這項錯誤。
建立 orders.v2 及獨立 subject,v2 從第一版就使用明確欄位:
{
"type": "record",
"name": "OrderPlacedV2",
"fields": [
{ "name": "event_id", "type": "string" },
{ "name": "amount_minor", "type": "long" },
{ "name": "currency", "type": "string" },
{ "name": "schema_major", "type": "int" }
]
}遷移期間,所有訂單寫入 v2;只有能維持舊美元語意的訂單同時寫入 v1。可以由交易內 outbox 產生一個規範業務事件,再由發布器投遞兩種表示,或由可重播橋接器從規範日誌產生 v1。不能讓兩個獨立請求各自決定事件內容,否則一次失敗會造成串流間缺口。
消費方以同一個 eventid 核對。單一消費工作不要同時把 v1 與 v2 當成兩筆收入;切換時可先影子讀取 v2、比較結果,再在記錄好的 offset/時間邊界停止 v1、啟用 v2。確實需要重疊讀取時,在狀態儲存中依 eventid 去重,並讓去重保留期涵蓋重疊與重播期間。
第六步:保護歷史 Schema、預設值與重播語意
訊息保留 90 天,Registry 中對應的 writer Schema 至少要在這段期間及災備餘量內保持可取得。刪除 subject、改動舊 Schema 或回收欄位身分會讓原始位元組失去解釋方式。長期封存還要把 writer Schema 或穩定 Schema ID 對應與資料一起保存。
預設值是相容工具,不是業務遷移結果。新 reader 對舊訊息得到 currency = null 後,可以依明確的 v1 契約推導 USD;它不能對來源不明的資料一律猜美元。欄位含義、單位、時區、枚舉未知值政策和空值語意都要進入契約測試。
如果格式是 Protobuf,還要保留欄位編號:刪除欄位後 reserved 其 tag 與名稱,不能把舊 tag 分配給新含義,也不要在原 tag 上任意改變型別。舊二進位資料可能仍在日誌中,重新使用編號會讓歷史資料被錯誤解析。不同格式共享發布流程,但不能共用一張過度簡化的「允許變更」表。
第七步:設定可中止閘門與可觀測性
替每個階段規定進入和退出條件:
- 提案通過:owner、消費者清單、矩陣、樣例和下線計畫齊全。
- 新 reader 就緒:能重播 90 天樣本,舊訊息退回結果正確。
- writer canary:新欄位成對填值,美元雙表示一致,舊消費者沒有新增錯誤。
- v2 影子期:依
event_id的數量、金額、幣別和關鍵業務彙總與真實來源對平。 - 消費者切換:每個 owner 確認版本、offset、lag、結果差異與回復點。
- v1 下線:沒有活躍或計畫內消費者,重播期間已跨過,稽核證據已保存。
主要指標包含每個 Schema ID 訊息量、reader 版本分布、未知 Schema、反序列化失敗、語意驗證失敗、隔離區最舊紀錄、各消費方 lag、v1/v2 事件集合差異、欄位空值率和幣別分布。指標依 producer、consumer、Schema 版本和地區切片,避免全域低錯誤率掩蓋一個消費方完全失敗。
回復動作也要依階段定義。writer canary 失敗時停止新 Schema 寫入,舊欄位仍在所以不需回寫歷史;v2 語意錯誤時暫停 v2 發布並保留規範事件,以便修復後重播。已經送出的錯誤事件不能透過回復二進位檔「撤回」,需要更正事件、新版本重播或下游補償,並保留原始稽核紀錄。
第八步:用版本組合和故障注入驗收
測試矩陣至少涵蓋舊 writer/舊 reader、新 writer/舊 reader、舊 writer/新 reader、新 writer/新 reader 四種組合。對每個保留 Schema 版本做序列化—反序列化測試,並加入缺欄位、未知欄位、未知枚舉、只填一半新欄位、金額不一致和不支援幣別樣例。
整合演練還應涵蓋:writer 滾動發布中途回復;Registry 暫時不可用;consumer 在新版本上線後從 60 天前重播;v1 成功而 v2 發布重試;橋接器重複投遞;消費方在切換 offset 前後當機;隔離紀錄修復後重複重播;錯誤 Schema 被 CI 拒絕。驗收不能只看程序沒有報錯,還要核對事件集合、業務金額、Schema ID、offset 連續性與下游副作用。
發布完成後保留一次完整重播演練。只有新 reader 從最早保留位置讀到即時位置、結果對平且沒有未知 Schema,才能證明歷史相容鏈路成立。
高品質示範回答
「我會先列出 12 個消費者、各自 reader 版本、最大 lag 和 90 天重播要求,再把變更寫成四種 writer/reader 組合。Avro 新 reader 讀取舊資料時依賴 writer Schema 和 reader 預設值;舊 reader 讀取新資料則要求新 writer 繼續提供舊 reader 所需欄位。因為系統會混跑並重播歷史,我會為該 subject 設定涵蓋歷史的相容檢查,並在 CI 之外加入語意契約樣例。
第一階段只做擴充:加入可為空的 amountminor 和 currency,新 reader 先上線。它對舊訊息把 totalcents 明確解釋為美元;對新欄位缺一或美元雙表示不一致的訊息直接隔離。驗證影子消費後,writer canary 同時填入舊欄位與新欄位,監控 Schema ID、填值率、金額差異、反序列化錯誤和 consumer lag。
支援多幣別會改變 totalcents 的隱含含義,因此不能把日圓或歐元事件繼續送給 v1 舊 reader。我會建立 orders.v2 與獨立 subject,從規範業務事件產生 v2;v1 只橋接仍符合美元語意的事件。兩個表示共享穩定 eventid。消費方先影子讀取、依事件集合和金額核對,再在記錄好的 offset 邊界切換,重疊期間依事件 ID 去重。
最後,Registry 與原 writer Schema 至少保留完整重播期間。每個消費方都要證明能從最早保留位置讀到即時位置。v1 下線必須同時滿足沒有活躍消費者、棄用期間結束、v2 核對通過和可回復證據齊全。若 canary 失敗,我停止新寫入並繼續舊契約;若已發布的資料含義錯誤,就送出更正或修復後重播,不能聲稱回復程式碼會自動修復歷史。」
常見錯誤
- 只說「保持向後相容」 → 沒有說明新 reader 讀舊 writer,還是舊 reader 讀新 writer → 先畫四種版本組合,再選擇 Registry 模式和發布順序。
- Registry 檢查通過就直接上線 → 結構相容無法發現美元分變成任意幣別次級單位 → 補充語意契約、樣例訊息、影子消費與業務核對。
- 替新欄位加入預設值後立刻刪除舊欄位 → 舊 reader 仍依賴原欄位,歷史版本也可能沒有適合新 reader 的資訊 → 先擴充和雙填值;破壞性收縮使用新 major 串流或跨過完整支援期間。
- 讓舊 topic 同時承載舊語意和新語意 → 舊消費者可以解析,卻會安靜地產生錯誤金額 → 保持 v1 語意不變,把多幣別放入明確的 v2 契約。
- 雙發時產生兩個事件 ID → 消費者無法證明兩個 topic 中的是同一事實 → 從規範業務事件衍生兩種表示,並保留同一個穩定
event_id。 - 只和最新 Schema 版本比較 → 新 reader 重播 90 天歷史時可能遇到更早的不相容 writer → 在支援期間內做 transitive 檢查,並對所有保留版本執行 reader 測試。
- 把預設值理解為 writer 可以省略必填欄位 → Avro 預設值用於 reader 解析缺少欄位,不會改變 writer 編碼要求 → 分別驗證 writer Schema 與 reader resolution。
- Protobuf 刪除欄位後重複使用 tag → 歷史二進位資料或舊服務會把新含義解析成舊欄位 → 保留被刪除的編號和名稱,新增欄位使用新 tag。
- 靠群組確認消費方已經升級 → 靜默批次工作、災備工作和無人維護 consumer 容易漏掉 → 維護可查詢的消費者清單、版本遙測與下線核准。
- 回復應用程式就當作恢復歷史資料 → 已發布的錯誤事件仍在日誌和下游狀態中 → 暫停發布,保留原始事件,透過更正、重播和核對完成資料修復。
追問與回答
追問一:為什麼本題使用 FULL_TRANSITIVE,不直接使用預設 BACKWARD?
本題要求舊 reader 在滾動期間讀取新 writer,同時要求新 reader 重播所有保留舊資料,因此兩個方向都存在;90 天歷史又要求比較全部支援版本。FULLTRANSITIVE 是這組題設的保守選擇。真實系統如果強制 consumer 先升級、舊 reader 不會看到新 writer,可以採用 BACKWARDTRANSITIVE,但要把發布順序變成強制閘門。
追問二:欄位別名能否直接完成 totalcents 到 amountminor 的改名?
Avro alias 可以協助某些 reader 解析舊名稱,但不能讓已部署的舊 reader 理解新業務含義,也不能解決多幣別語意。別名適合結構改名的一部分,仍需驗證使用的實作、發布方向和歷史 Schema;本題的語意改變需要 v2 邊界。
追問三:雙 topic 如何保證不漏不重?
一個本機交易先提交規範業務事件或 outbox,再由可重播發布器產生 v1 與 v2 表示。每個目標獨立記錄投遞狀態並可重試,所以暫時只成功一個不會遺失另一份。重複投遞由穩定 event_id 和消費方冪等寫入吸收;核對依事件集合比較,不只比較總數。
追問四:Registry 不可用時 producer 應如何處理?
已快取且獲准的 Schema ID 可以在明確的過期和安全政策內繼續使用;未知 Schema 不能繞過 Registry 自動註冊。若無法確認 writer Schema,關鍵事件應限流、排隊或失敗關閉,並監控積壓。恢復後先驗證設定與 Schema ID,再依原順序重播,不能切成無 Schema JSON 當作臨時後門。
追問五:一個消費者永遠無法升級怎麼辦?
先確認它是否仍有業務 owner 和真實需求。若必須長期保留,就把 v1 當成正式相容產品,明確能力、成本和結束條件;橋接器只產生它能正確解釋的事件。不能讓這個消費者無限阻塞 v2,也不能在沒有業務核准時單方面切斷。無人認領且沒有可驗證讀取的消費者進入可稽核的下線流程。
追問六:如何驗證語意變更,而不只是反序列化成功?
使用固定黃金樣例、正式環境影子串流和獨立真實來源核對。依 event_id 比較新舊金額、幣別、訂單數和關鍵下游結果,特別涵蓋零值、最大值、退款、未知幣別和重播。欄位空值率、金額分布和幣別分布用來發現漂移,但最終閘門應是可解釋的不變條件與逐事件差異。