Webhook 平台 (Design a Webhook Platform)
設計一個 multi-tenant webhook 平台:內部 product services 產生 domain events(invoice.paid、pull_request …),客戶註冊 endpoint 並訂閱想要的 event types,平台把每個事件 以 HTTP POST fan-out 投遞 到所有匹配的 endpoint。本質是一個 可靠的非同步投遞引擎:事件不能丟、接收端可能很慢或暫時掛掉、要在 數天內 努力投遞成功。
招牌取捨:投遞保證選 at-least-once 而非 exactly-once——接受「同一事件可能投遞多次(網路超時但對方其實收到了)」、把冪等責任推給客戶,換取「接收端暫時不可用也絕不丟事件」這個對 webhook 平台而言不可妥協的可靠性,並避開 exactly-once 的分散式協調成本。其餘決勝點:queue 解耦 + 緩衝(ingestion 失敗不波及內部服務)、retry(指數退避 + jitter)→ retryable store + scheduler → DLQ + suspend endpoint 的完整失敗階梯、read-heavy cache(每次事件都要查訂閱)、queue partition + per-endpoint 隔離、以及 HMAC 簽章 + endpoint 所有權證明 的雙向信任。
配速(35 min 預算):~5 min 釐清需求 → ~4 min 容量估算 → ~3 min API + ~4 min 資料模型 → ~6 min 高階架構 → ~9 min deep dive(at-least-once 失敗階梯 / queue 擴展 / 安全)→ ~3 min 擴展瓶頸 → ~1 min 收尾。開場先用一句話複述題目、宣告配速、把細節標記為「待會 deep dive」——宣告配速本身就是高分動作,讓面試官知道你有限時面試的心智模型,也替你爭取「先框架、後細節」的許可。
1. 釐清需求與範圍 (Clarify & Scope ~5 min)
開場後先問清楚邊界,別急著畫架構。Webhook 題的死穴在「投遞語意」——一定要在開頭就把 delivery guarantee 與失敗處理時限 問死。
| 釐清問題 | 為何要問 / 影響到哪個決策 | 本題假設(依來源) |
|---|---|---|
| 投遞保證是 at-least-once 還是 exactly-once? | 決定整個重試 / 去重架構 | at-least-once(招牌取捨) |
| 接收端暫時掛掉時,要嘗試多久? | 決定 retry 階梯與 DLQ 設計 | 數天內 持續嘗試(例 SLA 3 天) |
| 規模多大?多少使用者 / 多少 event types? | 直接驅動估算與 queue / DB 選型 | 1 億使用者、100 種 event types |
| 延遲要求? | 決定是否非同步、queue 深度 | near real-time(近即時,可非同步) |
| event 來源是誰?內部服務還是外部? | 決定 ingestion 入口與信任邊界 | 內部 product services 發佈 domain events |
| 客戶要能管理訂閱(啟用 / 停用 / 改 endpoint)? | 決定 endpoint 管理 API 與資料模型 | 要(activate / deactivate / 選 event types) |
| 要不要驗證 endpoint 真的屬於客戶 / 防偽造? | 決定 auth / 簽章設計 | 要(所有權證明 + payload 簽章) |
「重試耗盡後怎麼辦 我先標記,等 deep dive 再展開失敗階梯——現在先假設 ingestion 把事件丟進一個會幫我重試的 delivery 子系統,這樣我們能先把 ingest → fan-out → deliver 端到端串起來。」把最深的可靠性細節推遲,保住架構敘事連貫。
2. 功能性與非功能性需求 (FR / NFR 與設計北極星)
-
功能性需求 (FR)
- 客戶可 管理 endpoint:啟用 / 停用,並選擇訂閱哪些 event types。
- 內部 services 把 events 發送進 webhook ingestion 平台。
- 平台把 events 轉成 webhook(HTTP request)並投遞 到客戶已註冊的 endpoint。
-
非功能性需求 (NFR)
- 規模:1 億使用者、100 種 event types。
- near real-time 投遞,且提供 at-least-once delivery guarantee。
- 接收端暫時不可用時,仍需在數天內努力完成投遞(不可丟事件)。
- 隱含:read-heavy(每事件查一次訂閱,註冊只寫一次)、高吞吐 fan-out。
設計北極星(NFR 直接導出後續所有選型的判準):
| NFR | 導出的設計北極星 | 後續對映選型 |
|---|---|---|
| at-least-once + 數天重試 | 每次「送出嘗試」都要是 durable 的、失敗要能重放 | queue 當 durable buffer、retry 階梯、DLQ |
| 接收端可能慢 / 掛 | 慢的 receiver 不可阻塞 ingestion;要可隔離 / 熔斷 | ingestion 與 delivery 解耦、per-endpoint partition |
| 1 億用戶 × 100 types、高吞吐 | 訂閱查詢要避開 DB、queue 要可水平 partition | cache、queue partition + sub-shard |
| near real-time + read-heavy | 投遞路徑查訂閱不能每次打 DB | user_id:event_type cache |
| 雙向信任 | 要證明 endpoint 屬於客戶、也要讓客戶驗證 sender | 所有權 challenge、HMAC 簽章 |
「這是一個 絕不丟事件、可重放、慢 receiver 互不影響 的非同步投遞系統」——記住這三個詞,後面每個選型都能從它推回去。
3. 容量估算 (Back-of-Envelope ~4 min)
估算的目的是 驅動 queue / cache / DB 決策。回連 02-Distributed-Systems/05-Numbers-to-Know。
訂閱資料量(DB rows)
1 億 users × 100 event types = 100 億 (10B) 訂閱列
每列 ≈ 1KB
→ 10B × 1KB ≈ 10TB
投遞吞吐(events/sec,採來源算式)
來源假設:每個 (user, event_type) 每天送出 1,000 筆 events
總量/day = 1,000 × 100M(users) × 100(event_type) = 10T events / day
events/sec = 10T ÷ 86,400 ≈ 120M events / sec ← 來源給的數字
下表為 推導值(由「使用者數 × event types × 每對每日量」與「rows × 每列大小」算出)。注意:120M events/sec 是來源用「每對每天 1,000 events」這個偏激進假設推出的上界——面試時應點明這假設極端,真實量級多半小很多;重點是 這個數字逼我們把 queue 做成可 partition、把 DB 前面擋 cache,而非數字本身的精確度。
| 維度 | 數字 | 對後續決策的意義 |
|---|---|---|
| 訂閱列數 | ≈ 100 億 rows(推導值) | 決定 DB 容量;以 (user_id, event_type) 為查詢鍵 |
| 訂閱儲存量 | ≈ 10TB(推導值) | 現代 SSD 單機起步可行,先不 sharding,硬體瓶頸時再分片 |
| 投遞吞吐 | ≈ 120M events/sec(來源數字) | 必須 queue partition + 多 delivery worker 才扛得住 |
| 寫 vs 讀 | 寫一次(註冊)/ 讀極多(每事件查訂閱) | read-heavy → DB 前必須加 cache |
兩個結論:(1) 10TB 訂閱表單機 SSD 可作為起點,硬體到頂才 sharding(別過早分片,對照 05-Database-Advanced/02-Sharding);(2) 投遞吞吐高到單一 queue 撐不住 → 必須 partition,這是後面 queue 設計的根因。
4. 用到的積木 / Building Blocks Used
架構即目錄:先給導覽圖,再展開。下表每個決策都對映一個既有 concept note(無 alias 連結);本表即「本案例練到哪些積木」的單一事實來源(single source of truth)。
| 設計決策 | 積木 | 在本題的作用(≤1 行) |
|---|---|---|
| 容量估算驅動選型 | 02-Distributed-Systems/05-Numbers-to-Know | 算出 10TB / 高吞吐,決定 cache 與 queue partition |
| ingestion 與 delivery 解耦 | 09-Messaging-Coord/02-Queue | queue 當 durable buffer,吸收突發、隔離慢 receiver |
| 高吞吐分流 | 09-Messaging-Coord/03-Kafka | partition event/delivery queue 以水平擴展、保 per-endpoint 序 |
| at-least-once 投遞與重試 | 12-Ops-and-Reliability/03-Reliable-Delivery | 重試、DLQ、可見性逾時等可靠投遞機制的母題 |
| 失敗 / 重試 / 退避 | 01-Networking/07-Fault-Handling | 指數退避 + jitter、超時、熔斷的通用機制 |
| 慢 / 過量 receiver 保護 | 12-Ops-and-Reliability/02-Overload-Protection | per-endpoint throttling / circuit breaking,防 hot endpoint |
| 訂閱查詢加速(read-heavy) | 07-Caching-Storage/01-Caching | user_id:event_type → endpoint 設定放 cache,擋住 DB |
| 訂閱資料儲存 | 06-Database-Tech/01-Database | 持久化 endpoint / 訂閱,10TB 單機起步 |
| 投遞 HTTP 語意 | 01-Networking/03-HTTP-and-HTTPS | webhook = 對客戶 URL 發 HTTPS POST,TLS server auth |
| sender / receiver 雙向信任 | 03-API-Design/05-API-Security | HMAC 簽章 + endpoint 所有權 challenge/response |
| delivery worker 水平擴展 | 02-Distributed-Systems/03-Scalability | worker 無狀態,依 queue 深度 autoscale |
| 管理 API(CRUD endpoint) | 03-API-Design/02-REST | 以 webhook 資源為中心定義註冊 / 更新 / 刪除 |
5. API 設計 (API Design ~3 min)
由 FR(管理 endpoint)驅動,採 REST 資源風格(理據見 03-API-Design/02-REST)。注意:投遞方向(平台 → 客戶 endpoint)不是這裡的 API,這些 API 只管「客戶管理自己的訂閱」。
| Method | Path | 用途 | 重點 |
|---|---|---|---|
POST |
v1/webhook |
註冊 endpoint(body 帶 event_type、endpoint) |
建立訂閱、回 { id };此時做所有權 challenge + 配 secret |
PUT |
v1/webhook/:id |
更新 endpoint(body 帶 endpoint) |
改接收 URL / 設定;改 domain 時可重新 challenge |
DELETE |
v1/webhook/:id |
刪除 endpoint | 連帶 cache invalidation |
「啟用 / 停用」可走 PUT 改一個 status 欄位(active / deactivated / suspended),不必額外開 endpoint。suspended 是系統在重試耗盡後自動設的狀態——這個欄位之後在失敗階梯會用到,先埋伏筆。
6. 資料模型與儲存選型 (Data Model & Storage Choice ~4 min)
核心是訂閱表(endpoint registry),它是投遞路徑每次都要查的地基(故獨立成段)。
WebhookEndpoints (訂閱 / endpoint registry)
--------------------------------------------------
id UUID/BIGINT PK
user_id BIGINT 客戶(租戶)
event_type VARCHAR 訂閱的 domain event(100 種之一)
endpoint_url VARCHAR 接收 webhook 的 HTTPS URL
secret VARCHAR HMAC 簽章用 shared secret(註冊時配發)
status ENUM active / deactivated / suspended
created_at TIMESTAMP
--------------------------------------------------
查詢鍵:(user_id, event_type) ← 投遞時用它找出所有匹配 endpoint
~1KB / row × 10B rows ≈ 10TB
RetryableEvents (重試耗盡前的待重放事件,見 deep dive 8.1)
--------------------------------------------------
event_id UUID 冪等去重 / 追蹤
endpoint_id BIGINT 目標 endpoint
payload BLOB/JSON 原始事件內容
attempt_count INT 已重試次數
next_retry_at TIMESTAMP scheduler 掃描依據
first_failed_at TIMESTAMP 判斷是否超過 SLA(如 3 天)
--------------------------------------------------
儲存選型:
| 選項 | 適配本題嗎 | 理由 |
|---|---|---|
| 關聯式 / KV DB(訂閱表) | ✅ 主選 | 查詢恆為 (user_id, event_type) 點查;10TB 單機 SSD 起步,到頂才 sharding |
| Cache(Redis)擋訂閱查詢 | ✅ 必備 | read-heavy,每事件查一次,cache 才能保護 DB + 降 ingestion latency |
| DB / store 存 retryable events | ✅ 輔助 | 重試耗盡前需 persistent storage 暫存,由 scheduler 掃描重放 |
| Queue(非 DB,但是儲存層) | ✅ 關鍵 | 每個「送出嘗試」要 durable,worker crash 也不丟(見 8.2) |
→ 積木:DB 選型見 06-Database-Tech/01-Database;cache 見 07-Caching-Storage/01-Caching;queue 見 09-Messaging-Coord/02-Queue。
「10TB 單機足夠起步」是 當前規模 的結論。命中硬體瓶頸(容量 / 寫入)時才引入 05-Database-Advanced/02-Sharding,依 user_id 分片即可——詳見第 9 段。
7. 高階架構 (High-Level Design ~6 min)
把前面的積木串成端到端流程。先看兩條核心 flow,再看 scaled 架構。
(A) Endpoint 管理 flow(register / edit / delete)
Client (客戶)
│ POST v1/webhook { event_type, endpoint }
▼
Management API Server (stateless)
│ 1. 對 endpoint 做所有權 challenge/response(POST 隨機 token,驗回傳)
│ 2. 配發 shared secret
│ 3. INSERT WebhookEndpoints (user_id, event_type, endpoint_url, secret, status=active)
▼
DB (訂閱表) ──► 回 { id }
└─ 同步 invalidate / 更新 cache (user_id:event_type)
(B) 投遞 flow(ingest → fan-out → deliver)
內部 product service
│ publish domain event (e.g. invoice.paid)
▼
Message Queue (Event Queue) ← 解耦 + 緩衝:ingestion 掛掉不波及內部服務
│ poll
▼
Webhook Ingestion Service
│ 1. 查 Cache:(user_id, event_type) → 匹配的 endpoints?(miss 才打 DB)
│ 2. 對每個匹配 endpoint,產生一個「送出嘗試」
▼
Delivery Queue (per-endpoint partition) ← durable buffer,含 retries
│ poll
▼
Delivery Workers (stateless, autoscale)
│ 1. 用 secret 對 (timestamp + raw_body) 做 HMAC-SHA256,放 header
│ 2. HTTPS POST 到 endpoint_url(驗 TLS server cert / hostname)
│ ├─ 2xx → ACK,從 queue 移除(visibility timeout 機制)
│ ├─ 5xx / timeout → 指數退避 + jitter 重試(見 8.1)
│ └─ 4xx schema 等 non-retryable → 直接 DLQ
▼
客戶 endpoint(自行驗 HMAC、做冪等去重)
(C) Scaled 架構(端到端)
Management API (stateless, N台) ──► DB(訂閱表) ──► Cache
▲
內部服務 ─► Event Queue ─► Ingestion Service(N台) ─查訂閱─────────┘
(publish) (partition by │ fan-out:每個匹配 endpoint 產生一筆
event_id) ▼
Delivery Queue (partition by endpoint_id)
│ ┌──────────────────────────────┐
├──►│ Delivery Worker pool (autoscale)│──HTTPS POST──► 客戶 endpoint
│ └──────────────┬───────────────┘
│ 失敗(5xx/timeout)│ retry 耗盡
│ ▼
│ RetryableEvents store ◄── Scheduler 週期掃描 → 重放回 queue
│ │ 超過 SLA(3天)
▼ ▼
per-endpoint throttle / DLQ + endpoint.status=suspended + 通知客戶
circuit breaking
關鍵 hand-off:內部服務 → event queue(解耦)→ ingestion(查 cache fan-out)→ delivery queue(per-endpoint partition)→ worker(HMAC + POST);失敗走 退避重試 → retryable store + scheduler → DLQ + suspend 的階梯,與正常路徑解耦。
8. 深入探討 (Deep Dives ~9 min)
通用機制(queue 語意、cache 策略、replication)只在 concept note 解釋;這裡只講「本題怎麼用、選哪個、為什麼」。任一機制超過 ~3 行就砍掉改連結。本題無 home note 的新材料(退避+jitter、HMAC 簽章、所有權 challenge、SQS visibility timeout)才在此 inline 教學並登記到 new_concepts_introduced。
8.1 如何實現 at-least-once 投遞(招牌取捨 + 失敗階梯)
問題:投遞時 endpoint 回錯或連不上,怎麼辦?耗盡重試還是失敗呢?
為何是瓶頸:webhook 平台的全部價值就是「事件最終會到」。接收端的可用性 不在我們掌控 內,所以失敗是常態而非例外,必須把「失敗處理」設計成一級流程。
- 指數退避:重試間隔逐次倍增(100ms → 200ms → 400ms → 800ms …)。目的是「東西壞了就快速退讓」——對方或網路有問題時,立刻 / 等間隔猛重試只會加重負載、引發連鎖故障;指數退避快速降低重試壓力,給對方恢復時間。
- Jitter(隨機抖動):在退避間隔上加隨機量。若上萬個 client 同時失敗、用同一節奏重試,會形成 thundering herd(同步重試風暴);jitter 把重試打散到不同時間點,讓 receiver 承受平滑、可控的負載。
失敗階梯(本題核心設計):
投遞失敗 (5xx / timeout)
│
├─ ① 初始重試:exponential backoff + jitter,最多 N 次(例 5 次)
│ └─ 期間成功 → 完成
│
├─ ② N 次後仍失敗 → 標記 retryable error,寫入 persistent storage
│ └─ Scheduler 週期掃描到期事件 → 重新放回 queue 再試
│
├─ ③ 在 SLA 內(例 3 天)反覆 ②,直到成功
│
└─ ④ 超過 SLA 仍失敗 → 送 DLQ + endpoint.status=suspended + 通知客戶
(另:一開始就 non-retryable 的(schema 錯、缺 secret)→ 直接 DLQ,不重試)
| 階段 | 機制 | 為什麼這樣設計 |
|---|---|---|
| 初始重試(秒~分) | 退避 + jitter,in-queue 重投 | 多數失敗是 瞬時 的(對方重啟、暫時 5xx),快速重試即可救回 |
| 中期重放(分~天) | retryable store + scheduler | 不能讓事件在 queue 裡占位數天;移到 DB 由 scheduler 排程重放 |
| 終局(超 SLA) | DLQ + suspend endpoint + 通知 | 對方長期掛掉,停止無謂重試、保護平台、把問題回饋給客戶 |
| non-retryable | 直接 DLQ | schema / secret 錯重試無意義,省資源 |
選擇與理由:at-least-once 而非 exactly-once。網路超時下我們無法區分「對方沒收到」與「對方收到了但 ACK 丟了」,重試就可能造成重複。要做 exactly-once 需跨平台/客戶的分散式協調,成本極高且仍脆弱。所以 接受重複、把冪等責任交給客戶(事件帶唯一 event_id,客戶用它去重)——這就是本題的 core_tradeoff。
→ 積木:12-Ops-and-Reliability/03-Reliable-Delivery、01-Networking/07-Fault-Handling、09-Messaging-Coord/02-Queue
因為 超時的語意是不確定的:我們發了 POST、沒收到 2xx,對方可能根本沒收到、也可能收到並處理了只是回應丟了。要在這種不確定下保證「恰好一次」,得讓 sender 與 每個客戶 receiver 共同維護去重狀態(分散式交易 / two-phase),跨組織不可控且昂貴。業界標準做法就是 at-least-once + 客戶端冪等:payload 帶 event_id,客戶用它判重。見 12-Ops-and-Reliability/03-Reliable-Delivery。
8.2 如何擴展(queue partition + cache + DB)
問題:吞吐高(來源估到 120M events/sec)、訂閱表 10TB、慢 receiver 不能拖垮全局。
為何是瓶頸:單一 queue、單一 DB、共享 worker pool 都會在這個量級崩潰;且「一個爛 endpoint」可能拖死所有投遞。
consumer 取走訊息後,該訊息進入「不可見」狀態一段時間(visibility timeout),其他 consumer 看不到它;只有 consumer 成功處理並刪除後才真正移除。若 consumer 在逾時內沒刪(crash / 處理失敗),訊息重新變可見、被重投——這正是 queue 層保證 at-least-once 的關鍵。
| 手段 | 做什麼 | 取捨 / 理由 |
|---|---|---|
| DB 前加 cache | user_id:event_type → endpoint 設定;ingestion 先查 cache,miss 才打 DB |
read-heavy(每事件查一次),cache 保護 DB 並降 ingestion latency |
| Event queue partition | partition key = event_id |
平均分散負載、最大化吞吐(事件之間無序依賴) |
| Delivery queue partition | partition key = endpoint_id |
同一 endpoint 的投遞落同一 partition → 可做 per-endpoint throttle / 熔斷 |
| Hot endpoint sub-shard | key = endpoint_id + ":" + hash(event_id) % K |
某 endpoint 爆量時再切 K 份,分散單一 endpoint 的壓力 |
| 獨立 delivery workers | ingestion 與 delivery 解耦,worker 依 queue 深度 autoscale | 慢 / 失敗 receiver 不阻塞 ingestion;worker 可獨立水平擴展 |
| DB sharding(後備) | 命中硬體瓶頸時依 user_id 分片 |
10TB 單機可起步,先不做,到頂才上 |
選擇與理由:兩個 partition key 的差異是精髓——event queue 用 event_id 求最大分散;delivery queue 用 endpoint_id 求隔離(把同一 endpoint 的投遞收攏,才能對它限流 / 熔斷,避免一個慢 endpoint 拖垮共享 worker)。這是「分散」與「隔離」兩種相反目標在不同層的體現。
→ 積木:09-Messaging-Coord/03-Kafka、09-Messaging-Coord/02-Queue、07-Caching-Storage/01-Caching、12-Ops-and-Reliability/02-Overload-Protection
因為投遞階段的敵人是 慢 / 故障的 endpoint。若按 event_id 打散,同一個壞 endpoint 的投遞會散落各 partition,無法對「這個 endpoint」整體限流或熔斷,它的超時會污染所有 worker。按 endpoint_id 收攏後,可對單一 endpoint 做 per-endpoint throttling / circuit breaking,把它的爛影響圈在自己的 partition 裡。代價是熱門 endpoint 會集中 → 再用 hash(event_id)%K sub-shard 化解。見 12-Ops-and-Reliability/02-Overload-Protection。
8.3 驗證與安全(雙向信任)
問題:(1) 怎麼確認 endpoint 真的屬於這個客戶,而不是隨便填別人 URL 來濫用平台?(2) 怎麼讓客戶(receiver)確認收到的 webhook 真的來自我們、沒被偽造 / 竄改?
為何重要:webhook 是「我們主動對外部 URL 發 POST」——既可能被用來打別人(SSRF / 濫用),收到的一方也無從天然信任 sender。
- TLS server authentication(基礎且必須):只允許送
https://,並驗 server 憑證鏈由可信 CA 簽發、憑證 hostname(CN/SAN)與receiver.com相符——確認我們真的在跟該 domain 擁有者通訊。 - Endpoint 所有權 challenge/response(註冊時):註冊時先 POST 一個隨機 token 給 receiver,只有對方正確回傳該 token 才接受該 endpoint;在關鍵變更(domain / IP / 憑證)或定期可重新 challenge。防止客戶把別人的 URL 註冊進來當攻擊放大器。
- Payload signing(HMAC-SHA256):註冊時 sender / receiver 共享一組 secret。每次投遞,sender 用 secret 對
timestamp + raw_body算 HMAC-SHA256 放 header;receiver 用同 secret 重算並做 constant-time comparison(定時比較,防 timing attack)。timestamp 一併簽是為了讓 receiver 能拒絕重放的舊請求。
| 方向 | 機制 | 防的是什麼 |
|---|---|---|
| 我們 → 確認 endpoint 屬實 | TLS server auth + 所有權 challenge/response | URL 偽填、把平台當 SSRF / 濫用放大器 |
| receiver → 確認 sender 屬實 | HMAC-SHA256(timestamp + raw_body) + 定時比較 | 偽造 webhook、竄改 payload、重放攻擊 |
→ 積木:03-API-Design/05-API-Security、01-Networking/03-HTTP-and-HTTPS
9. 擴展與瓶頸 (Scaling & Bottlenecks ~3 min)
主動找出系統會先在哪裂開並補強。
| 瓶頸 / 風險 | 補強手段 | 積木 |
|---|---|---|
| 一個慢 / 故障 endpoint 拖垮共享 worker | 按 endpoint_id partition + per-endpoint throttle / circuit breaking |
12-Ops-and-Reliability/02-Overload-Protection、09-Messaging-Coord/03-Kafka |
| 投遞吞吐超單一 queue | queue partition(event_id 分散 / endpoint_id 隔離)+ hot endpoint sub-shard | 09-Messaging-Coord/03-Kafka |
| 每事件查訂閱壓垮 DB | user_id:event_type cache 擋住絕大多數讀 |
07-Caching-Storage/01-Caching |
| 訂閱表 10TB 命中硬體上限 | 依 user_id sharding(先不做,到頂才上) |
05-Database-Advanced/02-Sharding |
| worker crash 導致事件遺失 | queue durable + visibility timeout:未 ACK 自動重投 | 09-Messaging-Coord/02-Queue、12-Ops-and-Reliability/03-Reliable-Delivery |
| 重試事件長期占用 queue | 達初始上限後移入 retryable store + scheduler 排程重放 | 12-Ops-and-Reliability/03-Reliable-Delivery |
| ingestion 失敗波及內部服務 | 內部服務 → event queue 解耦,ingestion 掛掉不影響內部流程 | 09-Messaging-Coord/02-Queue |
「系統會 先在「慢 endpoint 污染共享 worker」處裂開 → 所以 delivery queue 按 endpoint_id 分流並 per-endpoint 熔斷;其次是讀路徑(每事件查訂閱)→ cache 擋;吞吐到頂 → queue partition + sub-shard。寫只發生在註冊一次,寫不是瓶頸。」
10. 取捨總表與收尾 (Trade-off Recap & Wrap-up ~1 min)
| 決策點 | 選擇 | 放棄了什麼 | 為什麼值得 |
|---|---|---|---|
| 投遞保證 | at-least-once | exactly-once(客戶免去重) | 絕不丟事件 + 避開分散式協調成本(招牌取捨) |
| 失敗處理 | 退避+jitter → retryable store → DLQ+suspend | 簡單「失敗即丟」 | 數天內努力投遞、保護平台、回饋客戶 |
| ingestion/delivery | queue 解耦 + 獨立 worker | 同步直送的簡單 | 慢 receiver 不阻塞、可獨立 autoscale |
| queue 分流 | event_id 分散 / endpoint_id 隔離 | 單一 partition key 的一致 | 一個求吞吐、一個求隔離 + 可熔斷 |
| 訂閱查詢 | cache + 單機 DB(不預先 sharding) | 預先分片的炫技 | read-heavy 靠 cache,10TB 單機起步足夠 |
| 安全 | TLS + 所有權 challenge + HMAC 簽章 | 無驗證的簡單 | 防濫用 + 雙向信任 |
「整題的單一錨點是 at-least-once:我們選擇『寧可重複、絕不丟失』,把冪等推給客戶(事件帶 event_id),換來接收端掛掉也不丟事件——並用 退避重試 → retryable store + scheduler → DLQ + suspend 的階梯把這個保證落實到數天。其餘是這個保證的支撐:queue 解耦與緩衝、delivery 按 endpoint 隔離以熔斷慢 receiver、cache 擋住每事件的訂閱查詢。10TB 訂閱表單機起步,不過早 sharding。」
後續追問演練 (Interviewer Follow-ups — "What if…")
超時語意不確定(沒收到 2xx ≠ 對方沒處理),exactly-once 需跨平台與每個客戶做分散式協調,昂貴且脆弱。選 at-least-once + 客戶端冪等(payload 帶 event_id 去重),保證不丟、系統簡單。見 12-Ops-and-Reliability/03-Reliable-Delivery。
兩個理由:解耦(ingestion 失敗不波及內部服務的正常流程)與 緩衝(event 流量突增不會直接壓垮 ingestion)。見 09-Messaging-Coord/02-Queue。
純指數退避下,上萬個同時失敗的投遞會用同一節奏重試,形成 thundering herd。jitter 把重試打散到不同時間點,讓 receiver / 系統承受平滑可控的負載。見 01-Networking/07-Fault-Handling。
event_id、delivery queue 用 endpoint_id,為什麼不同?
目標相反。event 階段要 最大分散吞吐(事件間無依賴)→ event_id;delivery 階段要 隔離慢 endpoint(同 endpoint 收攏才能限流 / 熔斷)→ endpoint_id。熱門 endpoint 集中時再用 endpoint_id + hash(event_id)%K sub-shard。見 09-Messaging-Coord/03-Kafka、12-Ops-and-Reliability/02-Overload-Protection。
先用 per-endpoint throttling / circuit breaking 限它的速率、避免它拖垮 worker;再對它的 partition 做 sub-sharding(endpoint_id + ":" + hash(event_id) % K)把單 endpoint 的負載切成 K 份分散。見 12-Ops-and-Reliability/02-Overload-Protection。
不會。queue 是 durable 的,靠 visibility timeout:worker 取走訊息後它暫時不可見,只有成功處理並刪除才真正移除;worker crash 沒在逾時內刪 → 訊息重新可見被重投。這正是 at-least-once 的 queue 層保證(代價是可能重複,故客戶要冪等)。見 09-Messaging-Coord/02-Queue。
走失敗階梯終局:初始重試(退避+jitter)→ retryable store + scheduler 重放(SLA 內如 3 天)→ 仍失敗則送 DLQ、把 endpoint.status 設 suspended 並 通知客戶。停止無謂重試、保護平台、把問題回饋出去。見 12-Ops-and-Reliability/03-Reliable-Delivery。
不該由平台保證去重——那等同 exactly-once。正確分工是:平台保證 at-least-once 並在 payload 帶唯一 event_id + timestamp;客戶用 event_id 做冪等去重。同時 HMAC 簽 timestamp 也讓客戶能拒絕重放的舊請求。這是 webhook 的業界標準契約。見 12-Ops-and-Reliability/03-Reliable-Delivery、03-API-Design/05-API-Security。
依序:(1) delivery 隔離與吞吐——queue partition 擴容、delivery worker autoscale、hot endpoint sub-shard;(2) 訂閱查詢——擴 cache、預熱熱門 (user_id,event_type);(3) 訂閱表 10TB×規模 命中硬體上限 時依 user_id 引入 05-Database-Advanced/02-Sharding;(4) retryable store / scheduler 也需分片避免成新瓶頸。ingestion / worker 因無狀態直接加機器,見 02-Distributed-Systems/03-Scalability。
註冊時做 endpoint 所有權 challenge/response:先 POST 一個隨機 token,只有對方正確回傳才接受該 endpoint;並只允許 https:// + 驗 TLS 憑證 hostname。關鍵變更(domain/IP/cert)或定期重新 challenge。見 03-API-Design/05-API-Security。
主動回想:白板重建 (Whiteboard from Scratch)
不看上文,照下面提示把整個設計重建一次。每格都該能 30 秒內口述。
常見陷阱 (Exam Traps for This Problem)
What:為了讓客戶不用去重,宣稱保證「恰好一次」。
Why:超時語意不確定,exactly-once 需跨平台與每個客戶做分散式協調,昂貴又脆弱;面試官會立刻追問你怎麼實作然後你會卡住。
正確做法:at-least-once + payload 帶 event_id,由客戶冪等去重。明確說出這個分工。見 12-Ops-and-Reliability/03-Reliable-Delivery。
What:無限重試(占滿 queue / 打爆掛掉的 endpoint),或一次失敗就放棄(丟事件)。
Why:無限重試會把 queue 塞爆、對死掉的 endpoint 形成 DoS;直接丟又違反「數天內努力投遞」。
正確做法:退避+jitter 初始重試 → retryable store + scheduler 重放(SLA 內)→ DLQ + suspend endpoint + 通知。non-retryable 直接 DLQ。見 12-Ops-and-Reliability/03-Reliable-Delivery、01-Networking/07-Fault-Handling。
What:event 與 delivery queue 都按 event_id(或都按 endpoint)分。
Why:全 event_id → 無法隔離慢 endpoint,一個壞 endpoint 的超時污染所有 worker;全 endpoint_id → ingestion 階段失去分散、熱門 endpoint 成單點。
正確做法:event queue 用 event_id(分散吞吐),delivery queue 用 endpoint_id(隔離 + 可熔斷),hot endpoint 再 sub-shard。見 09-Messaging-Coord/03-Kafka、12-Ops-and-Reliability/02-Overload-Protection。
What:內部服務直接呼叫投遞、或 ingestion 收到就同步 POST 客戶 endpoint。
Why:慢 / 掛掉的 receiver 會 回壓阻塞 ingestion 乃至內部服務;流量突增直接壓垮;worker crash 就丟事件。
正確做法:內部 → event queue 解耦緩衝;ingestion → delivery queue(durable) → 獨立 worker。見 09-Messaging-Coord/02-Queue。
What:投遞路徑每事件都打 DB 查 (user_id, event_type)。
Why:read-heavy(每事件查一次、註冊只寫一次),高吞吐下 DB 是第一個倒下的。
正確做法:DB 前加 cache(key user_id:event_type),miss 才回 DB。見 07-Caching-Storage/01-Caching。
What:客戶填什麼 URL 就投什麼,webhook 不簽章。
Why:可被用來把平台當 SSRF / 攻擊放大器;客戶也無法確認 webhook 真來自我們、沒被竄改 / 重放。
正確做法:TLS server auth + 註冊時所有權 challenge/response;投遞用 HMAC-SHA256 簽 timestamp + raw_body,receiver 定時比較。見 03-API-Design/05-API-Security。
Related Notes
- 02-Distributed-Systems/05-Numbers-to-Know
- 02-Distributed-Systems/03-Scalability
- 03-API-Design/02-REST
- 03-API-Design/05-API-Security
- 05-Database-Advanced/02-Sharding
- 06-Database-Tech/01-Database
- 07-Caching-Storage/01-Caching
- 09-Messaging-Coord/02-Queue
- 09-Messaging-Coord/03-Kafka
- 01-Networking/03-HTTP-and-HTTPS
- 01-Networking/07-Fault-Handling
- 12-Ops-and-Reliability/02-Overload-Protection
- 12-Ops-and-Reliability/03-Reliable-Delivery