問題與範圍
一個 SaaS 平台會產生 invoice.paid、order.shipped、user.disabled 等事件。客戶註冊 HTTPS 端點,並替每個端點訂閱指定事件類型。請設計從業務事件提交成功到客戶回傳 HTTP 回應的出站投遞平台。端點管理、投遞歷史、密鑰輪替與手動重送都在範圍內;客戶確認請求後如何在內部處理,不受平台控制。
採用以下面試假設:
- 產品每天產生 5000 萬個業務事件,每個事件平均符合 4 個端點,因此每天形成 2 億個邏輯投遞。
- 平均負載約為每秒 579 個事件、2315 個新投遞;10 倍尖峰約為每秒 5787 個事件、23148 個新投遞。
- 若重試令嘗試次數增加 10%,尖峰分派路徑應能承載約每秒 25463 次嘗試。
- 正常尖峰下,99% 符合條件的新投遞應在 10 秒內開始第一次嘗試。重試截止時間為 24 小時,投遞歷史可查詢 30 天。
- 假設不可變事件載荷為 1.5 KB、每個投遞中繼資料為 300 位元組、每次嘗試為 250 位元組,且每個投遞平均嘗試 1.1 次,則邏輯儲存量約為每天 190 GB、30 天 5.7 TB;未計索引、副本、壓縮與物件儲存開銷。
這些數值用於容量推導,並非業界基準。計費、任意載荷轉換、入站 Webhook 與客戶應用程式內部設計不在範圍內。核心契約是至少一次投遞:允許重複與亂序,但在承諾的保留與重試界線內,任何靜默遺失都必須能被發現並修復。
面試官在評估什麼
第一個訊號是候選人能否準確區分識別碼與交付保證。一個 eventid 代表已提交的業務事實;一個 deliveryid 代表該事件送往某個端點,並在自動重試與手動重送時維持不變。對 (eventid, endpointid) 建立唯一限制,可防止扇出任務重播時產生第二個邏輯投遞。不過,若工作程序沒有收到回應,它仍無法阻止同一 HTTP 請求兩次抵達客戶端。
第二個訊號是交易邊界。業務資料庫提交後再直接發布訊息會形成雙寫缺口:交易可能已提交,程序卻在發布前當機。交易型 outbox、變更資料擷取串流或等價的持久事件來源可以封閉這個缺口。扇出階段應先具體化投遞狀態再傳送,讓系統能回答哪些端點被選中、哪些嘗試已發生,以及還有哪些工作到期未執行。
第三個訊號是故障隔離。單一緩慢端點不能占滿所有連線;擁有數千個故障目的地的租戶不能耗盡健康租戶的重試預算。僅靠佇列分區無法保證公平,還需要端點層級的並行上限、租戶配額、延遲重試排程,以及斷路或暫停狀態。
第四個訊號是雙向安全。接收方需要對精確載荷位元組與投遞中繼資料驗證 HMAC 簽章;傳送方也必須把客戶可控制的 URL 視為 SSRF 攻擊面,限制協定與目標位址、重新驗證 DNS 結果、約束重新導向並隔離出口網路。優秀答案也會把這些控制與密鑰輪替、防重播、稽核和事故處理連結起來。
回答前要釐清的問題
- 什麼算成功? 本設計把任何
2xx回應視為端點確認。3xx、逾時、連線錯誤、408、429或5xx都不算成功。確認只代表端點接受請求,無法證明客戶後續業務處理成功。 - 承諾哪些事件與載荷版本? 每種事件類型都需要文件化的 schema 與版本策略。重試必須傳送相同的不可變載荷位元組,不能用資料庫目前狀態重建舊事件。
- 需要什麼順序? 預設不保證順序。若客戶需要同一業務物件的順序,可攜帶
objectid與單調遞增的objectversion,或提供依 key 排序的選用能力並接受隊首阻塞。全域有序既無必要,也無法經濟地實作。 - 可以重試與重送多久? 本題中自動重試在 24 小時後停止,紀錄保留 30 天。自動重試視窗結束後的手動重送仍使用原事件與投遞識別碼,但會建立新的嘗試紀錄。
- 端點可以指向任意位址嗎? 產品只接受公網 HTTPS 端點。IPv4 與 IPv6 的私有、迴路、鏈路本地、多播、保留位址及雲端中繼資料位址都必須拒絕。
- 哪些資料可以離開平台? 扇出時要檢查訂閱授權並最小化載荷。若整合方能透過已驗證身分的 API 取得詳細資料,敏感欄位應省略或改用資源參照。
30 秒回答框架
「我會透過 outbox 提交每個業務事件,將它發布到持久事件日誌,再由扇出服務解析目前有效的訂閱。服務先為每個 (eventid, endpointid) 建立一筆投遞紀錄,再把工作送入佇列。工作程序以租約領取嘗試,執行端點與租戶層級的限流,把不可變載荷位元組與穩定投遞 ID、目前嘗試時間戳一起簽章,再透過 HTTPS 傳送。2xx 結束投遞;可重試故障使用帶完整抖動的指數退避,直到 24 小時截止;永久故障立即停止。由於請求送出後的逾時結果不確定,契約只能是至少一次,客戶必須依投遞 ID 去重。我也會加入密鑰輪替、SSRF 安全 URL 驗證、投遞紀錄與重送、端點斷路器,以及檢查 outbox、扇出和佇列缺口的對帳工作。」
逐步深入設計
從事件產生開始。在業務狀態變更所在的本地交易內,同時寫入一筆 outbox 紀錄,包含 eventid、租戶、事件類型、schema 版本、業務物件識別碼與版本、發生時間,以及標準化序列化載荷位元組的參照。outbox relay 將它發布到分區式持久日誌。relay 可能重複發布,因此下游依 eventid 去重。若業務資料跨越多個系統,權威生產者負責事件;Webhook 服務不能透過輪詢可變資料表重新拼裝業務事實。
控制面 API 可以維持精簡明確:
POST /v1/webhook-endpoints
PATCH /v1/webhook-endpoints/{endpoint_id}
POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret
GET /v1/webhook-deliveries?endpoint_id=&status=&cursor=
POST /v1/webhook-deliveries/{delivery_id}/replay建立端點時驗證租戶身分,接收 HTTPS URL 與事件類型篩選條件,驗證目標位址,並只回傳一次簽章密鑰。挑戰投遞可以證明端點擁有權,但挑戰成功不代表未來的 DNS 解析結果都安全。密鑰輪替在有限重疊期內同時保留目前與上一版本,並標記本次簽章使用的版本。URL 更新應產生可稽核的設定版本,不能默默改寫歷史嘗試。
使用四種持久紀錄:
Endpoint(endpoint_id, tenant_id, url, status, event_types,
current_secret_version, previous_secret_version, config_version)
Event(event_id, tenant_id, type, schema_version, object_id,
object_version, occurred_at, payload_ref, payload_hash)
Delivery(delivery_id, event_id, endpoint_id, endpoint_config_version,
status, attempt_count, next_attempt_at, expires_at, lease_version)
Attempt(attempt_id, delivery_id, attempt_number, started_at, finished_at,
http_status, latency_ms, error_class, response_digest)扇出服務讀取事件,查詢該租戶與事件類型下已獲授權且啟用中的訂閱,再分成小批插入投遞紀錄。資料庫對 (eventid, endpointid) 強制唯一。只有投遞資料列落盤後,系統才把 delivery_id 放入佇列。若入列失敗,掃描工作會找出到期但沒有活躍佇列工作的 PENDING 資料列。扇出程序若在中途當機,重播會再次查詢訂閱,唯一限制只允許補齊缺少的資料列。保存訂閱快照或端點設定版本,後續稽核才有可解釋性。
新投遞與重試不能共用一個無上限 FIFO。排程器先把到期資料列放入時間桶,再執行租戶加權公平、端點權杖桶與端點並行上限。緩慢端點達到自己的 in-flight 上限後,健康端點仍可繼續。連續失敗會開啟斷路器並推遲後續嘗試,但投遞仍然可見,也仍可進行受控探測。租戶總配額可以阻止單一租戶用大量故障端點占滿工作程序。重試可以借用閒置容量,但不能讓最老的新投遞違反 SLO。
工作程序以原子方式領取投遞租約,並遞增 leaseversion。它讀取已儲存的載荷位元組,產生本次嘗試時間戳,使用 HMAC-SHA256 對 deliveryid.timestamp.payload_bytes 這類標準字串簽章。送出的必須是被簽章的原始位元組,並在請求標頭攜帶事件類型、投遞 ID、事件發生時間、嘗試時間、schema 版本與簽章版本。接收方對原始位元組進行常數時間比較,檢查時間戳容許範圍,並依穩定投遞 ID 去重。每次重試使用新的嘗試時間與簽章,但投遞 ID 和事件位元組保持不變。
回應策略必須明確。任何 2xx 都把投遞標記為 SUCCEEDED;不能自動跟隨 3xx。408、429、5xx、連線重設和逾時可以重試,並在 429 或 503 時遵守設有上限的 Retry-After;其他大多數 4xx 對目前設定屬於永久故障。TLS 與 DNS 故障可以先重試,但應快速開啟端點斷路器。採用帶完整抖動的指數退避、最大延遲、最大嘗試次數和 24 小時絕對截止時間。請求離開工作程序後的逾時是未知結果:重試可能讓客戶重複處理,因此平台絕不能承諾恰好一次。
手動重送會為同一邏輯 Delivery 新增一筆 Attempt,不會建立新的業務事件。操作人員可以看到原始不可變載荷、當時使用的端點設定、所有回應分類,以及自動重試是否仍在進行。重送前重新驗證授權,新嘗試可以使用端點目前有效的密鑰簽章。若產品承諾歷史驗證也必須逐位元組一致,就需要依明確的密鑰保留策略保存對應歷史密鑰。
客戶提供的 URL 需要專門的出口安全邊界。使用一套經驗證的 URL 解析實作,只允許 HTTPS 與核准連接埠,拒絕 URL 內的憑證,解析所有 A 與 AAAA 紀錄,並封鎖私有、迴路、鏈路本地、保留、多播和中繼資料位址。連線時再次驗證,或固定已驗證的位址,以縮小 DNS rebinding 及檢查時間與使用時間不一致的缺口。關閉重新導向;若必須支援,每一跳都重新執行完整策略,而且不能轉送密鑰。工作程序所在的出口網路不能存取控制面或內部服務,並限制連線時間、總請求時間、回應位元組與解壓縮大小。
容量取決於扇出,而不只是事件數。5000 萬個事件乘以 4 個訂閱,得到每天 2 億個投遞;每個投遞平均 1.1 次嘗試,因此每天有 2.2 億筆嘗試歷史。依題設原始大小計算,50M × 1.5 KB = 75 GB、200M × 300 B = 60 GB、220M × 250 B = 55 GB,合計約每天 190 GB。只為目前到期狀態保留小型線上索引,依時間與租戶雜湊切分歷史分區,把不可變載荷和舊嘗試歸檔到成本較低的儲存空間。監控事件到扇出的延遲、新投遞與重試的最老年齡、第一次嘗試延遲、依端點和狀態碼分類的成功率、重試放大倍數、斷路狀態、租戶限流與重送結果。
最後,對每個持久邊界進行對帳:比較已提交 outbox 與已發布事件 ID、事件與訂閱快照的預期投遞數、到期投遞與排程租約、終態數量與嘗試歷史。故障注入應涵蓋 relay 發布後當機、扇出完成一半後當機、佇列訊息重複、遠端已處理 POST 但本地寫入成功前工作程序被終止、批次同步回傳 429,以及單一租戶的端點全部卡住。驗收依據是持久計數、受控佇列年齡、公平復原和可解釋的重複,不能只看一次示範請求成功。
高品質示範回答
「我會替每個已提交業務事實分配不可變的 eventid,並替每個事件與端點組合分配穩定的 deliveryid。生產者在業務交易中寫入 outbox,relay 發布到持久日誌;扇出服務在入列前依 (eventid, endpointid) 唯一限制具體化投遞。這樣重播能修復缺失工作,不會建立第二個邏輯投遞。
題設尖峰下,扇出每秒產生約 23148 個新投遞,包含重試的分派路徑按約每秒 25463 次嘗試規劃。排程器分離新投遞與重試,執行租戶加權公平、端點並行上限與權杖桶,再由工作程序使用帶版本的租約領取嘗試。單一緩慢客戶因此只能消耗自己的額度。連續失敗會開啟端點斷路器,同時保留受控探測與 24 小時重試截止狀態。
每次嘗試都使用 HMAC-SHA256 對 delivery_id、嘗試時間戳與精確不可變載荷位元組簽章。客戶用常數時間比較驗證簽章,拒絕過期時間戳,並依穩定投遞 ID 去重。2xx 代表成功;408、429、5xx、網路錯誤與逾時採用帶完整抖動的指數退避,其他大多數 4xx 停止。工作程序可能在客戶處理完成後、記錄成功前當機,因此我只承諾至少一次,並明確要求接收方採用冪等處理。
控制面提供端點與訂閱管理、有限重疊的密鑰輪替、投遞紀錄和重送。端點 URL 在隔離出口網路內通過 HTTPS、IP 範圍、DNS rebinding、重新導向、逾時和回應大小控制。我會透過持久邊界對帳,以及重複發布、部分扇出、確認遺失、批次 429 和大量掛起端點的故障注入來驗證。通過標準是第一次嘗試 SLO、沒有無法解釋的投遞缺口、受控的重試放大,以及健康租戶能夠公平復原。」
常見錯誤
- 業務資料提交後才發布 → 兩個操作之間當機會遺失 Webhook 事件 → 使用交易型 outbox 或消費等價的持久變更串流。
- 每次重試都產生新的投遞 ID → 接收方無法對同一邏輯投遞去重 → 保持
delivery_id穩定,另建嘗試紀錄。 - 承諾 HTTP 恰好一次投遞 → 遠端處理完成後回應遺失,傳送方無法得知結果 → 提供至少一次投遞,並要求消費者冪等。
- 重試時重建載荷 → 資料庫目前狀態會改變歷史事件,也會令舊簽章失效 → 保存標準化的不可變事件位元組與 schema 版本。
- 所有端點共用一個 FIFO → 緩慢目標占用連線並延遲健康客戶 → 限制端點資源,並依租戶公平排程。
- 所有非 2xx 都立即重試 → 永久故障浪費容量,同步重試也會放大事故 → 分類錯誤,並採用帶完整抖動與截止時間的指數退避。
- 跟隨客戶 URL 重新導向 → 公網 URL 可以把工作程序導向內部服務 → 關閉重新導向,或在隔離出口內完整重驗每一跳。
- 對解析後的 JSON 簽章 → 重新序列化會改變位元組並導致合法簽章失敗 → 對精確原始 body 及經驗證的 ID、時間戳中繼資料簽章和驗章。
- 把控制台重送視為新事件 → 下游可能用新身分再次套用同一業務事實 → 重送現有投遞,只新增嘗試紀錄。
追問與回答
追問一:平台為什麼不能保證恰好一次投遞?
假設端點已提交內部工作並回傳 200,但連線在工作程序讀到回應前中斷。重試可能重複客戶副作用,不重試則可能遺失一個實際從未抵達的事件。傳送方無法與任意客戶伺服器建立原子交易。穩定投遞 ID、接收方冪等與對帳能讓至少一次變得可管理,但不能把兩個資料庫與一段網路變成一次恰好提交。
追問二:如何避免單一故障端點拖慢所有人?
為每個端點設定較小的 in-flight 上限與權杖桶,再依租戶加權公平排程。逾時後釋放租約並排程延遲重試,不能讓工作程序原地等待。連續失敗會開啟斷路器,只執行受控探測,健康端點則繼續使用工作叢集。同時監控端點與租戶兩級的重試放大;攻擊者即使建立多個端點,仍受租戶總預算限制。
追問三:你會提供什麼順序保證?
預設不保證投遞順序。在載荷中包含 occurredat、objectid 與單調遞增的 object_version,消費者可以捨棄舊更新或擷取最新狀態。若付費能力要求同一物件有序,就依 ordering key 對該訂閱分區,並讓每個 key 同時只有一個活躍序列。較早工作失敗會阻塞該 key 的後續工作,所以產品必須公開延遲與可用性代價,不能宣稱全域有序。
追問四:簽章密鑰輪替期間如何處理?
建立新版本,在有限重疊期內保留上一版本,並透過請求標頭或文件標明可接受版本。重疊期間可以同時攜帶兩個密鑰產生的簽章,也可以讓消費者安全地依序驗證兩者。新嘗試使用目前密鑰,不改變不可變載荷與投遞 ID。稽核操作人員、時間、端點設定版本與最終退役動作。若密鑰已洩漏,可能需要立即退役並向客戶提供可見的重送指引,不能繼續重疊。
追問五:扇出只完成一部分時如何復原?
讓持久事件重新經過扇出。(eventid, endpointid) 唯一限制會讓已完成插入成為空操作,只建立缺少的投遞。對帳工作比較事件保存的訂閱快照或設定版本與具體化資料列。若訂閱語意要求使用事件發生時的設定,就必須保存該快照;修復時使用今天的訂閱,可能把舊事件送給當時無權接收的目的地。
追問六:手動重送應該重設 24 小時截止時間嗎?
自動重試與人工重送是兩份契約。自動工作在原截止時間停止。30 天歷史視窗內的重送要重新驗證授權並檢查端點狀態,只建立一筆新嘗試,且在介面明確標記為人工操作;不能默默重新啟動自動排程。安全或刪除類事件即使紀錄仍可見,也可能依業務策略在更短截止時間後禁止重送。