地震通知系統 (Design an Earthquake Notification System)

一句話定位 + TL;DR(含 35 分鐘心智時鐘)

設計一個 地震警報廣播系統:外部地震中心送來事件 → 系統算出「影響範圍(多邊形)內、且滿足使用者自訂條件(震度 / 距離)」的目標名單 → 在 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 + workeridempotency 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
defer 話術

怎麼有效率地找出範圍內使用者怎麼避免重複推播 我先標記,等 deep dive 再展開——現在先把 geo 查詢當成一個會回傳『範圍內 device 清單』的 black box,先把 ingest → targeting → fan-out 端到端串起來。」這招保住架構敘事連貫,也宣告你知道難點在哪。

2. 功能性與非功能性需求 (FR / NFR 與設計北極星)

設計北極星(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)

不重複原則(link-don't-restate)

通用機制(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 索引不適合多維。

新材料:S2 / H3 cell grid + polyfill(首次 introduce → 已登記 new_concepts_introduced)

  • 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-Index07-Caching-Storage/04-Redis06-Database-Tech/03-DynamoDB

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-Queue09-Messaging-Coord/03-Kafka12-Ops-and-Reliability/02-Overload-Protection

8.3 如何避免重複推播 + 處理版本覆蓋(去重與順序)

問題:要處理兩種情況——(1) Duplicates:同一 alert 的同一 version 不能送同一使用者兩次;(2) Out-of-order:同一 incident 的較新更新必須 覆蓋(supersede) 較舊通知。

新材料:Outbox idempotency gate + supersession(已登記 new_concepts_introduced)

  • notification_id = hash(alert_id | version | device_id):把「哪個事件的哪個版本送給哪個裝置」唯一化。每個 id 只保留一筆 Outbox 紀錄 → 天然冪等鍵。
  • status 狀態機ENQUEUED → ATTEMPTED → VENDOR_ACCEPTED | FAILED | CANCELLED_SUPERSEDEDATTEMPTED 之後皆為 terminal(含其後狀態)。
  • supersession cachesupersession:{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-Delivery07-Caching-Storage/03-Distributed-Cache

9. 擴展與瓶頸 (Scaling & Bottlenecks ~3 min)

主動找出系統會先在哪裂開並補強。

瓶頸 / 風險 補強手段 積木
位置寫入 5.5K/s 壓垮單庫 NoSQL 以 cell_id 分片、location service stateless 水平擴展 06-Database-Tech/03-DynamoDB02-Distributed-Systems/03-Scalability
alert 時 geo 查詢爆發 預寫 cell→devices 反向索引(讀已攤平到寫);命中後串流取 chunk 04-Database-Indexing/05-Geospatial-Index07-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
drive 的關鍵句

「系統 平時在位置寫入裂開(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…")

為什麼不能用 lat/long 加 B-tree 索引來查範圍內使用者?

位置寫入 QPS 怎麼估出來的?為什麼回報頻率不用很高?

Geohash、S2、H3 差在哪?本題為何選 S2 / H3?

流量放大 100x(全國級大地震,影響範圍內上千萬 device),哪裡先撐不住?怎麼補?

某個 cell 異常熱(如市中心,單 cell 塞百萬 device),讀這個 cell 變 hot key 怎麼辦?

Orchestrator 在送 alert 中途崩潰重啟,會漏推或重複推嗎?

為什麼 queue 選 Redis Stream 而不是 Kafka?什麼情況反而該選 Kafka?

同一地震連續更新震度(v1→v2→v3),但 v2 的 job 還卡在 queue,怎麼保證使用者不會先收到 v2 再收到 v3、也不會被 v2 蓋掉 v3?

cell 邊界的 false positive(範圍外的人也收到警報)能接受嗎?怎麼降低?

APNs 突然開始大量 retry-after / 限速,會不會拖垮整個系統?

主動回想:白板重建 (Whiteboard from Scratch)

不看上文,照下面提示把整個設計重建一次。每格都該能 30 秒內口述。

常見陷阱 (Exam Traps for This Problem)