問題與使用場景
接收器要把一個不可信的 HTTP 請求轉換成持久的內部事件,難點就在這兩個狀態的邊界。如果回傳 200 OK 後程序仍可能在儲存事件前崩潰,就會靜默遺失;如果等支付業務全部執行完再回應,慢速依賴又會觸發服務商重試並放大流量。
採用以下面試假設:
- 峰值為每秒 2,000 個請求,原始請求主體平均 10 KiB、最大 1 MiB,因此按平均請求主體計算的峰值入口流量約為 19.5 MiB/s,尚未計入請求標頭、副本和儲存開銷。
- 服務商要求 2 秒內回應,內部目標是確認延遲 p99 不超過 500 ms,以保留餘量。
- 投遞語意是至少一次且不保證順序。服務商可能並行傳送同一邏輯事件、稍後重試,或先送達物件的較新狀態。
- 已接收事件必須在接收器崩潰後仍可復原,並最終進入
PROCESSED或FAILED終態;同一邏輯事件不能重複執行同一業務變更。 - 簽章金鑰需要無停機輪換。原始承載資料加密保留 30 天用於復原和稽核;冪等記錄至少涵蓋服務商文件規定的重新投遞週期。
主要場景包括支付狀態、訂閱生命週期、退款、爭議和帳戶通知。系統要相容多個服務商,同時不能假設它們的請求標頭、簽章演算法、重試身分和時間戳規則完全相同。
面試官考察什麼
第一,面試官要看候選人能否準確界定確認語意。2xx 表示「接收器已持久接收該事件」,不表示郵件、帳本或服務商 API 呼叫已經完成。在持久寫入前回傳成功會產生靜默遺失;對已經接收的重複請求回傳成功則是正確做法,因為服務商可以停止重試。
第二,安全檢查必須按正確順序執行。接收器先限制方法、內容類型、請求標頭和請求主體大小,保留完全一致的原始位元組,再用可信的金鑰版本驗證服務商簽章,使用固定時間比較 MAC;若服務商提供已簽章的新鮮度中繼資料,還要驗證它。驗簽前解析並重新序列化 JSON 可能改變空格或鍵順序,讓合法簽章失效。
第三,需要區分重放防護和重試去重。已簽章時間戳可以拒絕很早以前截獲的請求;穩定的服務商事件 ID 或投遞 ID 可以防止合法重試被執行兩次。一些服務商會在每次重試時產生新的嘗試時間戳和簽章,但保留同一個邏輯事件 ID。兩種機制不能互相取代。
第四,需要給出沒有資料庫與佇列雙寫漏洞的持久處理模型。可以在同一交易中提交收件匣記錄和寄件匣記錄,再由中繼發佈任務;也可以讓工作程序直接租約領取收件匣記錄。唯一鍵必須由儲存層強制保證,不能採用容易被並行重複請求穿透的「先查再寫」。
最後,高品質回答還要涵蓋亂序狀態、外部副作用、金鑰輪換、毒性事件、背壓、可觀測性、對帳,以及每個交易邊界上的崩潰。
回答前的釐清問題
2xx究竟承諾什麼? 本題中,它表示驗簽成功,而且事件或先前接收的重複事件已經持久化;不表示郵件、帳本或服務商 API 呼叫已經結束。- 哪個服務商身分在重試期間保持穩定? 每個配接器都要說明邏輯事件 ID、嘗試時間戳、簽章格式,以及手動重投是否沿用同一 ID。不能只用承載資料雜湊推導身分。
- 服務商是否對原始請求主體和中繼資料簽章? 配接器負責定義規範簽章位元組,HTTP 框架必須在 JSON 中介軟體執行前提供未經修改的請求主體。
- 存在哪些順序資訊? 優先使用權威的物件版本或序號。事件建立時間可以作為證據,但不天然是嚴格順序;如果只有狀態型事件,可以向服務商查詢目前狀態。
- 重新投遞可能持續多久? 冪等記錄保留期和舊金鑰重疊期必須涵蓋服務商文件行為及產品的手動重放政策。本題的 30 天原始事件保留期只是產品假設,並非通用服務商規則。
- 哪些故障應觸發重試? 如果無法確認真實性或持久儲存不可用,就不能確認;事件持久接收後,工作程序故障不應改變 HTTP 回應。
- 哪些資料屬於敏感資料? 原始請求主體要加密、限制存取並在日誌中遮罩,還要定義刪除例外。簽章驗證真實性和完整性,不負責加密內容。
30 秒回答框架
「我會在帶請求主體大小和限流保護的服務商專用 HTTPS 入口保留原始位元組,使用目前或上一版金鑰,對已簽章的 ID、時間戳和承載資料做固定時間驗簽。在一個資料庫交易中,以服務商事件唯一鍵寫入收件匣和寄件匣,提交後才回傳 2xx;已接收的重複請求也回傳 2xx。中繼和工作程序透過租約非同步處理並重試。業務變更和處理標記一起提交,外部副作用則使用寄件匣和穩定冪等鍵。亂序投遞使用物件版本,或查詢服務商權威目前狀態,絕不使用到達順序。我會監控確認延遲、拒絕原因、收件匣積壓、重複和失敗,並測試並行重複、金鑰重疊、過期簽章、亂序事件及每個提交邊界的崩潰。」
分步深入分析
每個服務商使用一個配接器,但共用同一條接收流程。配接器提供允許的事件類型、最大請求主體、請求標頭解析、規範簽章位元組、演算法、可信金鑰版本、新鮮度策略,以及邏輯事件 ID 的擷取方式。金鑰來自代管金鑰儲存,只能在有限時間內快取。請求不能透過不可信請求標頭自行指定一個「可信」驗簽金鑰。
入口順序需要刻意設計:
1. 只接受 HTTPS POST,並按端點和服務商限流。
2. 驗證有界請求標頭;存在 Content-Length 時先檢查它。
3. 串流讀取最多 1 MiB 原始位元組,超過上限立即拒絕。
4. 只解析簽章中繼資料,不先解析 JSON 請求主體。
5. 使用目前和上一版可信金鑰,以固定時間方式驗簽。
6. 按服務商專屬容差檢查已簽章的嘗試時間戳。
7. 解析已驗簽請求主體,驗證事件信封和允許的類型。
8. 以邏輯事件唯一鍵持久接收,隨後再確認。時間戳新鮮度和去重解決的是不同問題。假設攻擊者截獲一條合法簽章請求,嚴格的已簽章時間視窗能阻止視窗之外的重放,但同一請求仍可能在視窗內到達兩次。反過來,事件 ID 記錄可以阻止重複邏輯事件,卻不能證明一個未簽章時間戳是新鮮的。不同服務商還可能在重試時更新已簽章嘗試時間戳,但沿用事件 ID。因此兩項檢查都要保留,具體語意歸配接器所有。
使用收件匣作為事實來源:
WebhookInbox(
inbox_id, provider, endpoint_id, provider_event_id,
event_type, object_id, object_version, provider_created_at,
received_at, raw_payload_ref, payload_hash, matched_secret_version,
status, attempt_count, next_attempt_at, lease_until, last_error
)
WebhookOutbox(outbox_id, inbox_id, topic, created_at, published_at)
UNIQUE(provider, endpoint_id, provider_event_id)在一個資料庫交易中插入收件匣記錄及其寄件匣通知。如果唯一鍵已存在,就讀取既有接收狀態並回傳 2xx,不建立更多任務。這裡必須是原子插入,不能「先查詢再插入」。交易提交後才能確認。如果資料庫不可用或提交結果未知,就回傳可重試的非 2xx;若第一次提交其實成功,後續重複請求會收斂到同一條唯一記錄。
資料庫加寄件匣的設計消除了儲存與入列之間的缺口。中繼反覆發佈未發佈記錄,並在之後標記已發佈。發佈可能發生兩次,因此佇列消費者仍按 inbox_id 去重。更簡單的實作可以省略訊息代理,讓工作程序透過租約領取到期收件匣記錄,例如使用 FOR UPDATE SKIP LOCKED。可以根據吞吐和維運需要選擇,但收件匣始終是持久接收和稽核邊界。
工作程序領取短租約,解析帶版本的事件,並只路由支援的類型。若業務變更位於同一個資料庫,就在一個交易中更新業務資料列、記錄已處理事件,並把收件匣標為 PROCESSED。已處理事件表使用同一穩定服務商鍵,因此工作程序重試會成為空操作。若要呼叫其他服務,則寫入本地寄件匣,並以 inbox_id 作為冪等鍵。任意網路之間無法保證嚴格恰好一次;穩定身分和冪等接收端能讓至少一次執行保持安全。
業務順序不能由到達順序決定。如果事件帶有權威物件版本,就以 incomingversion > storedversion 之類的條件更新,舊事件作為已處理空操作。如果只有「狀態已改變」型通知,就查詢服務商目前資源並讓本地狀態收斂。若事件代表不可重複的增量,就要求序號,有限快取缺口,並對帳補齊缺失版本。單獨時間戳可能相同、存在時鐘偏差,或表示建立時間而非提交順序。
故障要按持久邊界劃分。接收前,簽章無效、已簽章時間戳過期、請求主體過大、金鑰不可用和儲存不可用,都應按政策拒絕或回傳可重試回應。不能僅為偵錯而持久儲存未經驗證的敏感請求主體。接收後,即使佇列或工作程序故障,也可以回傳 2xx;收件匣會積壓,復原後繼續處理。瞬時處理故障使用帶抖動的退避;結構錯誤或重試耗盡進入 FAILED,保留遮罩後的診斷並提供維運可見的復原路徑。
金鑰輪換在一個有界週期內同時信任目前和上一版本,週期依據服務商重新投遞行為決定。記錄比對的版本,但絕不記錄金鑰或完整簽章。先在兩端設定新金鑰,觀察正式環境流量開始比對,再明確停用舊版本。緊急洩漏可能要求立即停用並從服務商重新投遞,因此輪換手冊要區分計畫重疊和事故回應。
在每秒 2,000 個請求、平均 10 KiB 時,入口原始請求主體流量約為 19.5 MiB/s。容量規劃還要計算 TLS 與 HMAC CPU、資料庫交易率、副本、佇列放大和突發持續時間。無狀態入口水平擴展;必要時按服務商和時間切分收件匣索引,同時在其所有權邊界內保持唯一鍵可全域強制;如果原始加密請求主體會讓資料庫資料列過大,則放入物件儲存。
要分別統計接收、重複、簽章無效、過期、超限和不支援事件。監控確認延遲 p50/p95/p99、資料庫提交延遲、最舊未處理收件匣年齡、工作成功率與重試率、FAILED 數量、寄件匣中繼延遲及比對的金鑰版本。警報應以延遲和持久狀態為依據,不能只看佇列深度。對帳任務比較已接收收件匣、處理記錄和寄件匣發佈狀態,並安全地重新投遞缺失工作。
測試要從原始 HTTP 位元組開始。先使用服務商公開的簽章測試向量,再改動一個位元組、空格、ID 或時間戳。涵蓋缺失和重複請求標頭、請求主體大小邊界、時鐘偏差、目前/上一金鑰和舊金鑰停用。並行傳送數百份同一事件,證明只有一條收件匣記錄和一次業務變更。在收件匣提交後但回應前、佇列發佈後但寄件匣標記前、業務提交後但工作確認前分別製造崩潰。按 3、1、2 順序投遞版本,壓滿工作程序再復原,證明系統最終收斂且確認延遲有界。
高品質範例回答
「我把 2xx 定義為持久接收。入口是無狀態的,只有配接器邊界按服務商區分。它接收 HTTPS POST,把請求主體限制為 1 MiB,保留完全一致的原始位元組,再用可信的目前或上一版金鑰驗證服務商規範中的 ID、嘗試時間戳和承載資料。HMAC 使用固定時間比較,之後才解析事件信封並只允許支援的事件類型。
我在一個資料庫交易中,以 UNIQUE(provider, endpointid, providerevent_id) 插入 WebhookInbox 和寄件匣記錄,提交後才確認。並行重複請求會在唯一鍵上衝突,不產生新任務,同樣回傳 2xx。每秒 2,000 個請求、平均 10 KiB 對應約 19.5 MiB/s 原始入口流量,因此入口水平擴展,並按簽章 CPU、資料庫提交、副本和突發儲存來規劃容量,而不只計算請求數。
寄件匣中繼發佈 inboxid,重複發佈是安全的。工作程序租約領取收件匣記錄。能在同一資料庫完成時,業務變更、已處理事件標記和收件匣完成狀態在一個交易中提交。遠端副作用使用另一層寄件匣,並把 inboxid 作為冪等鍵。這樣不會宣稱跨網路恰好一次,同時能保證重試不會重複邏輯效果。
我不使用到達順序。權威物件版本控制更新,否則狀態型 Webhook 會觸發讀取服務商目前狀態;缺失的序列缺口進入對帳。持久接收前,無法驗真的請求或儲存故障不能確認;接收後,工作程序故障由收件匣吸收,HTTP 仍可回傳 2xx。
金鑰輪換採用有界的目前/上一版本重疊,並記錄比對版本。我監控確認延遲、各類拒絕、重複、收件匣年齡、寄件匣延遲、失敗和金鑰版本使用情況。最後使用官方簽章向量、位元組變更、過期時間戳、金鑰重疊、並行重複、亂序版本,以及每個提交邊界的崩潰來測試。通過標準是每個邏輯事件只有一條持久記錄和一次業務變更,不遺失任何已確認事件,並在復原後最終收斂。」
常見錯誤
- 驗簽前解析 JSON → 重新序列化改變被簽章位元組 → 先擷取並驗證完全一致的原始請求主體。
- 持久化前回傳
200→ 崩潰會靜默遺失已確認事件 → 提交收件匣後再回應。 - 先寫資料庫再單次發佈佇列 → 兩次寫入之間崩潰會擱置工作 → 與收件匣一起提交寄件匣,或直接租約領取收件匣。
- 先讀再判斷重複 → 並行請求可以同時通過 → 以原子方式強制服務商事件唯一鍵。
- 只檢查時間戳新鮮度 → 同時到達的重複仍會執行兩次 → 結合新鮮度和穩定 ID 去重。
- 只使用事件 ID → 當記錄不存在或過期時,截獲的合法請求仍可重放 → 還要按服務商約定驗證已簽章的新鮮度。
- 把到達順序當事件順序 → 遲到事件會讓狀態倒退 → 使用版本、單調狀態轉移或權威狀態對帳。
- 在 HTTP 處理器執行遠端副作用 → 延遲會導致重試和未知結果 → 先持久接收,再非同步冪等執行。
- 記錄完整承載資料和簽章 → 可觀測性變成資料與金鑰洩漏 → 加密儲存受限證據,只記錄遮罩後的身分。
追問與回答
追問 1:為什麼尚未處理完成的重複事件也回傳 2xx?
原事件已經持久接收,服務商繼續重試不會增加復原能力,只會製造更多重複流量。既有收件匣記錄仍可由工作程序和對帳任務處理。這個結論的前提是記錄已經持久化,而且狀態不表示接收交易已回滾。
追問 2:收件匣提交後、傳送 2xx 前程序崩潰怎麼辦?
服務商會重試。唯一鍵找到已提交記錄,不建立第二個任務,接收器回傳 2xx。這就是預期的至少一次路徑。如果用戶端收到 2xx,但服務商看到的連線結果不確定,同樣可以收斂。
追問 3:可以把冪等鍵放在 Redis 嗎?
Redis 可以做加速層,但僅靠短期快取比接收承諾更弱。淘汰、容錯移轉或過期都可能讓同一支付事件再次執行。應在要求的業務與重新投遞週期內持久儲存已處理身分;只有遺失該鍵不會破壞承諾時,才可單獨使用 Redis。
追問 4:金鑰儲存故障時怎麼辦?
可以用有明確過期時間的加密記憶體快取儲存已信任版本,並監控其狀態。如果沒有有效快取金鑰,就應關閉式失敗並回傳可重試回應,讓服務商重新投遞。不能接收未簽章工作,也不能自動信任請求提供的金鑰識別碼。
追問 5:如何復原亂序的支付事件流?
優先使用服務商物件版本或單調的領域狀態轉移,拒絕狀態回退。如果事件只說明物件發生變化,就讀取其目前權威狀態。對於帶序號的增量事件,有限快取缺口,主動補齊缺失版本,並在超出復原視窗時警報。不能只按本地接收時間排序。
追問 6:為什麼這仍然不是恰好一次?
接收器可以讓本地變更和處理標記原子提交,卻無法和任意遠端郵件、銀行或服務商系統原子提交。網路故障可能掩蓋遠端是否已經執行。穩定冪等鍵、交易寄件匣、重試和對帳,可以在遠端 API 支援冪等時實現業務效果上的一次,而傳輸語意仍是至少一次。