地震通知系統 (Design an Earthquake Notification System)
設計一個 地震警報廣播系統:外部地震中心送來事件 → 系統算出「影響範圍(多邊形)內、且滿足使用者自訂條件(震度 / 距離)」的目標名單 → 在 sub-second 內透過 APNs / FCM fan-out 推播,且 不重複、舊版本不蓋掉新版本。本質是 寫多(位置回報)讀爆發(alert 瞬間大量讀)+ 大規模 fan-out 的系統。
招牌取捨:geo 比對 不在 alert 時即時算 lat/long 距離,而是讓 App 預先把位置回報成 cell_id、在高速 KV 維護 cell → device_refs 反向索引;alert 來時只做 polyfill(多邊形 → cell 清單)→ 查 KV → 去重 → enqueue。放棄 raw 座標的精確比對與少量邊界 false positive,換取 alert 路徑極短、可 sub-second fan-out。其餘決勝點:Geo cell 選型(S2 / H3 而非 Geohash)、三段 queue 解耦(Gateway / Orchestrator / Workers)以隔離故障、per-channel queue + worker、idempotency gate + supersession 去重。
配速(35 min 預算):~5 min 釐清需求 → ~4 min 容量估算 → ~3 min API + ~4 min 資料模型 → ~6 min 高階架構(三條 flow)→ ~9 min deep dive(geo 索引 / 快速 fan-out / 去重)→ ~3 min 擴展瓶頸 → ~1 min 收尾。開場先一句話複述題目、宣告配速、把「geo 索引怎麼做」「怎麼去重」標記為待會 deep dive——宣告配速本身就是高分動作。
1. 釐清需求與範圍 (Clarify & Scope ~5 min)
開場後先問清楚邊界,別急著畫架構。地震題最關鍵的釐清是「影響範圍是點還是多邊形」與「延遲到底要多低」,這兩者直接決定 geo 索引與整條 alert 路徑。
| 釐清問題 | 為何要問 / 影響到哪個決策 | 本題假設(依來源) |
|---|---|---|
| 使用者能設定哪些通知條件? | 決定 config schema 與過濾邏輯 | 距離 distance、震度 magnitude |
| 地震事件從哪來?格式 / 接法? | 決定 ingest(Gateway)設計 | 外部地震中心;維持 persistent feed 接連續串流 |
| 影響範圍是「以震央為圓心的半徑」還是「多邊形」? | 直接決定 geo 索引選型(圓 vs 多邊形) | 多邊形影響範圍(→ 偏好 polyfill 能力強的索引) |
| 延遲要求多嚴? | 決定 queue / 推播架構是否能容 Kafka | sub-seconds 送達所有目標使用者 |
| 規模多大(使用者數 / 位置回報頻率)? | 驅動寫入估算與 geo 儲存選型 | ~2,000 萬使用者(如台灣)、每人每小時回報一次 |
| 重複推播能容忍嗎?順序呢? | 決定是否要 idempotency + supersession | 盡量避免重複;新版本須覆蓋舊版本 |
| 我們自己送推播,還是走 APNs / FCM? | 決定 fan-out 是否受 vendor 限速 | 走第三方:iOS→APNs、Android→FCM |
「怎麼有效率地找出範圍內使用者 與 怎麼避免重複推播 我先標記,等 deep dive 再展開——現在先把 geo 查詢當成一個會回傳『範圍內 device 清單』的 black box,先把 ingest → targeting → fan-out 端到端串起來。」這招保住架構敘事連貫,也宣告你知道難點在哪。
2. 功能性與非功能性需求 (FR / NFR 與設計北極星)
-
功能性需求 (FR)
- 使用者可 設定通知條件(距離 distance、震度 magnitude)。
- 使用者裝置 定期回報所在位置。
- 地震發生時,系統 通知所有符合條件的使用者。
-
非功能性需求 (NFR)
- 低延遲 fan-out:sub-seconds 將警報送達所有目標使用者。
- 盡量不重複推播(duplicate-free),且 新版本覆蓋舊版本(supersession)。
- 隱含 寫多:位置回報是高頻寫入(~5.5K writes/s)。
- 隱含 爆發式讀:平時無事,alert 一來瞬間要查出數百萬目標。
設計北極星(NFR 直接導出後續所有選型的判準):
| NFR | 導出的設計北極星 | 後續對映選型 |
|---|---|---|
| sub-second fan-out | alert 路徑必須極短:禁止 full scan、禁止即時算距離 | 預寫 cell 反向索引、queue + per-channel worker |
| 多邊形影響範圍 | geo 索引要能用少量 cell 緊密覆蓋多邊形 | S2 / H3(polyfill / RegionCoverer 強) |
| 不重複 + 順序覆蓋 | 每個 (alert,version,device) 只送一次、舊版本可被取消 | idempotency gate(Outbox)+ supersession cache |
| 高頻位置寫入 | 位置寫入路徑要可水平擴展、與 config 解耦 | 獨立 location service + DB、寫 cell_id 而非座標 |
| 故障隔離(vendor 限速) | ingest / decisioning / sending 互不拖累 | 三段 queue 解耦、per-channel queue |
「這是一個 寫多位置 + 爆發讀 geo + 大規模 fan-out 的系統」——alert 路徑要短到能 sub-second,去重要靠冪等而非靠『送之前小心翼翼地檢查』。
3. 容量估算 (Back-of-Envelope ~4 min)
估算目的是 驅動 geo 索引與 queue 決策。回連 02-Distributed-Systems/05-Numbers-to-Know。
位置回報寫入(write-heavy 主軸)
使用者數 ≈ 20,000,000(台灣級)
每人每小時回報 1 次位置
writes/s = 20,000,000 ÷ 3,600 ≈ 5,556 ≈ 5.5K writes/s
alert 時的讀爆發(fan-out 規模)
平時 alert QPS ≈ 0(事件驅動,不是穩態流量)
單次大地震:影響範圍內可能 數十萬~數百萬 device
→ 必須在 sub-second 內「查出 + 去重 + 切 chunk + enqueue」
→ 推播本身受 APNs / FCM vendor 限速,靠 queue 吸收尖峰、worker 依安全速率消化
chunk 與 job 粒度(推導值)
每個 send job = 一個 chunk ≈ 500–2,000 device refs(來源建議區間)
假設 100 萬 device、chunk=1,000 → ≈ 1,000 個 jobs / alert(推導值)
→ workers 可水平擴充,per-channel 並行消化
下表寫入 QPS 為 推導值(由「使用者數 ÷ 3600」算出);fan-out 規模與 job 數隨 alert 影響範圍變動,非穩態 QPS——這正是「平時閒、瞬間爆」的形狀,決定了「queue 吸收尖峰」的必要性。
| 維度 | 數字 | 對後續決策的意義 |
|---|---|---|
| 位置寫入 | ≈ 5.5K writes/s(推導值) | 寫多 → 位置走 獨立 service + DB、寫 cell_id、可水平擴展 |
| config 寫入 | 遠低於位置寫入 | 設定極少改 → 與位置 拆成兩個 store |
| alert fan-out | 數十萬~數百萬 device / 事件(爆發) | 必須 queue + per-channel worker,禁止單機直推 |
| job 粒度 | chunk 500–2,000 refs / job(推導值) | 切 chunk 才能讓多 worker 並行、控制 vendor 限速 |
流量形狀是 「平時穩態寫多 + alert 瞬間讀爆發」。穩態寫靠分庫 + cell_id 解決;爆發讀靠 預寫反向索引(讀已被攤平到寫入時)+ queue 吸收尖峰。看懂這個形狀,後面選型全部水到渠成。
4. 用到的積木 / Building Blocks Used
架構即目錄:下表每個決策都對映一個既有 concept note(無 alias 連結);本表即「本案例練到哪些積木」的單一事實來源。
| 設計決策 | 積木 | 在本題的作用(≤1 行) |
|---|---|---|
| 容量估算驅動選型 | 02-Distributed-Systems/05-Numbers-to-Know | 算出 ~5.5K writes/s 與爆發 fan-out,決定 cell 索引 + queue |
| geo proximity / 多邊形覆蓋 | 04-Database-Indexing/05-Geospatial-Index | S2 / H3 cell + polyfill 取代 full scan 與 1D B-tree |
| 為何不能用 B-tree 索引座標 | 04-Database-Indexing/02-B-Tree-Index | 說明 1D B-tree 不適合 2D geometry(naive 失敗點) |
| 高頻位置寫入 store | 06-Database-Tech/03-DynamoDB | 以 cell_id 分片、高寫入吞吐承載 5.5K writes/s |
| cell→devices 高速反向索引 | 07-Caching-Storage/04-Redis | 維護 cell:devices,alert 時低延遲拉候選裝置 |
| supersession / idempotency 快取 | 07-Caching-Storage/03-Distributed-Cache | 存 supersession:{alert_id}→latest_version、去重 gate |
| ingest 與 decisioning 解耦 | 09-Messaging-Coord/02-Queue | 三段 queue 隔離故障、吸收尖峰、獨立 scaling |
| 低延遲 fan-out 隊列選型 | 09-Messaging-Coord/03-Kafka | 對照 Kafka(disk-first)vs Redis Stream(memory-first)取捨 |
| 可靠投遞(恰好一次效果) | 12-Ops-and-Reliability/03-Reliable-Delivery | Outbox + ack/retry 達成不漏不重 |
| 故障隔離(vendor brownout) | 12-Ops-and-Reliability/02-Overload-Protection | per-channel queue + 安全速率,隔離 APNs/FCM 限速 |
| 對外請求入口 | 11-Infrastructure/01-API-Gateway | config / location 請求經 Gateway 轉送後端 |
| 位置 / config 服務水平擴展 | 02-Distributed-Systems/03-Scalability | location service 與 workers 皆 stateless、可加機器 |
| API 身分驗證 | 03-API-Design/05-API-Security | 使用者身分以 JWT 放 header |
5. API 設計 (API Design ~3 min)
只有兩個寫入 endpoint(系統主要靠事件驅動,不對外開放 alert API)。身分以 JWT 放在 header(見 03-API-Design/05-API-Security)。
| Method | Path | 用途 | 重點 |
|---|---|---|---|
POST |
/alerts/configuration |
設定通知條件 | body 帶 { magnitude, distance, ... } → 寫 config DB;回 Success/Error |
POST |
/alerts/user_location |
定期回報位置 | body 帶 { lat, long }(或裝置端已換好的 cell_ids)→ 寫 location DB |
「位置回報 頻率遠高於 設定更新(5.5K/s vs 極低),所以我刻意把它們拆成 兩個 service + 兩個 DB——這樣位置寫入的 scaling 不會被 config 綁住。」這句話把「為什麼要拆」講清楚,是高分點。另外,裝置端可直接上報 cell_ids 而非 raw lat/long,把 geo 編碼成本推到 client、也更隱私友善。
6. 資料模型與儲存選型 (Data Model & Storage Choice ~4 min)
四個核心結構:config、location、supersession cache、notification_outbox。
AlertConfig(config DB)
--------------------------------------------------
user_id PK
magnitude INT ← 使用者願意收的最小震度
distance INT ← 使用者願意收的最大距離(km)
status ENUM ← active / inactive(註冊時預設 inactive)
--------------------------------------------------
UserLocation(location DB,high write)
--------------------------------------------------
device_id PK
cell_ids SET<64-bit> ← S2/H3 cell(非 raw lat/long)
ttl TIMESTAMP ← 7–30 天,過期自動清理
--------------------------------------------------
cell:devices(Redis 反向索引,hot path)
--------------------------------------------------
KEY: cell:{cell_id} → SET<device_ref> ← alert 時 O(查 cell) 拉候選
(並保留 device→cells 反向索引以便清理)
supersession:{alert_id} → latest_version ← 記錄最新版本
notification_outbox(idempotency gate + status ledger)
--------------------------------------------------
notification_id = hash(alert_id | version | device_id) ← PK,1 record / id
alert_id, version, device_id, channel
status ENUM ← ENQUEUED | ATTEMPTED | VENDOR_ACCEPTED | FAILED | CANCELLED_SUPERSEDED
儲存選型:
| 結構 | 選型 | 理由 |
|---|---|---|
| UserLocation | NoSQL(Cassandra / DynamoDB) | 5.5K writes/s 寫多、以 cell_id 分片簡單、可水平擴充;見 06-Database-Tech/03-DynamoDB |
| cell:devices | 高速 KV(Redis / Aerospike / DAX) | alert hot path 要低延遲拉候選 device;見 07-Caching-Storage/04-Redis |
| AlertConfig | 獨立 DB(與位置拆開) | 寫入頻率低、與位置 scaling 解耦 |
| supersession | Distributed cache | 版本比對是高頻小讀寫;見 07-Caching-Storage/03-Distributed-Cache |
| Outbox | 持久 store(idempotency ledger) | 每 notification_id 一筆,做去重與狀態帳本 |
→ 積木:geo 編碼見 04-Database-Indexing/05-Geospatial-Index;寫多 store 見 06-Database-Tech/03-DynamoDB;反向索引快取見 07-Caching-Storage/04-Redis。
location DB 存的是 cell_ids(粗粒度)而非精確座標——這是 隱私友善 的設計,代價是 alert 範圍邊界可能有少量 false positive,除非再做精細裁切(見第 8 段)。
7. 高階架構 (High-Level Design ~6 min)
三條核心 flow:config 設定、location 回報、alert 廣播。
(A) User configuration flow(設定流程)
Client ──POST /alerts/configuration { magnitude, distance }──►
API Gateway ──► Config Service ──► Config DB
└─ 註冊時給預設設定,或標記 status=inactive
(B) User location reporting flow(位置回報流程)
Device ──(定期,每小時級)── POST /alerts/user_location { lat,long | cell_ids }──►
API Gateway ──► Location Service ──► Location DB (NoSQL, by cell_id)
│ 寫入 cell_ids(TTL 7–30 天)
└─► 更新 Redis cell:{cell_id} → +device_ref
(頻率不需高:位置精準度要求低,移動 100km 通常需數小時)
(C) Alert broadcast flow(警報廣播流程 — 系統心臟)
Event Source (地震中心)
│ persistent feed(連續事件串流)
▼
Gateway ──(維持 TLS / heartbeat / reconnect / backoff,處理來源 quirks)
│ └─► [Queue 1] ingest queue ← Gateway 持續收,不掉資料
▼
Broadcast Service / Orchestrator(geo-targeting + 路由)
│ 1. polyfill:用 cells 覆蓋 alert 多邊形 → cell 清單
│ 2. 從 Redis cell:devices 拉候選 device refs
│ 3. 去重(de-dupe)+ 依使用者 config(magnitude/distance)過濾
│ 4. 切 chunk(每組 500–2,000 refs)→ 封成 send job
│ 5. 更新 supersession cache + 寫 Outbox(idempotency gate)
│ └─► [Queue 2..N] per-channel queues(APNs / FCM / SMS 各一條)
▼
Workers(per-channel,stateless)
│ 取一個 job(chunk) → 逐 token 呼叫 APNs / FCM / SMS(vendor 不支援真 broadcast)
│ upsert Outbox status:ENQUEUED → ATTEMPTED → VENDOR_ACCEPTED / FAILED
▼
APNs / FCM / SMS ──► 使用者裝置
Scaled 架構(端到端,標出三段 queue 邊界)
┌── Config Service ── Config DB
API Gateway ──►│
└── Location Service ── Location DB (NoSQL) ──► Redis cell:devices
▲ (alert 時讀)
Event Source ──persistent feed──► Gateway ──[Q1]──► Orchestrator ─┘
(ingest) (geo-target / chunk / Outbox)
│
┌───────────────┼───────────────┐
[Q2 APNs] [Q3 FCM] [Q4 SMS] ← per-channel
▼ ▼ ▼
APNs Workers FCM Workers SMS Workers ← 各自 scaling / retry
▼ ▼ ▼
APNs FCM SMS ──► devices
關鍵 hand-off:Gateway(持久 ingest)─Q1─ Orchestrator(CPU-heavy targeting)─per-channel Q─ Workers(依 vendor 安全速率送);三段間都用 queue,讓 ingest / decisioning / sending 獨立 scaling 且互不拖累。
8. 深入探討 (Deep Dives ~9 min)
通用機制(geohash/S2/H3 原理、queue 語意、cache 細節)只在 concept note 解釋;這裡只講「本題怎麼用、選哪個、為什麼」。本題無 home note 的新材料(S2/H3 cell grid 的工程取捨、polyfill、supersession 版本、Outbox 帳本)才在此 inline 教學並登記到 new_concepts_introduced。
8.1 如何處理位置更新 + 有效率地找出受影響使用者(招牌取捨)
問題:alert 一來,要在 sub-second 內從數百萬使用者中找出「落在多邊形影響範圍內」的人。
為何是瓶頸:naive 做法(用 raw lat/long 查 OLTP table、對每位使用者算距離)需要 full / near-full scan;即使對 lat/long 建 1D B-tree 也不適合 2D geometry,proximity search 效能仍差——百萬使用者時是 non-starter。詳見 04-Database-Indexing/02-B-Tree-Index 為何 1D 索引不適合多維。
- Geohash:經緯度二分切矩形格、編 Base32 字串,前綴相同 ≈ 位置相近。簡單普及,但 格子長寬比不一致、近兩極變形嚴重,圓 / 多邊形覆蓋會 鋸齒邊界 + cell 數暴增。
- S2(Google):地球投影到立方體 6 面,每面用 quadtree 切成近似正方形 S2 Cell(64-bit ID)。RegionCoverer 做多邊形覆蓋很強、變形可預期。
- H3(Uber):在二十面體鋪 六角形 格網(少量五邊形封閉曲面),每層細分成 7 個小六角;六角鄰接均勻、面積接近等面積,內建
polyfill/kRing。 - polyfill / RegionCoverer:把一個 多邊形 轉成「覆蓋它的 cell 清單」。S2 / H3 能用 少量且緊密 的 cells 覆蓋,Geohash 用矩形拼接易鋸齒、cell 暴增。
| 方案 | 做法 | 取捨 |
|---|---|---|
| 1. Naive:raw lat/long + 算距離 | alert 時直接查 OLTP、逐人算距離 | ❌ full scan、1D B-tree 不適合 2D、無法 scale |
| 2. Better:NoSQL + geo key 持久化 | 以 cell_id(非座標)存 Cassandra/Dynamo | ✅ 高寫入、好分片;△ 仍需額外結構支援即時推播、多邊形要自己處理 |
| 3. Best:cell grid + KV 反向索引 | Redis/Aerospike 維護 cell→device_refs,並存 device→cells 反向以便清理 |
✅ 低延遲、行為可預測、隱私友善;△ 邊界少量 false positive |
選擇與理由:採 方案 3。核心招式是 把 geo 比對的成本攤平到寫入時——App 回報的是 cell_ids(設 TTL 7–30 天),輕鬆支援 O(100k)/s 寫入;alert 時只做 polyfill(多邊形→cell 清單)→ 串流取出 chunked device refs → 去重 → 依 channel enqueue。geo 索引選 S2 / H3 而非 Geohash,因為本題是「多邊形影響範圍 → 找受影響者」,polyfill 能力與均勻性直接決定準確度與 cell 數。這就是本題 core_tradeoff:放棄精確座標比對與少量邊界 false positive,換取 alert 路徑短到能 sub-second。
→ 積木:04-Database-Indexing/05-Geospatial-Index、07-Caching-Storage/04-Redis、06-Database-Tech/03-DynamoDB
位置 精準度要求本來就不高(使用者移動 100km 通常需數小時),cell 粒度足夠。好處:(1) alert 時 不必逐人算距離,直接 cell→devices 查表;(2) 隱私友善(只存粗粒度 cell);(3) 寫入只是更新一個 set,吞吐高。代價是 邊界 false positive——可在 cell 命中後對邊緣 device 再做一次精細距離裁切(trade 延遲換準確)。
8.2 如何把警報快速送達所有受影響使用者(sub-second fan-out)
問題:影響範圍內可能有數十萬~百萬 device,要 sub-second 送達,且 APNs/FCM 不支援真 broadcast(得逐 token 送)。
為何是瓶頸:naive(收事件的 server 直接推給所有人)有兩個死法——(1) 使用者多時 無法快速送達所有人;(2) 送通知的資源與從 event source 拉資料的資源互相競爭,推播一卡甚至拖慢 event pulling、影響後續事件。
| 手段 | 做什麼 | 取捨 |
|---|---|---|
| Q1:Gateway↔Orchestrator | persistent ingest 與 CPU-heavy targeting 獨立 scaling | Orchestrator 重啟時 Gateway 仍收事件不掉資料(fail soft) |
| per-channel Q:Orch↔Workers | Orchestrator 快速丟 job、queue 吸尖峰、worker 依 vendor 安全速率消化 | APNs brownout 只堆 APNs queue,FCM/SMS 照常流動 |
| chunk 化 job | 每 job = 500–2,000 device refs | 多 worker 並行、好控限速;過大 job 失敗重送成本高 |
| Redis Stream 當 queue | memory-first、協調成本低、極低延遲 fan-out | 持久性弱於 Kafka,但本題要的是 低延遲 而非高持久 log |
選擇與理由:用 三段 queue 解耦 + per-channel queue/worker。三段邊界各有理由:(1) Gateway↔Orchestrator 一層 queue → ingest 與 decisioning 獨立 scaling、可快速演進 Orchestrator 邏輯而不碰脆弱的 long-lived 連線;(2) Orchestrator↔Workers 一層 queue → 吸收尖峰、依 vendor 限速慢慢消化、per-channel 故障隔離;(3) queue 選 Redis Stream 而非 Kafka——Kafka 是 disk-first 為高吞吐高持久 log 設計,不是極低延遲 fan-out;本題要 sub-second,memory-first 的 Redis Stream 更合適。
→ 積木:09-Messaging-Coord/02-Queue、09-Messaging-Coord/03-Kafka、12-Ops-and-Reliability/02-Overload-Protection
故障隔離 + 差異化政策。APNs / FCM / SMS 各有不同的 retry / rate limit;綁在一起的話某通道 brownout 會拖垮全部。per-channel 後:APNs 堆積不影響 FCM;每通道可有不同 shard / partition 數、獨立依 region 擴 worker,不影響 decisioning。見 12-Ops-and-Reliability/02-Overload-Protection。
8.3 如何避免重複推播 + 處理版本覆蓋(去重與順序)
問題:要處理兩種情況——(1) Duplicates:同一 alert 的同一 version 不能送同一使用者兩次;(2) Out-of-order:同一 incident 的較新更新必須 覆蓋(supersede) 較舊通知。
- notification_id =
hash(alert_id | version | device_id):把「哪個事件的哪個版本送給哪個裝置」唯一化。每個 id 只保留一筆 Outbox 紀錄 → 天然冪等鍵。 - status 狀態機:
ENQUEUED → ATTEMPTED → VENDOR_ACCEPTED | FAILED | CANCELLED_SUPERSEDED;ATTEMPTED之後皆為 terminal(含其後狀態)。 - supersession cache:
supersession:{alert_id} → latest_version,記錄某 alert 已知最新版本。
避免同版本重複(Avoiding duplicates)
| 角色 | 動作 |
|---|---|
| Orchestrator | 算 notification_id;若 Outbox 已存在且為 terminal → 不再 enqueue;否則 enqueue 並設 ENQUEUED |
| Sender Worker | 讀 Outbox:若 terminal → 直接 ack/drop;否則呼叫 APNs/FCM、upsert ATTEMPTED → 收回應再 upsert VENDOR_ACCEPTED / failure |
處理版本覆蓋(Avoiding out-of-order / supersession)
| 角色 | 動作 |
|---|---|
| Orchestrator | 更新 supersession cache;若進來的 version 比 latest_version 舊 → 直接 drop;對同一 alert_id,把 Outbox 中所有 version < latest_version 且 尚未 terminal 的紀錄批次改為 CANCELLED_SUPERSEDED |
| Sender Worker | 送前讀 supersession cache;若自己的 version < latest_version → 跳過發送,並在尚未 terminal 時把自己改為 CANCELLED_SUPERSEDED |
選擇與理由:雙重防線——Orchestrator 在 enqueue 前用 Outbox 擋已 terminal 的、用 supersession 取消舊版本;Worker 在 送出前 再讀一次 supersession 做最後攔截(因為 job 在 queue 裡排隊期間版本可能又更新了)。這把「不重複 + 新蓋舊」落實成 冪等寫入 + 版本比對,而不是靠送之前小心翼翼地檢查。
→ 積木:12-Ops-and-Reliability/03-Reliable-Delivery、07-Caching-Storage/03-Distributed-Cache
因為 job 在 queue 裡排隊期間,可能又來了更新的 version。Orchestrator 取消的是「enqueue 當下」已知的舊版本;Worker 在真正呼叫 vendor 前再讀一次,能攔截「排隊期間才變舊」的 job,避免送出一則馬上要被覆蓋的過期警報。這是 最後一道閘。
9. 擴展與瓶頸 (Scaling & Bottlenecks ~3 min)
主動找出系統會先在哪裂開並補強。
| 瓶頸 / 風險 | 補強手段 | 積木 |
|---|---|---|
| 位置寫入 5.5K/s 壓垮單庫 | NoSQL 以 cell_id 分片、location service stateless 水平擴展 | 06-Database-Tech/03-DynamoDB、02-Distributed-Systems/03-Scalability |
| alert 時 geo 查詢爆發 | 預寫 cell→devices 反向索引(讀已攤平到寫);命中後串流取 chunk | 04-Database-Indexing/05-Geospatial-Index、07-Caching-Storage/04-Redis |
| 推播端與 ingest 端搶資源、互相拖垮 | 三段 queue 解耦:Gateway / Orchestrator / Workers 獨立 scaling | 09-Messaging-Coord/02-Queue |
| 某 vendor(APNs)brownout | per-channel queue:只堆該通道,其餘照流;依 channel 獨立擴 worker | 12-Ops-and-Reliability/02-Overload-Protection |
| 重複 / 過期推播 | Outbox idempotency gate + supersession(雙重防線) | 12-Ops-and-Reliability/03-Reliable-Delivery |
| 過期 / 漂移的位置資料累積 | location 設 TTL 7–30 天 自動過期;device→cells 反向索引便於清理 |
— |
| 隊列延遲拖慢 fan-out | queue 選 Redis Stream(memory-first) 而非 Kafka(disk-first) | 09-Messaging-Coord/03-Kafka |
「系統 平時在位置寫入裂開(5.5K/s)→ 用 NoSQL + cell 分片解;alert 瞬間在 geo 查詢與 fan-out 裂開 → 用預寫反向索引 + 三段 queue + per-channel worker 解。我把 geo 比對成本攤平到寫入時,讓最緊的 alert 路徑只剩 polyfill→查 KV→enqueue。」
10. 取捨總表與收尾 (Trade-off Recap & Wrap-up ~1 min)
| 決策點 | 選擇 | 放棄了什麼 | 為什麼值得 |
|---|---|---|---|
| geo 比對 | 預寫 cell→devices 反向索引(alert 時 polyfill) | raw 座標精確比對、少量邊界 false positive | alert 路徑短到能 sub-second(招牌取捨) |
| geo 索引 | S2 / H3 | Geohash 的簡單普及 | 多邊形覆蓋強、均勻、cell 數少 |
| 位置 store | NoSQL by cell_id(與 config 拆開) | 單庫的簡單 | 寫多可分片、與 config scaling 解耦 |
| ingest↔send | 三段 queue 解耦 | 直推的簡單 | 故障隔離、獨立 scaling、fail soft |
| fan-out 隊列 | Redis Stream(memory-first) | Kafka 的高持久 | 要的是 低延遲 fan-out 而非耐久 log |
| 去重 | Outbox idempotency + supersession 雙防線 | 「送前小心檢查」的直覺 | 冪等 + 版本比對,原子可靠、能攔排隊期間變舊的 job |
「整題的單一錨點是 把 geo 比對攤平到寫入時:使用者回報 cell_id、我們維護 cell→devices 反向索引,alert 來時只做 polyfill→查 KV→去重→enqueue,這是達成 sub-second fan-out 的關鍵。送達靠 三段 queue + per-channel worker 隔離故障、Redis Stream 壓低延遲;不重複靠 Outbox 冪等 + supersession 雙防線。代價是 cell 邊界少量 false positive,必要時再精細裁切。」
後續追問演練 (Interviewer Follow-ups — "What if…")
用 raw lat/long 查需要 full / near-full scan 對每人算距離;即使對 lat/long 建 1D B-tree,它 不適合 2D geometry,proximity search 仍很差。百萬使用者時是 non-starter。要用 cell grid(S2/H3)+ polyfill。見 04-Database-Indexing/05-Geospatial-Index、04-Database-Indexing/02-B-Tree-Index。
2,000 萬使用者 ÷ 3,600 s ≈ 5.5K writes/s(推導值,假設每人每小時回報一次)。頻率不用高是因為 位置精準度要求低——使用者移動 100km 通常需數小時,cell 粒度足夠。回連 02-Distributed-Systems/05-Numbers-to-Know。
Geohash 矩形字串、簡單但 高緯度變形、多邊形覆蓋鋸齒且 cell 暴增;S2 立方體投影 + quadtree、RegionCoverer 多邊形覆蓋強;H3 六角格、接近等面積、鄰接均勻、內建 polyfill/kRing。本題是「多邊形影響範圍→找受影響者」,S2/H3 覆蓋更準、更省。見 04-Database-Indexing/05-Geospatial-Index。
fan-out 先裂。依序:(1) Orchestrator 多 instance 並行對不同 cell 子集做 targeting;(2) 加大 chunk 並行度、per-channel worker 大量水平擴;(3) queue 吸收尖峰、依 vendor 安全速率消化(接受『不是所有人同一毫秒收到,但都在 sub-second 級』);(4) Redis cell:devices 依 cell_id 分片避免單節點熱。位置寫入端 NoSQL 已可分片。見 09-Messaging-Coord/02-Queue、12-Ops-and-Reliability/02-Overload-Protection。
經典 hot cell。手段:(1) 對該 cell 的 device set 再分片(cell:{id}:shard{n})讓多節點分擔;(2) alert 時 串流(chunked)讀取 而非一次全拉;(3) 用更細解析度 cell 拆散熱點。core 概念見 07-Caching-Storage/04-Redis 與 07-Caching-Storage/03-Distributed-Cache。
不會漏:Gateway↔Orchestrator 間有 queue,事件仍在 Q1 等候(fail soft)。不會重:Outbox 以 notification_id = hash(alert_id|version|device_id) 為冪等鍵,已 terminal 的不再 enqueue;重啟後重跑會跳過已完成的。見 12-Ops-and-Reliability/03-Reliable-Delivery。
本題北極星是 sub-second 低延遲 fan-out;Redis Stream memory-first、協調成本低,更合適。Kafka disk-first,為 高吞吐 + 高持久 log 設計,延遲較高。若需求改成「事件必須長期可重放 / 審計、吞吐遠大於延遲敏感」(如把所有地震事件存成不可變 log 供下游多消費者重算),則該選 Kafka。見 09-Messaging-Coord/03-Kafka。
靠 supersession 雙防線:Orchestrator 收到 v3 時更新 supersession:{alert_id}=v3,並把 Outbox 中 v1/v2 尚未 terminal 的批次標 CANCELLED_SUPERSEDED;Worker 送出前再讀一次 supersession,發現自己 version < latest 就跳過。所以卡在 queue 的 v2 job 出列時會被攔下,不會覆蓋 v3。見 12-Ops-and-Reliability/03-Reliable-Delivery。
地震警報 寧可多送也別漏送,少量 false positive 通常可接受。要降低就在 cell 命中後對邊緣 device 再做一次精細距離裁切(用 config 的 distance 精算),以一點延遲換準確度。這是 cell 粒度與精度間的可調 trade。
不會。per-channel queue 讓 APNs 的堆積 只影響 APNs queue,FCM / SMS 照常流動,Orchestrator 的 decisioning 也不受影響。APNs worker 依 vendor 的安全速率慢慢消化、套用該通道專屬的 retry policy。見 12-Ops-and-Reliability/02-Overload-Protection。
主動回想:白板重建 (Whiteboard from Scratch)
不看上文,照下面提示把整個設計重建一次。每格都該能 30 秒內口述。
常見陷阱 (Exam Traps for This Problem)
What:地震來時掃 location table,對每人算與震央距離。
Why:需要 full / near-full scan,1D B-tree 不適合 2D,百萬使用者時 無法 sub-second。
正確做法:把 geo 比對 攤平到寫入時——使用者回報 cell_id,維護 cell→devices 反向索引;alert 時只 polyfill→查 KV→去重。見 04-Database-Indexing/05-Geospatial-Index。
What:直覺挑最常見的 Geohash 做 geo 索引。
Why:Geohash 矩形拼多邊形會 鋸齒邊界 + cell 數暴增,高緯度還變形。
正確做法:選 S2 / H3,用 polyfill / RegionCoverer 以少量緊密 cell 覆蓋多邊形。見 04-Database-Indexing/05-Geospatial-Index。
What:ingest 與 send 寫在同一條路徑、單機直推。
Why:(1) 使用者多時無法快速送達;(2) 送通知資源與拉事件資源互搶,推播一卡會拖慢 event pulling、影響後續事件。
正確做法:三段 queue 解耦 + per-channel worker,獨立 scaling、故障隔離。見 09-Messaging-Coord/02-Queue。
What:看到 queue 就上 Kafka。
Why:Kafka disk-first,為高持久 log 設計,延遲較高;本題北極星是 sub-second fan-out。
正確做法:用 Redis Stream(memory-first) 壓低延遲;需要長期可重放 / 審計才換 Kafka。見 09-Messaging-Coord/03-Kafka。
What:應用層先查再送;只防重複不防舊蓋新。
Why:有 race condition;且同 incident 連續更新時,舊版本 job 可能蓋掉新版本。
正確做法:Outbox 以 hash(alert_id|version|device_id) 為冪等鍵(原子)+ supersession 雙防線(Orchestrator 取消舊版、Worker 送前再查)。見 12-Ops-and-Reliability/03-Reliable-Delivery。
Related Notes
- 02-Distributed-Systems/05-Numbers-to-Know
- 02-Distributed-Systems/03-Scalability
- 03-API-Design/05-API-Security
- 04-Database-Indexing/05-Geospatial-Index
- 04-Database-Indexing/02-B-Tree-Index
- 06-Database-Tech/03-DynamoDB
- 07-Caching-Storage/04-Redis
- 07-Caching-Storage/03-Distributed-Cache
- 09-Messaging-Coord/02-Queue
- 09-Messaging-Coord/03-Kafka
- 11-Infrastructure/01-API-Gateway
- 12-Ops-and-Reliability/02-Overload-Protection
- 12-Ops-and-Reliability/03-Reliable-Delivery