系統設計面試框架與時間分配 高頻
系統設計面試沒有標準答案,面試官評估的是思考過程:你能不能把模糊的需求變成清楚的問題、能不能用數字驅動設計決策、能不能看出瓶頸、能不能說清楚取捨。最常見的失敗模式不是「不會」,而是「沒有結構」:一開口就畫 Kafka + Redis + K8s,結果 40 分鐘後還沒講到核心問題。
建議把整場面試當成一個有固定節奏的流程,並且主動宣告你的流程:「我先花幾分鐘釐清需求和規模,接著估算、定 API 和資料模型,畫出高層架構,然後挑兩三個最關鍵的瓶頸深入,最後談取捨與擴展。」這句話本身就是加分,因為它讓面試官知道你有方法,也方便他在中途把你拉到他想考的點。
八步驟框架
- 釐清需求(Requirements):功能需求(functional:系統要做什麼)與非功能需求(non-functional:QPS、延遲、可用性、一致性、資料保留期、成本、安全/隱私)。明確寫下「在範圍內 / 不在範圍內」。
- 估算(Back-of-the-envelope):QPS(平均與峰值)、讀寫比、儲存量(每日、保留期總量)、頻寬、記憶體(cache 熱資料大小)。目的不是算得精準,而是決定「單機能不能撐」「要不要 sharding」「要不要 cache」。
- API 設計:列出 3–5 個核心 endpoint(REST 或 gRPC),含參數、回應、冪等性(idempotency key)、分頁、認證。API 讓需求變得具體。
- 資料模型:核心實體、主鍵、存取模式(access pattern)、索引、分片鍵(shard key)。先講 access pattern 再選 DB,而不是反過來。
- 高層架構(High-level design):畫出 client → LB → 服務 → 儲存 / 佇列 / cache 的資料流,並沿著一個請求「走一遍」(read path 與 write path 各一次)。
- 深入瓶頸(Deep dive):挑 2–3 個最難的點深入(熱點、一致性、去重、延遲預算、故障處理)。通常面試官會在這裡引導,順著他走。
- 取捨與擴展(Trade-offs & scaling):講出你放棄了什麼、為什麼;10 倍流量時哪裡先壞;單點故障在哪;如何監控。
- 總結(Wrap-up):用 30 秒複述設計、已知限制與下一步可改進之處。
時間分配建議
| 階段 | 45 分鐘場 | 60 分鐘場 | 產出 |
|---|---|---|---|
| 釐清需求 | 5 分 | 5–8 分 | 功能清單、非功能指標、範圍外清單 |
| 估算 | 3–5 分 | 5 分 | QPS、儲存、頻寬三個數字 |
| API + 資料模型 | 5–7 分 | 8–10 分 | endpoint 列表、主要 table/key 設計 |
| 高層架構 | 8–10 分 | 10–12 分 | 架構圖 + 走一遍 read/write path |
| 深入瓶頸 | 12–15 分 | 15–20 分 | 2–3 個關鍵問題的解法與替代方案 |
| 取捨、擴展、總結 | 3–5 分 | 5 分 | 限制、監控、未來改進 |
(1) 你會先問問題而不是直接畫圖;(2) 你的設計決策有數字或需求當依據(「因為寫入峰值 5 萬/秒,單一 MySQL primary 撐不住,所以…」);(3) 你知道每個元件的代價(Kafka 帶來延遲與維運成本、cache 帶來一致性問題);(4) 你能主動指出自己設計的弱點。這四點比「用了多少潮流技術」重要得多。
白板或線上畫圖時,把畫面分成三區:左上角寫需求與估算數字(之後會一直回頭引用)、中間畫架構、右側列「待深入 / 取捨」清單。當面試官問到你沒想過的點,先把它記在右側清單,回答「這是個好問題,我先把主流程講完再回來處理」,可以避免被打亂節奏。
釐清需求:該問的問題清單 高頻
Appier 的題目通常只有一句話(「設計一個廣告投放系統」「SDK 會回報大量 JSON,設計統計報表系統」),刻意保留模糊空間。問對問題可以把題目縮到 45 分鐘講得完的範圍,也展現你理解業務。
功能需求(Functional)
- 誰是使用者?終端消費者、廣告主、內部營運、其他服務?不同使用者的 SLA 完全不同。
- 核心使用情境是哪 2–3 個?例如報表系統:是「看即時儀表板」還是「每天下載 CSV」?
- 輸入/輸出長什麼樣?事件格式、欄位數、是否有 schema、大小。
- 需要哪些維度與指標?(報表題)可以任意 group by 嗎?還是固定幾種報表?
- 有沒有多租戶(multi-tenant)?Appier 的產品都是 B2B SaaS,幾乎都有。
- 哪些功能明確不做?例如「計費和反作弊先不談」。
非功能需求(Non-functional)
| 面向 | 要問的問題 | 會影響的設計 |
|---|---|---|
| 規模 | DAU、每日事件數、峰值倍數、成長率 | 是否分片、Kafka partition 數、叢集大小 |
| 延遲 | p99 要多少?讀和寫分別? | 同步 vs 非同步、cache、預計算 |
| 新鮮度 | 資料多久後可見?秒級、分鐘級、隔日? | 串流 vs 批次(Flink vs Spark) |
| 準確度 | 要精確還是可以近似?可容忍重複/遺失嗎? | exactly-once、去重、HLL |
| 一致性 | 使用者寫完要立刻讀到嗎?跨區域? | 強一致 DB vs 最終一致、read-your-writes |
| 可用性 | 99.9% 還是 99.99%?哪些功能可以降級? | 多 AZ、多 region、fallback |
| 保留期 | 原始資料保留多久?聚合資料多久? | 冷熱分層、S3 lifecycle、TTL |
| 安全/隱私 | 是否有 PII?GDPR/個資法刪除需求? | 雜湊化、加密、刪除流程 |
| 成本 | 是否有預算限制?LLM 每次呼叫多少錢? | 取樣、預聚合、模型選擇 |
問了一大串問題但沒有把答案「定下來」。正確做法是:問完後自己總結假設,例如「那我假設 DAU 5000 萬、每人每天 20 個事件、報表延遲 5 分鐘內可接受、unique user 可接受 1% 誤差」,寫在白板左上角。面試官沒給數字時,自己提出合理假設並請對方確認,不要卡住等答案。
以 AdTech/MarTech 平台常見情況來說,幾個「預設值」很好用:廣告競價(RTB)回應需在約 100ms 內完成;事件量級為每天數十億筆;報表通常可接受分鐘級延遲,但計費相關數字必須精確且可稽核;所有資料都要以 tenant(廣告主/品牌客戶)隔離。主動說出這些,代表你理解這個產業。
估算技巧與常用數字 高頻 基礎
估算的目的只有一個:讓數字幫你做設計決策。所以要算的是「會改變架構的數字」:峰值 QPS(決定機器數與是否需要佇列緩衝)、總儲存量(決定 DB 選型與分片)、熱資料大小(決定 cache 能否放進記憶體)、頻寬(決定是否要壓縮、批次)。
QPS 換算
一天 86,400 秒,估算時可以近似成 105 秒(誤差約 16%,面試可接受;需要時再修正)。
| 每日量 | 平均 QPS(精算) | 近似(÷105) | 峰值(×3~×5) |
|---|---|---|---|
| 100 萬/天 | ≈ 11.6/s | 10/s | 35–60/s |
| 1,000 萬/天 | ≈ 116/s | 100/s | 350–600/s |
| 1 億/天 | ≈ 1,160/s | 1,000/s | 3,500–6,000/s |
| 10 億/天 | ≈ 11,600/s | 10,000/s | 3.5 萬–6 萬/s |
| 100 億/天 | ≈ 116,000/s | 100,000/s | 35 萬–60 萬/s |
峰值倍數:一般網路服務峰值約平均的 2–3 倍;有明顯晚間高峰或促銷活動(雙 11、推播發送瞬間)的系統,用 5–10 倍估比較安全。推播類系統更特殊:一次發送 100 萬則訊息,接著幾分鐘內的點擊/開啟回流會形成尖峰,要特別說明。
儲存量與頻寬
- 儲存 = 每日筆數 × 每筆大小 × 保留天數 × 副本數(replication factor,常見 3)÷ 壓縮比。
- 頻寬 = QPS × 每筆大小。注意要換成 bit:1 MB/s = 8 Mbps。
- 記憶體 / cache:常用 80/20 法則,熱資料約 20%;或用「最近 N 小時的 key 數 × 每個 key 大小」。
- 機器數 = 峰值 QPS ÷ 單機可承受 QPS(保留 30–50% 餘裕)。
# 面試時可以口算,這段只是把思路寫清楚
DAY = 86_400
def estimate(events_per_day: float, bytes_per_event: int, peak_factor: float = 4,
retention_days: int = 90, replicas: int = 3, compression: float = 5):
avg_qps = events_per_day / DAY
peak_qps = avg_qps * peak_factor
daily_raw_gb = events_per_day * bytes_per_event / 1e9
stored_tb = daily_raw_gb * retention_days * replicas / compression / 1e3
peak_mbps = peak_qps * bytes_per_event * 8 / 1e6
return dict(avg_qps=round(avg_qps), peak_qps=round(peak_qps),
daily_raw_gb=round(daily_raw_gb), stored_tb=round(stored_tb, 1),
peak_ingress_mbps=round(peak_mbps))
print(estimate(1e9, 1_000))
# {'avg_qps': 11574, 'peak_qps': 46296, 'daily_raw_gb': 1000,
# 'stored_tb': 54.0, 'peak_ingress_mbps': 370}
2 的冪次與數量級
| 冪次 | 近似值 | 名稱 | 常見用途 |
|---|---|---|---|
| 210 | 約 1 千(1,024) | KB | 一個小 JSON 事件約 0.5–2 KB |
| 220 | 約 1 百萬 | MB | — |
| 230 | 約 10 億 | GB | — |
| 232 | 約 43 億 | — | 32-bit int 上限、IPv4 位址數 |
| 240 | 約 1 兆 | TB | — |
| 250 | 約 1,000 兆 | PB | 大型事件資料湖量級 |
| 264 | 約 1.8 × 1019 | — | 64-bit ID 空間 |
延遲數量級(每個工程師都該記得的數字)
下列為經典「Latency Numbers Every Programmer Should Know」的數量級,實際值依硬體而異,面試時記得「差幾個數量級」比記精確值重要。
| 操作 | 約略延遲 | 啟示 |
|---|---|---|
| L1 cache 存取 | 約 1 ns | — |
| 主記憶體存取 | 約 100 ns | in-process cache 極快 |
| 1 Gbps 網路傳 1 KB | 約 10 µs | — |
| SSD 隨機讀 4 KB | 約 100–150 µs | — |
| 同資料中心網路 round trip | 約 0.5 ms | 一次 Redis 呼叫約 0.2–1 ms,在 100ms 預算下只能呼叫有限次數 |
| SSD 循序讀 1 MB | 約 1 ms | — |
| HDD seek | 約 10 ms | 線上路徑避免隨機磁碟 IO |
| 跨洲 round trip | 約 100–150 ms | RTB bidder 必須部署在靠近 exchange 的區域 |
單機容量的粗略參考
以下是常被引用的「數量級」,用來判斷要不要水平擴展,實際仍需壓測(benchmark):
- 一般無狀態 API 服務:單機每秒數千到上萬個簡單請求(取決於語言、邏輯與 IO)。
- Redis 單一實例:簡單指令每秒約十萬級 ops;使用 pipeline 可更高;大 key、Lua 長腳本會拖慢(單執行緒執行命令)。
- MySQL 單一 primary:簡單主鍵寫入每秒數千級,視硬體、索引與交易大小而定;讀可以靠 replica 擴展。
- Kafka:單一 broker 可處理每秒數十 MB 到數百 MB 的吞吐,水平擴展靠 partition。
估算後一定要下結論:「峰值 5 萬 QPS 寫入,每筆 1KB,等於約 50MB/s、400Mbps,單機 MySQL 寫不進去,而且資料是 append-only 的事件,所以前面放 Kafka 吸收尖峰,後面用列式儲存。」算完不用、或算完跟設計脫節,等於沒算。
構建積木(Building Blocks)速查 高頻
系統設計就像用樂高組裝:大部分題目都是十幾個標準元件的組合。關鍵是知道每個元件解決什麼問題、代價是什麼、何時不該用。
| 元件 | 解決的問題 | 典型選擇 | 何時用 | 代價 / 注意 |
|---|---|---|---|---|
| 負載平衡(LB) | 把流量分散到多台無狀態服務、健康檢查、TLS 終止 | L4:AWS NLB、LVS;L7:ALB、Nginx、HAProxy、Envoy | 任何需要多台服務的情況 | L7 可依路徑路由但成本較高;長連線(WebSocket/gRPC)要注意負載不均 |
| CDN | 靜態資源與可快取內容就近回應 | CloudFront、Cloudflare、Akamai | SDK JS 檔、廣告素材(圖片/影片)、靜態頁 | 失效(invalidation)延遲;個人化內容難以快取 |
| Cache | 降低讀延遲與 DB 負載 | Redis、Memcached、本地 LRU | 讀多寫少、可接受短暫不一致 | 一致性、cache stampede、熱 key、記憶體成本 |
| 關聯式 DB | 交易、join、強一致、schema 約束 | MySQL、PostgreSQL、Aurora | 帳務、campaign 設定、使用者帳號 | 單機寫入上限;水平擴展需要 sharding |
| KV / 寬欄 NoSQL | 超高吞吐的 key 查詢、易水平擴展 | DynamoDB、Cassandra、ScyllaDB、HBase、Aerospike | user profile、frequency cap、ID mapping | 查詢模式受限、需依 access pattern 設計 key |
| 文件 DB | 彈性 schema 的文件存取 | MongoDB | 設定、對話紀錄、半結構化資料 | 跨文件交易與 join 能力較弱;shard key 選錯很難改 |
| 訊息佇列(MQ) | 非同步、削峰填谷、解耦、重播 | Kafka、Pulsar、RabbitMQ、SQS、Pub/Sub | 事件收集、推播發送、任何「不需要立即完成」的工作 | 端到端延遲增加、需處理重複與順序、維運成本 |
| 物件儲存 | 便宜、近乎無限的大檔與資料湖 | S3、GCS | raw event(Parquet)、素材、備份、上傳檔 | 單次延遲較高(數十 ms 級)、不適合小隨機讀寫 |
| 搜尋引擎 | 全文檢索、多欄位過濾、模糊查詢 | Elasticsearch、OpenSearch | 客服對話搜尋、log 搜尋、後台搜尋 | 不是 source of truth;索引延遲;叢集維運 |
| OLAP | 大量資料的快速聚合查詢 | ClickHouse、Druid、Pinot、BigQuery、Redshift | 報表、儀表板、ad-hoc 分析 | 不適合高頻單筆更新與交易 |
| 串流處理 | 即時聚合、視窗運算、event-time 處理 | Flink、Spark Structured Streaming、Kafka Streams | 即時報表、即時特徵、預算消耗 | 狀態管理、checkpoint、遲到資料處理複雜 |
| 批次處理 | 大量資料重算、回補 | Spark、Hive、MapReduce | 每日報表校正、identity merge、模型訓練資料 | 延遲以小時計 |
| 排程 / 工作流 | 定時任務與相依 DAG | Airflow、Argo Workflows、cron、Temporal | 每日批次、排程推播、backfill | 需處理重跑冪等、補跑、相依失敗 |
| 分散式協調 | leader election、設定、鎖 | ZooKeeper、etcd、Consul | Kafka/K8s 底層、少量關鍵鎖 | 不適合高吞吐資料存取 |
上圖是一個「萬用骨架」:同步的 request path 盡量短(LB → API → cache/DB),把能延後的工作丟進 MQ,由下游非同步處理。大部分 AdTech 系統都可以從這個骨架變形出來。
「什麼都放 Kafka + 微服務」。如果寫入量每秒只有幾十筆,MySQL + 一個 cron job 就夠了,引入 Kafka 只會增加維運負擔。面試官很喜歡問「為什麼這裡需要 Kafka?拿掉會怎樣?」,要能回答具體原因:削峰、解耦多個消費者、可重播、持久化緩衝。
可擴展性模式 進階
當估算顯示單機撐不住時,要知道有哪些「標準招式」可用,以及各自的副作用。
- 無狀態服務 + 水平擴展
- 把 session、狀態移到外部(Redis、DB、JWT),服務本身無狀態,就能在 LB 後面任意加機器、自動擴縮(K8s HPA)。這是第一個也最便宜的擴展手段。注意 in-process cache 會讓各實例資料不一致。
- 讀寫分離(Read replica)
- 寫入 primary、讀取 replica。適合讀多寫少。代價是複寫延遲(replication lag):剛寫入的資料在 replica 讀不到,需要 read-your-writes 策略(寫後短時間讀 primary、或依版本號判斷)。
- 分片(Sharding / Partitioning)
- 依 shard key 把資料拆到多個節點。方法:range(利於範圍查詢但易熱點)、hash(分布平均但範圍查詢困難)、一致性雜湊(節點增減只搬移部分資料)、目錄式(lookup table,最彈性)。難點:跨分片查詢/交易、熱點 key、重新分片(resharding)。
- 非同步化(Async)
- 同步路徑只做「必須立刻完成」的事(驗證、寫入佇列、回 202),其餘交給 worker。降低延遲、吸收尖峰,代價是最終一致與錯誤處理變複雜(重試、DLQ、狀態查詢 API)。
- 批次化(Batching)
- 把多筆小請求合併:SDK 批次上傳、DB bulk insert、Kafka producer 的
linger.ms/batch.size、Redis pipeline。用少量延遲換大幅吞吐提升,並減少每筆的 overhead(TCP、header、fsync)。 - 預計算(Precomputation)
- 把昂貴的計算移到寫入時或離線完成:報表 rollup、推薦結果預先算好放 KV、news feed fan-out on write。讀取變成 O(1) 查表,代價是儲存與「新鮮度」。
- CQRS(Command Query Responsibility Segregation)
- 寫入模型與讀取模型分開。例如寫入 OLTP DB 或事件流,再透過 CDC/串流建出專門給查詢用的 read model(ES、OLAP、KV)。適合讀寫模式差異大的系統(例如 campaign 設定寫入 MySQL,投放服務讀取記憶體索引)。代價是讀模型落後寫模型。
- Cache 分層
- 本地 cache(ns–µs)→ 分散式 cache(約 1ms)→ DB。熱點 key 用本地 cache 擋掉,搭配短 TTL 與主動失效。
- 背壓(Backpressure)與削峰
- 下游處理不了時,讓上游放慢(佇列長度上限、429、token bucket),而不是讓整個系統雪崩。
上圖是 CQRS 在廣告系統的典型應用:營運人員對 campaign 的修改寫入 MySQL(需要交易與約束),而每秒數十萬次的投放查詢讀的是記憶體中的倒排索引,兩者透過 CDC 事件同步,可接受數秒延遲。
AdTech 的讀寫極度不對稱:campaign 設定一天可能只改幾千次,但每秒要被讀數十萬次;事件則相反,每秒寫入數萬筆,但報表查詢一天只有幾千次。這正是 CQRS + 預計算的最佳場景:寫入端與讀取端各自用最適合的儲存。
可用性與一致性取捨 進階
可用性數字與停機時間
| 可用性 | 每年停機 | 每月停機(30 天) | 每天停機 |
|---|---|---|---|
| 99%(two nines) | 約 3.65 天 | 約 7.2 小時 | 約 14.4 分鐘 |
| 99.9%(three nines) | 約 8.76 小時 | 約 43.2 分鐘 | 約 1.44 分鐘 |
| 99.95% | 約 4.38 小時 | 約 21.6 分鐘 | 約 43 秒 |
| 99.99%(four nines) | 約 52.6 分鐘 | 約 4.3 分鐘 | 約 8.6 秒 |
| 99.999%(five nines) | 約 5.26 分鐘 | 約 26 秒 | 約 0.86 秒 |
串聯降低可用性:一個請求依序依賴 3 個各 99.9% 的服務,整體約 99.9%3 ≈ 99.7%。並聯(冗餘)提升可用性:兩個獨立 99% 的副本,同時掛掉機率 1%×1% = 0.01%,整體約 99.99%(前提是故障彼此獨立,同一 AZ 停電就不獨立)。
提升可用性的手段
- 冗餘(Redundancy):每層至少 N+1,消除單點故障(SPOF)。包括 LB、服務、DB、佇列、甚至 DNS。
- Failover:主備切換(active-passive)或多活(active-active)。DB failover 要考慮複寫延遲造成的資料遺失(RPO)與切換時間(RTO)。
- Multi-AZ / Multi-region:multi-AZ 處理機房級故障,延遲低(同區域內毫秒級),是預設;multi-region 處理區域級災難,但跨區延遲高、資料一致性難,通常只對關鍵服務做。
- 優雅降級(Graceful degradation):非核心功能失敗時回預設值。例如 CTR 模型服務超時 → 用 campaign 的歷史平均 CTR;推薦服務掛掉 → 回熱門商品。
- Timeout、重試、熔斷(circuit breaker)、隔艙(bulkhead):避免故障擴散。重試必須搭配指數退避 + jitter 與冪等性。
- 限流與 load shedding:過載時主動丟棄低優先請求,保住核心功能。
一致性取捨
CAP 定理說明在網路分區(P)發生時,只能在一致性(C)與可用性(A)之間選擇;PACELC 進一步指出「沒有分區時(E),要在延遲(L)與一致性(C)之間取捨」。面試中比較實用的是依資料類型分類:
| 資料類型 | 一致性需求 | 常見做法 |
|---|---|---|
| 帳務、預算總額、計費 | 強一致、可稽核 | RDBMS 交易、ledger(只增不改的帳本)、每日對帳 |
| 即時預算消耗、frequency cap | 近似即可,容許少量超支 | Redis 計數 + 定期與帳本校正 |
| 報表數字 | 最終一致;隔日精確 | 串流近即時 + 批次校正(Lambda 架構) |
| 使用者 profile | 最終一致,但需 read-your-writes(後台) | KV store、版本號、寫後讀 primary |
| 對話狀態 | 單一對話內需順序一致 | 依 conversation_id 分區、樂觀鎖 |
不要說「我要強一致」或「最終一致就好」一句帶過。要說清楚:哪一部分資料需要什麼程度的一致性、不一致的後果是什麼(超支 0.5% 預算可以接受嗎?重複發送推播會被客戶投訴嗎?)、以及你如何補償(對帳、校正、冪等)。
面試中的扣分行為與加分行為 高頻
扣分行為
- 沒問需求就開始畫圖,或問了需求卻沒寫下假設。
- 堆砌名詞(「用 K8s + Kafka + Flink + Cassandra」)卻講不出為什麼。
- 只畫方塊不講資料怎麼流動,沒有走一遍 read/write path。
- 忽略故障:完全沒提 timeout、重試、重複、資料遺失。
- 一個元件講太深(例如花 15 分鐘講 Raft),沒時間完成整體設計。
- 被挑戰就立刻放棄原設計,或相反地堅持不改。
- 數字明顯不合理(例如每秒 1 億筆寫入單機 MySQL)卻沒察覺。
- 沉默太久:思考時不說話,面試官無法給提示。
加分行為
- 主動宣告流程並控制時間。
- 用估算數字推導設計:「因為…所以選…」。
- 每個選擇都說出至少一個替代方案與取捨。
- 主動指出瓶頸與單點故障,並提出監控指標(QPS、p99、consumer lag、錯誤率)。
- 考慮營運面:部署、回滾、backfill、資料修正、成本。
- 從簡單版本開始,再演進到可擴展版本(「MVP 先這樣,10 倍流量時改成…」)。
- 把設計連結到業務:計費要精確、報表可近似、推播重複會傷品牌。
- 被追問時能說「我不確定,但我會這樣驗證」。
對實習/新鮮人職缺,面試官並不期待你有大規模系統的實戰經驗,而是看你的推理能力與基本功(DB index、cache、佇列、冪等性)。可以誠實說「我沒有實際操作過 Flink,但我理解它用 watermark 處理遲到資料,所以這裡我會…」,比硬掰更好。
情境題 1:SDK 回報大量 JSON 事件的統計報表系統 高頻 深入
題目(Appier 真題):「客戶的 App / 網站裝了我們的 SDK,SDK 會把使用者行為(開啟、瀏覽、加入購物車、購買、廣告曝光/點擊)以 JSON 回報給後端。請設計一個統計報表系統,讓客戶能查詢各種指標。」這是 AdTech/MarTech 最核心的資料管線,幾乎涵蓋了分散式系統的所有經典問題:高吞吐寫入、重複、遲到、近似計算、多租戶、重算。
需求釐清
功能需求
- SDK 上傳事件(批次),後端可靠收下。
- 客戶在 dashboard 查詢指標:事件數、unique users、轉換率、營收,依時間(小時/日)、事件名稱、國家、平台、campaign 等維度切分與篩選。
- 匯出報表(CSV),可能是大範圍查詢 → 非同步。
- 客戶可自訂事件名稱與屬性(自訂 schema)。
- 範圍外(先宣告):使用者層級的漏斗/留存分析、反作弊、即時個人化。
非功能需求(假設,請面試官確認)
- 每日 10 億事件,峰值為平均 4 倍。
- 收事件 API:高可用(99.95%+),p99 低於 200ms;寧可延遲也不能遺失。
- 報表新鮮度:近即時(5 分鐘內),隔日數字需精確(與計費相關的指標)。
- 查詢延遲:常見查詢 p95 在 1–2 秒內。
- unique users 可接受約 1% 誤差;事件數需精確(去重後)。
- raw 資料保留 1 年供重算;多租戶隔離。
估算
- 平均 QPS(事件):109 ÷ 86,400 ≈ 11,600/s;峰值約 46,000/s。
- SDK 每批平均 20 筆 → HTTP 請求峰值約 2,300/s,幾台到十幾台 collector 即可(保留餘裕與 AZ 冗餘)。
- 每筆 JSON 約 1 KB → 每日 1 TB raw;峰值 ingress 約 46 MB/s(約 370 Mbps);gzip 後傳輸量可降到數分之一。
- Kafka 保留 3 天、replication factor 3:約 9 TB(壓縮前),壓縮後再小數倍。
- S3 Parquet(列式 + 壓縮常見 5–10 倍):每日約 100–200 GB,一年約 40–70 TB,S3 成本可接受。
- 聚合表:假設 5,000 個租戶 × 50 種事件 × 24 小時 × 維度組合數百 → 每天數千萬列的小時級聚合,ClickHouse 輕鬆處理。
從估算得出三個結論:(1) 寫入是 append-only 高吞吐 → 用 Kafka + 列式儲存,不要用 MySQL 一筆一筆 insert;(2) 原始量(TB/天)不可能每次查詢都掃 → 需要預聚合;(3) collector 本身流量不大,瓶頸在下游處理與儲存,所以設計重點放在管線而不是 API 伺服器。
API 設計
POST /v1/events HTTP/1.1
Host: collect.example.com
X-App-Key: ak_live_xxx
Content-Type: application/json
Content-Encoding: gzip
{
"sdk_version": "3.2.1",
"device_id": "d-7f3a...",
"sent_at": 1762156800.120,
"events": [
{"event_id": "2b1e6c0e-...", "name": "add_to_cart", "client_ts": 1762156790.002,
"user_id": "u-123", "props": {"sku": "A-1", "price": 590, "currency": "TWD"}}
]
}
HTTP/1.1 202 Accepted
{"accepted": 1, "rejected": []}
# 413 Payload Too Large / 401 / 429 (帶 Retry-After) / 5xx:SDK 依碼決定是否重試
GET /v1/reports/query?app_id=42&metrics=events,unique_users,revenue
&dimensions=date,country&filters=event_name:purchase,platform:ios
&from=2026-10-01&to=2026-11-01&granularity=day&tz=Asia/Taipei
Authorization: Bearer <token>
200 OK
{"rows": [{"date": "2026-10-01", "country": "TW", "events": 18234, "unique_users": 9120, "revenue": 1203400}],
"meta": {"approximate": ["unique_users"], "data_freshness": "2026-11-03T13:05:00Z"}}
POST /v1/reports/exports → 202 {"job_id": "exp_91"} # 大查詢非同步匯出
GET /v1/reports/exports/exp_91 → {"status": "done", "url": "https://s3-presigned..."}
設計重點:事件 API 支援批次 + gzip;回應 202 Accepted 代表「已持久化到佇列」而非「已處理」;查詢 API 回傳 meta.approximate 告知哪些指標為近似值,以及資料新鮮度,讓前端顯示「資料更新至 13:05」。
資料模型
| 層 | 儲存 | Key / 分區 | 用途 |
|---|---|---|---|
| 佇列 | Kafka events.raw、events.clean、events.dlq | partition key = hash(app_id, device_id),同一裝置事件有序 | 削峰、解耦、可重播 |
| 原始資料湖 | S3 Parquet | s3://lake/events/dt=2026-11-03/hr=13/app_bucket=17/part-*.parquet | source of truth、重算、ad-hoc 分析(Athena/Spark) |
| 聚合 | ClickHouse(或 Druid/Pinot) | ORDER BY (app_id, hour, event_name, 維度...);PARTITION BY 日 | 報表查詢 |
| 中繼資料 | MySQL | apps、api_keys、event_schemas、report_jobs | 租戶設定、schema 定義、匯出工作狀態 |
| 查詢快取 | Redis | hash(tenant + 正規化後的查詢) | 熱門 dashboard 查詢 |
-- ClickHouse:小時級預聚合表(AggregatingMergeTree 會在背景合併相同 ORDER BY key 的列)
CREATE TABLE events_hourly
(
app_id UInt32,
hour DateTime,
event_name LowCardinality(String),
country LowCardinality(String),
platform LowCardinality(String),
cnt SimpleAggregateFunction(sum, UInt64),
revenue SimpleAggregateFunction(sum, Float64),
users AggregateFunction(uniq, String) -- 近似 distinct 的中間狀態,可跨小時合併
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMMDD(hour)
ORDER BY (app_id, hour, event_name, country, platform)
TTL hour + INTERVAL 400 DAY;
-- 由明細表(或 Kafka engine 表)自動寫入聚合表
CREATE MATERIALIZED VIEW mv_events_hourly TO events_hourly AS
SELECT app_id, toStartOfHour(event_time) AS hour, event_name, country, platform,
count() AS cnt, sum(revenue) AS revenue, uniqState(user_id) AS users
FROM events_detail
GROUP BY app_id, hour, event_name, country, platform;
-- 報表查詢:小時 rollup 到日;uniqMerge 合併 HLL 類狀態
SELECT toDate(hour, 'Asia/Taipei') AS day, country,
sum(cnt) AS events, uniqMerge(users) AS unique_users, sum(revenue) AS revenue
FROM events_hourly
WHERE app_id = 42 AND event_name = 'purchase'
AND hour >= '2026-10-01 00:00:00' AND hour < '2026-11-01 00:00:00'
GROUP BY day, country
ORDER BY day, country;
高層架構
Write path 走一遍:SDK 累積事件後 gzip 上傳 → LB → collector 驗證 app key、大小、配額,補上 server 時間,寫入 Kafka events.raw(等 ack 才回 202)→ validator 依租戶的 event schema 驗證、補 geo/IP 資訊,合格進 events.clean、不合格進 DLQ → Flink 依 event_id 去重、依 event time 做 1 分鐘/1 小時視窗聚合寫入 ClickHouse;同時 S3 sink 把明細寫成 Parquet → 每天 Spark 從 S3 重算前一天的精確數字覆蓋 ClickHouse 分區。
Read path:dashboard → Query API 驗證 token 並強制注入 tenant 過濾 → 查 Redis 快取 → miss 則把查詢轉成 ClickHouse SQL(只允許白名單維度與指標)→ 結果快取(TTL 依時間範圍:今天的資料 1 分鐘、歷史資料 1 小時)。
深入 1:SDK 端的 batching、壓縮與重試
SDK 是整條管線最不可控的一端(使用者網路差、App 被殺、時鐘不準),設計原則是「至少一次送達(at-least-once)+ 伺服器端去重」:
- Batching:達到 N 筆、或經過 T 秒、或 App 進入背景時 flush。減少請求數與電量消耗。
- 持久化佇列:事件先寫入本地 SQLite/檔案,送成功才刪除;App 被殺也不遺失。佇列有上限,滿了丟最舊或低優先事件。
- 壓縮:JSON 重複欄位多,gzip 常能壓到原本的數分之一,對行動網路很重要。
- 重試:只對網路錯誤、408、429、5xx 重試;指數退避 + full jitter;尊重
Retry-After;4xx(如 400 格式錯誤)不重試,避免毒藥訊息卡住佇列。 - event_id:在 client 端產生 UUID,重送時不變,讓伺服器端能去重。
- 時鐘偏移:同時送
client_ts(事件發生時間)與sent_at(送出時間),伺服器以server_received - sent_at估算偏移量校正 event time。 - 遠端設定:取樣率、flush 間隔可由伺服器下發,緊急時可降低流量(kill switch)。
import gzip, json, random, time, uuid
import requests
RETRYABLE = {408, 429, 500, 502, 503, 504}
class EventUploader:
"""簡化版 SDK 上傳器:真實 SDK 會在背景執行緒執行,並把 queue 存在磁碟。"""
def __init__(self, endpoint, app_key, max_batch=50, max_wait=10.0, max_queue=5_000):
self.endpoint, self.app_key = endpoint, app_key
self.max_batch, self.max_wait, self.max_queue = max_batch, max_wait, max_queue
self.queue = []
self.last_flush = time.time()
def track(self, name, user_id=None, **props):
if len(self.queue) >= self.max_queue:
self.queue.pop(0) # 背壓:丟最舊,避免無限成長
self.queue.append({"event_id": str(uuid.uuid4()), "name": name,
"client_ts": time.time(), "user_id": user_id, "props": props})
if len(self.queue) >= self.max_batch or time.time() - self.last_flush >= self.max_wait:
self.flush()
def flush(self):
batch = self.queue[: self.max_batch]
if not batch:
return
body = gzip.compress(json.dumps({"sent_at": time.time(), "events": batch}).encode())
headers = {"X-App-Key": self.app_key, "Content-Type": "application/json",
"Content-Encoding": "gzip"}
for attempt in range(6):
try:
resp = requests.post(self.endpoint, data=body, headers=headers, timeout=5)
status = resp.status_code
except requests.RequestException:
resp, status = None, None # 網路錯誤:可重試
if status in (200, 202):
break
if status is not None and status not in RETRYABLE:
break # 4xx:重試也不會成功,丟棄這批(並記錄)
retry_after = float(resp.headers.get("Retry-After", 0)) if status == 429 else 0
time.sleep(max(retry_after, random.uniform(0, min(60, 2 ** attempt)))) # full jitter
else:
return # 重試用盡:保留在 queue,下次再送
del self.queue[: len(batch)]
self.last_flush = time.time()
深入 2:無狀態 collector 與「何時回 202」
Collector 只做最輕量的事:認證(app key 查本地 cache)、大小與配額檢查、解壓、補 server 欄位、寫 Kafka。重要決策是等 Kafka ack 再回 202(acks=all + idempotent producer),這樣回 202 就代表資料已持久化在多個 broker;代價是延遲多幾毫秒。若 Kafka 短暫不可用,可以寫入本地磁碟 spool 檔、稍後補送,或直接回 503 讓 SDK 重試(SDK 本身有持久化佇列,所以回 503 是安全的)。
import gzip, json, time
from fastapi import FastAPI, Header, HTTPException, Request
from aiokafka import AIOKafkaProducer
app = FastAPI()
producer = None
MAX_BODY = 1_000_000 # 壓縮後上限
MAX_INFLATED = 10_000_000 # 解壓後上限,防 gzip bomb
MAX_EVENTS = 500
@app.on_event("startup")
async def startup():
global producer
producer = AIOKafkaProducer(bootstrap_servers="kafka:9092", acks="all",
enable_idempotence=True, linger_ms=5,
compression_type="lz4")
await producer.start()
@app.post("/v1/events", status_code=202)
async def ingest(request: Request, x_app_key: str = Header(...)):
app_id = await auth_cache.get_app_id(x_app_key) # 本地 LRU + Redis,避免每次打 DB
if app_id is None:
raise HTTPException(401)
if not await tenant_quota.allow(app_id): # 每租戶 token bucket
raise HTTPException(429, headers={"Retry-After": "30"})
raw = await request.body()
if len(raw) > MAX_BODY:
raise HTTPException(413)
if request.headers.get("content-encoding") == "gzip":
raw = safe_gunzip(raw, limit=MAX_INFLATED) # 超過上限就丟例外 → 413
payload = json.loads(raw)
received = time.time()
skew = received - float(payload.get("sent_at", received))
key = f"{app_id}:{payload.get('device_id', '')}".encode()
pending, rejected = [], []
for i, e in enumerate(payload.get("events", [])[:MAX_EVENTS]):
if not isinstance(e, dict) or "event_id" not in e or "name" not in e:
rejected.append(i) # 完整 schema 驗證留給下游
continue
e.update(app_id=app_id, server_ts=received,
event_ts=float(e.get("client_ts", received)) + skew)
pending.append(await producer.send("events.raw", json.dumps(e).encode(), key=key))
for fut in pending:
await fut # 全部 ack 後才回 202
return {"accepted": len(pending), "rejected": rejected}
在 collector 做太多事(查 DB 驗證每個欄位、即時聚合、寫多個儲存)。collector 一旦變慢,SDK 會大量重試,形成「重試風暴」。原則:收件服務越笨越好,所有重邏輯放到 Kafka 之後,因為 Kafka 之後的處理可以重播、可以慢慢追。
深入 3:Schema 驗證與 DLQ
- Schema registry:每個租戶的自訂事件在後台定義(欄位名、型別、必填),存在 MySQL 並快取到 validator。內部 topic 之間建議用 Avro/Protobuf + schema registry,比 JSON 省空間且有相容性檢查(backward/forward compatibility)。
- 驗證失敗不丟棄:送進
events.dlq,附上錯誤原因、原始 payload、schema 版本。提供 DLQ 儀表板讓客戶看到「你的 purchase 事件 price 欄位型別錯誤,佔 3%」。 - 可重播:修正 schema 或程式後,從 DLQ 重新送回
events.clean。因為下游有 event_id 去重,重播是安全的。 - 寬鬆 vs 嚴格:未知欄位保留在
props(JSON 欄位)而不是拒絕,避免 SDK 新版本上線時大量進 DLQ。
深入 4:串流聚合(Flink)vs 批次(Spark)
| 面向 | 串流(Flink) | 批次(Spark) |
|---|---|---|
| 新鮮度 | 秒~分鐘 | 小時~天 |
| 正確性 | 受遲到資料、故障重啟影響;需 checkpoint + 冪等/交易 sink | 資料完整後一次算,容易做到精確 |
| 重算 | 需重播 Kafka(受保留期限制) | 從 S3 任意日期重算 |
| 複雜度 | 狀態管理、watermark、背壓、升級時的狀態遷移 | 相對簡單,SQL 即可 |
| 成本 | 常駐叢集 | 按需執行 |
常見答案是兩者並用(Lambda 架構的變形):Flink 提供近即時的「暫定數字」,每天 Spark 從 S3 重算前一天並覆蓋 ClickHouse 的日分區,作為最終精確數字(計費依據)。另一派是 Kappa 架構:只有串流,重算時重播事件流;如果 Kafka 有長保留期(或 tiered storage)且 Flink 作業夠穩定,可以省去維護兩套邏輯的成本。面試中說出兩者取捨即可,維護兩套邏輯可能導致數字不一致是 Lambda 的主要缺點,可以用同一套 SQL(Flink SQL / Spark SQL)或共用函式庫緩解。
深入 5:去重、遲到事件與 watermark
重複從哪來:SDK 重送、collector 寫 Kafka 成功但回應前崩潰、consumer 處理後提交 offset 前崩潰(at-least-once)。所以去重必須在下游做,依據是 client 產生的 event_id:
- 串流:Flink 以 event_id 為 key 的 keyed state(RocksDB backend)+ TTL(例如 24 小時),見過就丟。狀態大小 ≈ 每日事件數 × key 大小,10 億 × 約 40 bytes ≈ 數十 GB,分散在各 TaskManager 可行。若想省空間可用 Bloom filter(有少量誤判把新事件當重複)。
- 批次:Spark 對當日分區做
dropDuplicates(["event_id"]),精確。 - 跨很長時間的重複(裝置離線三天後重送)由批次重算處理。
遲到事件:手機離線時事件會晚到數小時甚至數天。報表要以 event time(事件發生時間)而非 processing time 分桶,否則「昨天的購買」會被算到今天。Flink 用 watermark 表示「我認為 event time 早於 W 的事件已經到齊」;視窗在 watermark 越過視窗結束時間時輸出。可設定 allowed lateness 讓稍晚的事件更新已輸出的結果(sink 需支援 upsert),更晚的事件走 side output 寫到 S3,由每日批次校正。
from collections import defaultdict
WINDOW = 60 # 1 分鐘 tumbling window(秒)
MAX_DELAY = 300 # watermark = 目前看過最大 event time - 5 分鐘
class WindowCounter:
"""示意 Flink 的 event-time 視窗 + watermark + 去重(單機版)。"""
def __init__(self, sink):
self.counts = defaultdict(int) # (app_id, event_name, window_start) -> count
self.seen = {} # event_id -> 過期時間;實務用帶 TTL 的 keyed state
self.max_ts = 0.0
self.sink = sink
def on_event(self, e):
if e["event_id"] in self.seen:
return # 重複事件
self.seen[e["event_id"]] = e["event_ts"] + 86_400
self.max_ts = max(self.max_ts, e["event_ts"])
watermark = self.max_ts - MAX_DELAY
start = int(e["event_ts"]) // WINDOW * WINDOW
if start + WINDOW <= watermark:
self.sink.late(e) # 視窗已關閉:side output,交給批次校正
return
self.counts[(e["app_id"], e["name"], start)] += 1
self.fire(watermark)
def fire(self, watermark):
ready = [k for k in self.counts if k[2] + WINDOW <= watermark]
for key in ready:
self.sink.emit(key, self.counts.pop(key)) # sink 以 key upsert,重複輸出也冪等
watermark 是「全域」推進的,若某個 Kafka partition 沒有資料(idle),會拖住整體 watermark;Flink 有 idleness 設定處理這種情況。另外,若只看單一裝置的時間,一台時鐘錯亂(例如 2099 年)的手機會把 watermark 推到未來,導致所有正常事件都被當成遲到,所以要先過濾明顯不合理的時間戳(例如超過伺服器時間 + 1 天)。
深入 6:Exactly-once 的實際意義
端到端的 exactly-once 實際上是「at-least-once 傳遞 + 冪等/交易性處理」。Flink 透過 checkpoint 記錄 Kafka offset 與運算狀態,故障時從 checkpoint 恢復並重播;sink 端要嘛支援交易(Kafka transactional producer 的 two-phase commit),要嘛是冪等寫入(以 (app_id, event_name, window_start) 為 key upsert,重寫同樣的值不影響結果)。ClickHouse 這類 OLAP 的 insert 並非傳統交易,常見做法是:串流寫入「可覆蓋」的近即時表,最終數字由批次覆寫分區保證正確。
深入 7:預聚合 cube 與 rollup
若有 d 個維度,所有 group by 組合有 2d 種(cuboid)。10 個維度就是 1,024 種組合,而且高基數維度(如 user_id、sku)會讓聚合後的列數接近原始資料,失去預聚合的意義。實務策略:
- 只預算常用組合:依 dashboard 實際查詢日誌決定要建哪些 cuboid(例如「日 × 國家 × 平台」「小時 × campaign」)。
- 時間 rollup:分鐘級保留 7 天、小時級保留 90 天、日級保留數年,用 TTL 自動清除;查詢依時間範圍自動選表。
- 高基數維度不進 cube:需要時走明細表(ClickHouse 明細表本身就能在數秒內掃描數十億列)或非同步匯出。
- Druid 的 ingestion-time rollup:在寫入時就依時間粒度與維度合併,大幅降低儲存。代價是失去明細。
深入 8:精確 vs 近似(HyperLogLog)
unique users 是報表最貴的指標:count 可以直接相加,但「10/1 的 UU + 10/2 的 UU ≠ 兩天的 UU」,因為同一人可能兩天都來。精確計算需要保存完整的 user 集合。
| 方法 | 空間 | 誤差 | 可合併 | 適用 |
|---|---|---|---|---|
| Set / 精確 distinct | 與 unique 數成正比 | 0 | 可(集合聯集,但很貴) | 計費、小規模 |
| Roaring Bitmap | 壓縮後通常很省,需整數 ID | 0 | 可(OR)、也可做交集 | 受眾交集、精確 UU,需把 user 映射成整數 |
| HyperLogLog | 固定大小(Redis 的 HLL 為 12 KB) | Redis 實作標準誤差約 0.81% | 可(PFMERGE),不可相減 | dashboard UU、DAU/MAU |
import redis
r = redis.Redis()
# 每個 (app, 日期) 一個 HLL;每個 key 最多約 12KB,與 unique 數無關
def record(app_id: int, day: str, user_id: str):
r.pfadd(f"uu:{app_id}:{day}", user_id)
def unique_users(app_id: int, days: list):
keys = [f"uu:{app_id}:{d}" for d in days]
return r.pfcount(*keys) # 多個 key 時回傳「聯集」的估計值,等同合併後再數
主動說明「HLL 可以 merge(聯集)但不能 subtract,也不能可靠地算交集」,所以「這週新使用者 = 這週 UU − 上週也出現的人」這類指標不能直接用 HLL 做,需要 bitmap 或批次精確計算。並且在 API 回應中標示近似值,跟產品端講好誤差範圍。
深入 9:多租戶隔離
- 資料隔離:每一層都帶
app_id/tenant_id;查詢 API 由伺服器端從 token 推出 tenant 並強制加入 WHERE 條件,絕不信任 client 傳來的 tenant 參數。ClickHouse 可加 row policy 作為第二道防線。 - Noisy neighbor:每租戶的寫入配額(token bucket)與查詢併發上限;超大客戶可給專屬 Kafka topic/partition 或專屬 ClickHouse 叢集;查詢設
max_execution_time、max_memory_usage防止一個大查詢拖垮叢集。 - 熱點:如果 partition key 只用 app_id,大客戶會讓單一 partition 過熱,所以用 hash(app_id, device_id)。
- 隱私:PII(email、電話)在 collector 或 validator 端雜湊化;支援依 user 刪除(GDPR),S3 上的 Parquet 需定期重寫或以加密金鑰銷毀(crypto-shredding)處理。
深入 10:回補(Backfill)與重算
- 觸發情境:聚合邏輯有 bug、新增指標要算歷史資料、DLQ 修復後重送、遲到事件校正。
- Spark 從 S3 讀指定日期分區的 raw Parquet,套用新版邏輯,產出聚合結果到暫存表。
- 驗證(列數、總和與舊版差異在預期內)後,以分區為單位原子替換(ClickHouse
ALTER TABLE ... REPLACE PARTITION),查詢端不會看到半套資料。 - 工作必須冪等:同一日期重跑多次結果相同;由 Airflow 管理,可平行跑多天但限制併發,避免擠壓線上查詢資源。
- 清除受影響範圍的查詢快取。
瓶頸與取捨總結
| 潛在瓶頸 | 症狀 | 對策 |
|---|---|---|
| 流量尖峰(活動、推播後) | collector CPU 滿、SDK 重試風暴 | HPA 自動擴展、Kafka 吸收、429 + Retry-After、遠端調降取樣 |
| Kafka consumer lag | 報表延遲變長 | 增加 partition 與 consumer 平行度;監控 lag 告警 |
| Flink 狀態過大 | checkpoint 變慢、背壓 | 去重 TTL、RocksDB incremental checkpoint、Bloom filter |
| ClickHouse 小量頻繁寫入 | parts 過多、合併壓力(too many parts) | 批次寫入(每批數萬列、每秒少量 insert)、async insert 或 Buffer |
| 大範圍查詢 | 查詢逾時、影響其他租戶 | 選擇 rollup 表、查詢限制、改為非同步匯出 |
面試官可能的追問
如果 Kafka 整個叢集掛掉,事件會遺失嗎?
不應遺失。collector 只在 Kafka ack 後回 202;Kafka 不可用時回 503,SDK 的持久化佇列會保留事件並退避重試。若希望 collector 自己吸收,可寫本地磁碟 spool(需要處理 pod 被刪除時磁碟遺失的問題,K8s 上要用持久磁碟或接受此風險)。此外 Kafka 本身應跨 AZ 部署、replication.factor=3、min.insync.replicas=2,單一 broker 或單一 AZ 故障不影響寫入。
為什麼不直接把事件寫進 ClickHouse,而要經過 Kafka?
(1) 削峰:ClickHouse 喜歡大批次寫入,Kafka 讓下游以自己的節奏批次消費;(2) 解耦多個消費者:同一份事件要進 S3、Flink、即時特徵、反作弊,各自獨立消費、各自 offset;(3) 可重播:下游 bug 修正後可以從某個 offset 重新處理;(4) ClickHouse 維護或升級時,資料先堆在 Kafka 不會遺失。代價是多一個要維運的系統與數秒的延遲,對報表情境可接受。
同一個使用者的事件要保證順序嗎?
報表聚合不需要(加總與順序無關)。但如果下游有 session 化或漏斗分析,就需要同一裝置的事件依序處理:partition key 用 device_id 保證同裝置進同一 partition,且以 event time 排序(因為 SDK 重送可能讓到達順序錯亂),在視窗內依時間戳重排即可。
客戶要求「任意維度組合 + 即時」的查詢,怎麼辦?
先釐清真實需求:通常是少數常用組合 + 偶爾的探索。常用組合走預聚合;探索查詢走 ClickHouse 明細表(列式 + 稀疏索引 + 向量化執行,掃描數十億列仍可在數秒內完成),並設定資源限制;超大查詢走非同步匯出。若預算允許,可以對明細資料取樣(例如 10% 取樣表)提供快速近似結果,並在 UI 標示。
如何監控這條管線的健康?
四類指標:(1) 入口:collector QPS、錯誤碼分布、p99 延遲、每租戶流量;(2) 管線:Kafka consumer lag(最重要,代表報表延遲)、DLQ 比例、Flink checkpoint 時間與背壓;(3) 資料品質:每小時事件數與上週同時段比較(異常下降可能代表 SDK 壞了)、遲到事件比例、重複率;(4) 查詢:Query API p95、ClickHouse 查詢時間、快取命中率。並對「資料新鮮度」做 SLO:例如 99% 的時間報表延遲在 10 分鐘內。
如果要把成本降一半,你會從哪裡下手?
(1) Kafka 與內部傳輸改用二進位格式(Avro/Protobuf)+ 壓縮;(2) S3 用 lifecycle 把舊資料轉到較便宜的儲存層級;(3) 細粒度聚合縮短保留期;(4) 低價值的高頻事件(如 scroll)在 SDK 端取樣或只送聚合後的計數;(5) 批次作業用 spot instance;(6) 檢查 dashboard 查詢日誌,刪掉沒人用的 cuboid。
情境題 2:設計廣告投放系統(Ad Serving / RTB Bidder) 高頻 深入
題目(Appier 真題):「設計一個廣告投放系統。」範圍非常大,第一步一定要縮小:是自家網站/App 的廣告伺服器(ad server),還是在廣告交易平台(ad exchange)上即時競價的 DSP bidder?以程式化廣告平台常見情境,建議主動選擇 DSP 的 RTB bidder + campaign 管理 + 追蹤計費,因為它最能展現低延遲與分散式的設計能力。更多 AdTech 名詞(OpenRTB、first/second price、bid shading)請參考 第 14 章。
需求釐清
功能需求
- 接收 ad exchange 的 bid request(OpenRTB),決定是否出價、出多少、用哪個素材。
- 廣告主建立 campaign:預算(每日/總額)、出價策略、目標受眾(地區、裝置、受眾分群、時段)、素材、頻次上限。
- 追蹤勝標(win notice)、曝光、點擊、轉換;依此扣預算與計費。
- 報表:曝光、點擊、花費、CTR、CPA。
非功能需求(假設)
- 峰值 50 萬 bid requests/s,多個 region。
- exchange 給的回應時限約 100ms(含網路),bidder 內部處理 p99 需壓在數十 ms。
- 高可用:bidder 故障時應回「不出價」而不是逾時。
- 預算:可容忍極小比例超支(例如 1% 以內),但計費必須精確可稽核。
- Campaign 設定修改後,數秒到一分鐘內生效即可。
估算
- 流量:峰值 50 萬 QPS;假設單台 bidder 可處理約 5,000 QPS(含模型推論),需約 100 台以上,再加 30–50% 餘裕並分散到多個 region/AZ。
- 頻寬:OpenRTB JSON 約 1–2 KB → 峰值約 0.5–1 GB/s 入流量(4–8 Gbps),回應只有出價時才有內容。
- Campaign 數:數萬個 active line items,加上 targeting 規則,記憶體中數百 MB 以內 → 每台 bidder 可以放完整索引。
- 使用者 profile:數億到十億個 user/device ID,每筆數百 bytes 到 1 KB → 數百 GB 到 TB,需要分散式低延遲 KV(Aerospike、Redis Cluster、ScyllaDB 等),單次查詢需在約 1–5ms 內。
- 事件:若勝率約 10%,曝光約 5 萬/s;點擊率以 0.1–1% 計約數十到數百/s。bid log 全量可能每天數十 TB,通常取樣或只保留精簡欄位。
API 設計
# 1) Exchange → Bidder(OpenRTB,簡化)
POST /rtb/v1/bid
{"id": "req-1", "tmax": 100,
"imp": [{"id": "1", "banner": {"w": 300, "h": 250}, "bidfloor": 0.5}],
"app": {"bundle": "com.example.news"}, "device": {"ifa": "abcd-...", "geo": {"country": "TWN"}, "os": "iOS"},
"user": {"id": "exch-user-9"}}
200 OK → {"id": "req-1", "seatbid": [{"bid": [{"impid": "1", "price": 1.8, "crid": "cr-77",
"adm": "...", "nurl": "https://win.example.com/w?b=...&p=${AUCTION_PRICE}"}]}]}
204 No Content → 不出價(必須在 tmax 內回,否則等同不出價且可能被降流量)
# 2) 追蹤
GET /w?b=<signed_bid_token>&p=<encrypted_price> win notice(exchange 回呼,帶清算價)
GET /i?t=<signed_token> impression pixel(回 1x1 或 204)
GET /c?t=<signed_token> click → 302 轉址到廣告主落地頁
# 3) Campaign 管理(給廣告主 / 營運)
POST /v1/campaigns 建立
PATCH /v1/campaigns/{id} 修改預算、狀態、targeting(需樂觀鎖 version)
GET /v1/campaigns/{id}/stats?from=&to=
追蹤 URL 中的 token 由 bidder 在出價時簽章(HMAC),內含 bid_id、campaign_id、creative_id、時間戳等,tracker 驗章後即可得知所有資訊,不必查 DB,也防止偽造;token 帶過期時間以防重放。
資料模型
| 資料 | 儲存 | 存取模式 |
|---|---|---|
| advertisers、campaigns、line_items、creatives、targeting 規則 | MySQL(source of truth) | 低頻寫、需交易與約束;透過 CDC 推送到 bidder |
| Targeting 倒排索引 | 每台 bidder 的記憶體 | 每次 bid 查詢;定期快照 + 增量更新 |
| User profile(segments、近期行為) | 分散式 KV(記憶體/SSD) | 以 user/device id 點查,1–5ms |
| Frequency cap 計數 | Redis Cluster(Hash,每 user 一個 key) | bid 時讀、曝光時 +1,帶 TTL |
| 預算剩餘 / 花費 | Budget service(Redis + MySQL ledger) | bidder 申請額度租約;花費由事件流累加 |
| Bid / win / imp / click 事件 | Kafka → S3 + OLAP | 計費、報表、模型訓練 |
高層架構
深入 1:100ms 延遲預算怎麼分
| 階段 | 示意預算 | 手段 |
|---|---|---|
| Exchange ↔ Bidder 網路來回 | 20–40 ms | 部署在 exchange 附近的 region、長連線 keep-alive、HTTP/2 |
| 解析 + 前置過濾 | 約 1 ms | 高效 JSON parser、提早淘汰(不支援的尺寸、黑名單 app) |
| User profile + 頻次查詢 | 2–5 ms | 平行發出、設嚴格 timeout(超時當作無資料) |
| 候選召回(倒排索引) | 約 1 ms | 記憶體索引、bitmap 交集 |
| 模型推論 | 5–10 ms | in-process 模型或同機 sidecar、批次推論候選、限制候選數 |
| 出價計算 + 組回應 | 約 1 ms | — |
| 安全餘裕 | 剩餘 | GC、排隊、尾延遲 |
關鍵原則:(1) 整體有一個 deadline,每個下游呼叫用「剩餘時間」當 timeout,時間快到就直接回 204;(2) 網路呼叫越少越好,能在記憶體做的就不要遠端查;(3) 注意尾延遲:一次請求平行呼叫 5 個服務,任一個慢就整體慢,所以要用 hedged request 或嚴格 timeout;(4) 語言 runtime 的 GC 停頓會吃掉預算,Go/Java 要減少配置、物件池化。
以 DSP 常見做法為例,每次 bid 都在算「這個曝光對這個廣告主值多少錢」,ML 模型的品質直接決定營收,但模型越複雜推論越慢。這是 ML 與後端工程的經典拉扯:後端工程師的工作是讓模型在毫秒級延遲內服務數十萬 QPS(模型量化、特徵快取、批次推論、候選數限制),這也是職缺描述中「profiling 與效能調校關鍵元件」的具體場景。
深入 2:Targeting 倒排索引
暴力法是對每個 bid request 掃過所有 campaign 檢查 targeting 條件:數萬個 campaign × 數十萬 QPS 太慢。倒排索引把「屬性值 → 符合的 campaign 集合」預先建好,查詢時只做集合交集:
from collections import defaultdict
DIMENSIONS = ["country", "os", "ad_size"] # 單值維度:請求只有一個值
class TargetingIndex:
def __init__(self, campaigns):
self.by_value = defaultdict(set) # (dim, value) -> {campaign_id}
self.unrestricted = defaultdict(set) # dim -> 該維度沒設限制的 campaign
self.by_segment = defaultdict(set) # segment -> {campaign_id}
self.no_segment = set()
self.all_ids = set()
for c in campaigns:
cid, t = c["id"], c["targeting"]
self.all_ids.add(cid)
for dim in DIMENSIONS:
values = t.get(dim)
if values:
for v in values:
self.by_value[(dim, v)].add(cid)
else:
self.unrestricted[dim].add(cid)
if t.get("segments"): # 多值維度:命中任一 segment 即可(OR)
for s in t["segments"]:
self.by_segment[s].add(cid)
else:
self.no_segment.add(cid)
def match(self, req, user_segments):
result = set(self.all_ids)
for dim in DIMENSIONS: # 各維度之間是 AND
result &= self.by_value.get((dim, req.get(dim)), set()) | self.unrestricted[dim]
if not result:
return result # 提早結束
seg_hits = set(self.no_segment)
for s in user_segments:
seg_hits |= self.by_segment.get(s, set())
return result & seg_hits
idx = TargetingIndex([
{"id": 1, "targeting": {"country": ["TW", "JP"], "os": ["iOS"], "segments": ["sports"]}},
{"id": 2, "targeting": {"country": ["TW"]}},
])
print(idx.match({"country": "TW", "os": "iOS", "ad_size": "300x250"}, ["sports", "travel"])) # {1, 2}
- 生產級做法:集合改用 Roaring bitmap(campaign 編成連續整數),交集是位元運算;排除條件(NOT)用差集;複雜布林表達式可參考 Yahoo 的「Indexing Boolean Expressions」論文中的 conjunction index。
- 更新:Index builder 從 MySQL CDC 收到變更,定期產生完整快照(例如每幾分鐘)並推送增量。bidder 用 double buffering:在背景建好新索引,再以原子指標交換(atomic pointer swap),讀取端完全不加鎖。
- 預算與狀態:預算用完、暫停的 campaign 要能「秒級」從候選移除,這部分不走完整重建,而是由 pacing 模組的本地旗標過濾。
深入 3:CTR 預估服務與出價
- 出價公式(以 CPC 計費的 campaign 為例):eCPM = pCTR × 廣告主願付 CPC × 1000;CPA 目標則是 pCTR × pCVR × target CPA × 1000。最後再依拍賣機制(first price 需 bid shading)調整。
- 特徵:使用者(segments、近期行為)、情境(app、時段、裝置、地區)、廣告(campaign、素材歷史表現)與交叉特徵。線上特徵需要與訓練時一致(training-serving skew 是常見 bug 來源),所以用 feature store 統一定義。
- 部署形式:in-process(最低延遲,但模型更新要重載、語言綁定)vs 獨立模型服務(gRPC,便於 GPU 與獨立擴展,但多一跳網路)。一個 request 有多個候選時,一次批次推論所有候選。
- 降級:模型服務超時 → 使用 campaign 的歷史平均 CTR 或保守出價;新 campaign 冷啟動用同類平均值 + 探索(exploration)流量。
- 上線:shadow mode(只算不用)比對 → 小流量 A/B → 逐步放量;持續監控預估 CTR 與實際 CTR 的校準(calibration)。
深入 4:Budget pacing 與分散式預算扣減
問題:同一個 campaign 同時在上百台 bidder、多個 region 被出價。如果每次出價都去中央 DB 檢查並扣款,中央節點會成為瓶頸且增加延遲;如果完全不協調,預算會嚴重超支。另外「出價」不等於「花費」:要等勝標(win notice)才知道實際花費,中間有時間差。
- 預算租約(budget lease):Budget service 把 campaign 剩餘預算切成小額度發給各 bidder(例如每次申請「約 1 分鐘的預估花費」或剩餘預算的一小比例)。bidder 在本地扣減,用完再申請;額度愈接近用完,發得愈小。最大超支 ≈ 所有 bidder 手上未結清額度 + 在途出價。
- 真實花費:win notice 與曝光事件 → Kafka → Flink 依 campaign 累加 → 寫回 budget service 與 MySQL ledger。這是計費的 source of truth。
- Pacing:預算不能在早上 9 點就花完(錯過晚間黃金時段)。依流量預測曲線算出「到現在為止應該花多少」,每分鐘比較實際花費,調整出價機率(throttling)或出價倍率。
- 預算耗盡:budget service 廣播停止訊號(pub/sub),bidder 立刻把 campaign 標記為不可出價;租約到期也會自然停止。
import redis
r = redis.Redis()
# 原子地從剩餘預算中切出一塊額度(單位:micro 元,用整數避免浮點誤差)
LEASE_LUA = r.register_script("""
local remaining = tonumber(redis.call('GET', KEYS[1]) or '0')
if remaining <= 0 then return 0 end
local grant = math.min(remaining, tonumber(ARGV[1]))
redis.call('DECRBY', KEYS[1], grant)
return grant
""")
class LocalBudget:
"""每台 bidder 對每個 campaign 持有的本地額度。"""
def __init__(self, campaign_id, chunk_micros):
self.key = f"budget:{{{campaign_id}}}:remaining" # {} 為 Redis Cluster hash tag
self.chunk = chunk_micros
self.balance = 0
def try_spend(self, bid_micros):
if self.balance < bid_micros:
self.balance += LEASE_LUA(keys=[self.key], args=[self.chunk]) # 實務上在背景預先補充
if self.balance < bid_micros:
return False # 預算不足,不出價
self.balance -= bid_micros # 以出價保守預扣;勝標後依清算價結算差額
return True
class Pacer:
"""依計畫花費曲線調整出價機率(簡化的比例控制)。"""
def __init__(self, daily_budget, planned_fraction_by_minute, k=0.5):
self.budget, self.plan, self.k = daily_budget, planned_fraction_by_minute, k
self.p = 0.5
def update(self, minute_of_day, spent_so_far):
planned = self.budget * self.plan[minute_of_day] # 累積計畫花費
error = (planned - spent_so_far) / self.budget # 正:花太慢;負:花太快
self.p = min(1.0, max(0.01, self.p + self.k * error * 10))
return self.p # bidder 以機率 p 參與競價
直接在 bid 路徑上對 Redis 做 DECRBY 扣預算:(1) 每次出價都多一次網路呼叫;(2) 大部分出價不會勝標,扣了還要退;(3) 熱門 campaign 的 key 成為熱點。面試官想看到你意識到「出價 ≠ 花費」以及「中央計數器是瓶頸」,然後提出本地額度 + 非同步結算的方案,並說出超支上限如何控制。
深入 5:Frequency capping(Redis)
「同一使用者每天最多看到這個 campaign 3 次」。做法:每個使用者一個 Redis Hash,field 為 campaign:日期,bid 時一次 HGETALL/HMGET 取回所有計數並過濾候選;曝光時(tracker 收到 impression)才 HINCRBY。
FC_TTL = 7 * 86_400
def frequency_ok(r, user_id, candidates, caps, day):
fields = [f"{cid}:{day}" for cid in candidates]
counts = r.hmget(f"fc:{user_id}", fields) # 一次網路往返
return [cid for cid, n in zip(candidates, counts)
if int(n or 0) < caps[cid]]
def on_impression(r, user_id, campaign_id, day):
pipe = r.pipeline()
pipe.hincrby(f"fc:{user_id}", f"{campaign_id}:{day}", 1)
pipe.expire(f"fc:{user_id}", FC_TTL) # 不活躍使用者自動清除
pipe.execute()
- 近似可接受:bid 時讀、曝光時寫,中間有時間差,同一使用者在幾百毫秒內被多個 exchange 請求時可能超過上限 1 次,業界通常接受。
- 省一次呼叫:若 user profile KV 也能存計數,與 profile 一起讀取,減少網路往返。
- Redis 故障:fail-open(當作沒看過,可能過度曝光)或 fail-closed(不出價,損失營收)。通常對有嚴格頻次要求的 campaign fail-closed,其餘 fail-open,並告警。
- 多 region:使用者通常固定在某區域,各 region 獨立計數即可,跨區只做非同步同步。
深入 6:曝光/點擊追蹤與計費
- Tracker:高 QPS、極簡(驗 token → 寫 Kafka → 回 204/302)。點擊轉址的延遲直接影響使用者體驗與廣告主轉換率,必須快。
- 去重:同一 bid_id 的 win notice / impression 可能重複(重試、瀏覽器重整),計費前以 bid_id/impression_id 去重(Flink keyed state 或 DB unique key)。
- 計費 ledger:只增不改(append-only)的帳本記錄每筆花費(campaign、金額、清算價、時間、來源事件 id),金額用整數 micros 或 Decimal,不用浮點數。
- 對帳:每天把自己的勝標紀錄與 exchange 提供的報告比對,差異超過門檻要告警與人工處理。
- 反作弊:異常點擊率、同 IP 大量點擊、資料中心 IP、點擊時間過短等規則與模型(可另開題目)。
瓶頸與取捨
| 取捨 | 選項 A | 選項 B | 建議 |
|---|---|---|---|
| 模型部署 | in-process,最低延遲 | 獨立服務,獨立擴展與更新 | 延遲極敏感時 in-process;需要 GPU 或大型模型時獨立服務 + 同機房 |
| 預算一致性 | 中央強一致扣款 | 本地額度、最終一致 | 本地額度,超支上限可控 |
| 頻次計數故障 | fail-open | fail-closed | 依 campaign 設定,預設 fail-open + 告警 |
| Bid log | 全量保存 | 取樣保存 | 勝標全量、未勝標取樣(訓練需要負樣本) |
| 索引更新 | 每次變更即時更新 | 快照 + 增量 | 快照 + 增量 + 本地暫停旗標 |
面試官可能的追問
Bidder 實例要不要有狀態?如何做水平擴展?
Bidder 的「狀態」只有可重建的快取(索引快照、模型、本地預算額度),沒有不可遺失的資料,所以視為無狀態:任意擴縮,掛掉的實例手上的預算額度只是暫時鎖住,租約到期後由 budget service 收回。擴展依 CPU 與 p99 延遲做 HPA;新實例啟動時要先載入索引與模型(warm-up)再加入 LB,否則前幾秒會大量逾時。
多 region 下,同一個 campaign 的預算怎麼分?
以流量比例預先把每日預算分配到各 region(例如依過去流量),各 region 內部再用租約機制;定期(例如每 5–15 分鐘)依實際消耗重新平衡。避免每次扣款都跨 region 同步,因為跨區 RTT 動輒數十到上百 ms,不可能放在 bid 路徑上。
如果 exchange 突然把流量加倍,你的系統會怎樣?
先保護自己:bidder 有並發上限與佇列上限,超過就立即回 204(load shedding),寧可少出價也不要全體逾時(逾時率高 exchange 可能會降低送來的流量)。同時 HPA 擴容;也可以在 LB 層依 exchange、app 品質做取樣,丟掉價值最低的流量(「流量篩選」)。事後檢討容量規劃。
怎麼避免重複計費?
每次出價產生唯一 bid_id 並簽在 nurl/追蹤 token 裡。計費管線以 bid_id 去重(Flink keyed state + DB unique constraint 雙保險),ledger 寫入以 bid_id 為冪等鍵(INSERT ... ON DUPLICATE KEY 不重複累加)。另外 token 有時效,過期的 win notice 進入人工審查而非直接計費。
Campaign 剛建立沒有任何歷史資料,CTR 怎麼估?
冷啟動:用同廣告主、同類別、同素材尺寸的平均 CTR 作為先驗;保留一小部分探索流量(explore/exploit,例如 epsilon-greedy 或 Thompson sampling)收集資料;隨資料累積用貝氏平滑把先驗與觀測結合,避免少量點擊造成估計劇烈波動。
Go 或 Java 寫 bidder,GC 會不會破壞延遲?
會影響尾延遲。手段:減少每請求的物件配置(物件池 sync.Pool、預配置 buffer、避免反射式 JSON)、調整 GC 參數(Go 的 GOGC/GOMEMLIMIT、Java 的 G1/ZGC)、把大型唯讀資料(索引)放在少指標的結構中以減少 GC 掃描成本。用 pprof/async-profiler 找出配置熱點,這正是職缺強調的 profiling 能力。
情境題 3:設計 Chatbot 平台 高頻 深入
題目(Appier 真題):「設計一個 chatbot。」以 MarTech 平台常見情境,建議把它定義成多租戶 chatbot 平台:品牌客戶在後台設定對話流程與知識庫,終端使用者透過 LINE、Facebook Messenger、網站聊天視窗與機器人對話,必要時轉真人客服。核心難點:多渠道接入、對話順序、狀態管理、LLM 的延遲與成本、人工轉接。
需求釐清
功能需求
- 接入 LINE、Messenger(webhook)與 Web(WebSocket)。
- Bot 回覆:規則/流程(按鈕選單)、意圖辨識(NLU)、LLM 生成回答(以品牌知識庫做 RAG)。
- 對話狀態:多輪對話上下文、填槽(slot filling,例如詢問訂單編號)。
- 真人客服轉接:客服後台即時收發訊息、排隊與分派。
- 對話紀錄查詢、基本分析(解決率、轉人工率)。
- 範圍外:大量主動推播(見情境題 4)、語音。
非功能需求(假設)
- 1,000 個租戶,每日 1,000 萬則使用者訊息,行銷活動時峰值為平均 10 倍。
- Webhook 必須快速回 200(渠道平台對 webhook 回應時間有限制,逾時會重送或停用)。
- Bot 回覆 p95:規則類 1 秒內;LLM 類數秒內,並有「處理中」提示。
- 同一對話內訊息順序正確、不重複回覆。
- LLM 成本可控:每租戶有配額。
估算
- 入站訊息:107 ÷ 86,400 ≈ 116/s,峰值約 1,200/s。對 API 層不算大,瓶頸在 LLM。
- 假設 30% 訊息需要 LLM,峰值約 360 次/s;每次 2–5 秒 → 依 Little's Law(並發數 = 到達率 × 平均處理時間),同時在途請求約 700–1,800 個 → worker 必須是非同步 IO(asyncio / goroutine),不能每個請求佔一條執行緒阻塞等待。
- Token 成本:每次約 1,500 tokens(含檢索內容)× 每日約 300 萬次 ≈ 45 億 tokens/天,成本相當可觀 → 分層路由、快取、限制上下文長度。
- 儲存:每則訊息約 1 KB → 每日約 10 GB,保留一年約 3.6 TB。
API 設計
POST /webhooks/line/{channel_id} # LINE 平台呼叫;驗證 X-Line-Signature
POST /webhooks/messenger/{page_id} # Messenger 呼叫;驗證 X-Hub-Signature-256
GET /webhooks/messenger/{page_id} # Messenger 訂閱驗證(hub.challenge)
GET /ws/chat?widget_key=...&session=... # Web 聊天視窗 WebSocket
# 內部 / 客服後台
POST /v1/conversations/{id}/handoff {"reason": "user_request"}
POST /v1/conversations/{id}/messages {"text": "...", "agent_id": "a-9"} # 客服回覆
POST /v1/conversations/{id}/release # 客服結束,交還 bot
GET /v1/conversations?tenant=..&state=WAITING_AGENT
GET /ws/agent # 客服後台 WebSocket(即時新訊息)
資料模型
| 實體 | 主要欄位 | 儲存 |
|---|---|---|
| channels | channel_id、tenant_id、type、secret、access_token(加密) | MySQL + 本地快取 |
| conversations | conversation_id(tenant + channel + external_user_id)、state、assigned_agent、context(slots、摘要)、recent_event_ids、version | MongoDB/DynamoDB(文件);熱資料在 Redis |
| messages | conversation_id、seq、direction(in/out)、sender、content、channel_message_id、created_at | 以 conversation_id 為 partition key、seq 排序(Cassandra/DynamoDB/MongoDB) |
| bot 設定 | flows、intents、prompt 模板、知識庫文件 | MySQL + 物件儲存;知識庫 embedding 在向量資料庫 |
| agents | agent_id、技能標籤、上限並發數、線上狀態 | MySQL + Redis(線上狀態) |
高層架構
深入 1:Webhook 接入、驗簽與非同步回覆
- 驗簽:LINE 以 channel secret 對 request body 做 HMAC-SHA256 再 Base64,放在
X-Line-Signature;Messenger 用 app secret 計算X-Hub-Signature-256。必須用原始 body bytes 計算,並用常數時間比較(hmac.compare_digest)。 - 立即回 200:gateway 只做驗簽、正規化、寫入 Kafka,絕不在 webhook 請求中呼叫 LLM。否則 LLM 一慢,平台判定逾時而重送,形成重複處理甚至被停用。
- 正規化:把各渠道格式轉成內部統一的
InboundMessage(tenant_id、conversation_id、event_id、type、text、payload、timestamp),bot 邏輯只認內部格式,新增渠道只要寫 adapter。 - 回覆方式:LINE 的 reply API 使用 webhook 事件附帶的 reply token(一次性、有時效);超過時效或需多次回覆時改用 push API(通常會計入訊息額度)。Messenger 有 24 小時標準訊息視窗的政策限制。這些渠道規則要封裝在 sender adapter 內。
import base64, hashlib, hmac, json
from fastapi import FastAPI, HTTPException, Request, Response
app = FastAPI()
def verify_line_signature(channel_secret: str, body: bytes, signature: str) -> bool:
digest = hmac.new(channel_secret.encode(), body, hashlib.sha256).digest()
return hmac.compare_digest(base64.b64encode(digest).decode(), signature)
@app.post("/webhooks/line/{channel_id}")
async def line_webhook(channel_id: str, request: Request):
body = await request.body() # 必須用原始 bytes 驗簽
channel = await channel_cache.get(channel_id) # tenant、secret;本地快取
if channel is None or not verify_line_signature(
channel.secret, body, request.headers.get("x-line-signature", "")):
raise HTTPException(401)
for ev in json.loads(body).get("events", []):
user_id = ev.get("source", {}).get("userId", "unknown")
conv_id = f"{channel.tenant_id}:line:{channel_id}:{user_id}"
msg = {
"event_id": ev.get("webhookEventId"), # 平台提供的事件 id,用來去重
"conversation_id": conv_id,
"tenant_id": channel.tenant_id,
"type": ev.get("type"),
"text": ev.get("message", {}).get("text"),
"reply_token": ev.get("replyToken"),
"ts": ev.get("timestamp"),
}
# key = conversation_id:同一對話進同一 partition,保證順序
await producer.send_and_wait("chat.inbound", json.dumps(msg).encode(),
key=conv_id.encode())
return Response(status_code=200)
深入 2:訊息順序與 head-of-line blocking
使用者常連續傳好幾則短訊息(「你好」「我想問」「訂單 123 到哪了」)。若平行處理,可能先回第三則再回第一則,甚至用不完整的上下文回答。解法:以 conversation_id 為 Kafka partition key,同一對話的訊息進同一 partition,由同一個 consumer 依序處理。
但這帶來新問題:一個 partition 內有成千上萬個對話,如果 worker 嚴格逐筆處理,一個 LLM 呼叫卡 5 秒,同 partition 的其他對話全部排隊(head-of-line blocking)。進階做法:
- Keyed executor:worker 從 partition 拉訊息後,依 conversation_id 分派到各自的記憶體佇列,「不同對話並行、同一對話序列」。offset 只能 commit 到「最小的尚未完成 offset」,避免崩潰時遺失。
- Debounce 合併:收到訊息後等待短暫時間(例如 1–2 秒),把連續訊息合併成一次 LLM 呼叫,減少成本也讓回答更完整。
- 取消重算:LLM 尚在生成時新訊息到達,可取消舊請求、用合併後的上下文重新生成(成本較高但體驗較好)。
- 出站順序:outbound 也以 conversation_id 分區,sender 依序送出;每則訊息帶 seq,Web 端依 seq 排序顯示。
深入 3:對話狀態管理與人工轉接
- 狀態存放:conversation 文件存 state、slots、近期訊息摘要、version;熱資料快取在 Redis(TTL 例如 30 分鐘),寫入時用樂觀鎖(version 比對)避免 bot 與客服同時修改造成覆蓋。
- 上下文長度:LLM 不能塞入完整歷史;保留最近 N 輪 + 滾動摘要(summary),並把關鍵 slot 結構化存放。
- 轉人工觸發:使用者明確要求、NLU 信心低於門檻連續兩次、負面情緒、VIP 客戶、特定意圖(退款、客訴)。
- 分派:Agent router 依技能標籤、語言、目前負載(每位客服並發上限)分派;沒有可用客服時進入佇列並告知預估等待時間,逾時改為留言單。
- 即時推送給客服:客服後台透過 WebSocket 連到 Agent WS Gateway;gateway 有多台,用 Redis pub/sub 或 Kafka 依 agent_id 路由訊息到對應連線所在的節點。
- AGENT 狀態時 bot 靜默:worker 收到訊息只轉發給客服;可提供「建議回覆」給客服(AI 輔助),但不直接送給使用者。
import asyncio
LLM_TIMEOUT = 8
async def handle_inbound(msg):
conv = await store.load(msg["conversation_id"]) # Redis 優先,miss 時讀 DB
if msg["event_id"] in conv.recent_event_ids: # 去重紀錄與狀態一起持久化,重播安全
return
conv.recent_event_ids.append(msg["event_id"])
conv.recent_event_ids = conv.recent_event_ids[-50:]
await store.append_message(conv.id, msg)
if conv.state in ("AGENT", "WAITING_AGENT"):
await agent_bus.publish(conv.assigned_agent, conv.id, msg) # bot 不回覆
else:
reply = await route_and_answer(conv, msg)
if reply.handoff:
conv.state = "WAITING_AGENT"
await agent_router.enqueue(conv)
await outbound.send(conv.id, reply.messages, reply_token=msg.get("reply_token"))
await store.save(conv, expected_version=conv.version) # 樂觀鎖;衝突時重新載入再處理
async def route_and_answer(conv, msg):
if (flow_reply := flow_engine.match(conv, msg)) is not None: # 1) 規則 / 按鈕:最快最便宜
return flow_reply
intent = await nlu.classify(msg["text"]) # 2) 小模型意圖辨識
if intent.confidence > 0.85 and intent.name in conv.bot.faq:
return conv.bot.faq[intent.name]
if not await llm_quota.try_consume(conv.tenant_id): # 3) 租戶 LLM 配額
return Reply.handoff_or_default()
try:
return await asyncio.wait_for(llm_orchestrator.answer(conv, msg), LLM_TIMEOUT)
except asyncio.TimeoutError:
return Reply.text("目前查詢人數較多,我幫你轉給專人處理", handoff=True)
深入 4:NLU/LLM 呼叫的延遲與成本
| 手段 | 降低延遲 | 降低成本 | 說明 |
|---|---|---|---|
| 分層路由(規則 → 小模型 → LLM) | ✔ | ✔ | 大部分訊息是固定問題,不需要 LLM |
| 回答快取 / 語意快取 | ✔ | ✔ | 對 FAQ 類問題以 embedding 相似度命中快取;要注意個人化內容不可快取 |
| Prompt 精簡、上下文摘要 | ✔ | ✔ | 輸入 token 數直接影響延遲與費用 |
| Provider 端 prompt caching | ✔ | ✔ | 固定的 system prompt 與知識庫前綴可被快取(依供應商支援) |
| 串流輸出(streaming) | ✔ 體感 | — | Web 渠道可逐字顯示;LINE 等渠道只能整則送出,改用載入提示 |
| 模型分級 | ✔ | ✔ | 簡單問題用小模型、複雜問題才用大模型 |
| Timeout + fallback | ✔ 尾延遲 | — | 超時回固定訊息或轉人工 |
| 租戶配額與全域併發上限 | — | ✔ | 防止單一租戶耗盡 LLM provider 的 RPM/TPM 限額 |
忽略 LLM 的安全面:(1) prompt injection(使用者要求「忽略以上指示」)→ 系統提示與使用者輸入分離、輸出檢查;(2) 幻覺(回答不存在的優惠)→ RAG 只根據知識庫回答、找不到就說不知道或轉人工;(3) 個資外洩 → 送進 LLM 前遮罩 PII、依租戶隔離知識庫檢索(向量搜尋時必須帶 tenant filter)。
深入 5:速率限制
- 入站:每租戶、每使用者限流,防止惡意洗訊息耗盡 LLM 配額(例如每使用者每分鐘 20 則)。
- 出站:各渠道 API 有速率上限;sender 以每個 channel 帳號為單位做 token bucket(Redis 分散式),收到 429 時退避並降低速率。
- LLM provider:全域 RPM/TPM 限制,用集中式的限流器 + 優先順序(付費方案租戶優先)。
面試官可能的追問
Web 渠道用 WebSocket,有幾十萬條長連線怎麼擴展?
WS gateway 本身無業務狀態,只維護連線;用 L4 LB 或支援 WebSocket 的 L7 LB 分散連線。連線與節點的對應(session_id → gateway 節點)存在 Redis,出站訊息由 sender 透過 pub/sub 送到正確節點。單機連線數受檔案描述符、記憶體與 epoll 能力限制,要調整 ulimit -n 與核心參數;部署更新時要優雅斷線讓 client 重連(加上 jitter 避免重連風暴)。
LINE 平台重送同一個 webhook 事件,怎麼避免回覆兩次?
以平台提供的事件 id(LINE 的 webhookEventId)去重,並把「已處理事件 id」與對話狀態在同一次寫入中持久化,worker 崩潰重播時也能判斷。出站發送若遇到逾時、不確定是否送達,可利用渠道支援的冪等機制(例如 LINE push 類 API 的 retry key)。完全的 exactly-once 對外部 API 做不到,最終取捨是「極少量重複」或「極少量遺失」,對客服情境通常選擇寧可不重複。
如果 LLM provider 整個掛了?
多 provider 備援(主要/次要模型,透過抽象層切換);熔斷器偵測錯誤率升高後快速失敗,不要讓每個請求都等到逾時;降級為規則與 FAQ 回答 + 告知使用者「稍後由專人回覆」並建立留言單;監控與告警。這也是為什麼分層路由很重要:LLM 掛了,大部分流量仍可被規則層服務。
如何評估 bot 的品質?
線上指標:自助解決率(沒轉人工就結束)、轉人工率、使用者回饋(讚/倒讚)、對話輪數;離線評估:建立測試集定期回歸測試(特別是換模型或改 prompt 時)、抽樣人工審核、LLM-as-judge 輔助。把每次回答的 prompt 版本、檢索到的文件、模型版本都記錄下來,才能追查錯誤回答的原因。
情境題 4:LINE / 推播訊息大量發送系統 高頻 進階
題目(Appier take-home 延伸):take-home 要求「合併兩個 JSON 檔 + 設計發送 LINE 訊息的 API」,面試時常延伸成:「行銷人員要對 1,000 萬名會員發送個人化 LINE 訊息,怎麼設計?」這是 MarTech 推播平台的核心:分眾、排程、扇出(fan-out)、上游限流、重試、去重、追蹤。
需求釐清
功能需求
- 行銷人員建立 campaign:選擇受眾分群、訊息模板(含個人化變數)、渠道(LINE、App push、Email、SMS)、發送時間(立即/排程/依使用者時區)。
- 單筆交易型訊息 API(訂單通知、OTP),優先級高於行銷訊息。
- 可暫停、取消、查看進度。
- 追蹤:已送出、送達(若渠道支援)、開啟、點擊、轉換。
- 規則:退訂/封鎖不發、勿擾時段、跨 campaign 頻次上限。
非功能需求(假設)
- 單一 campaign 最多 1,000 萬人,希望 30 分鐘內送完(受上游限流影響時需給出預估完成時間)。
- 每日總量 1 億則。
- 不可重複發送(重複推播傷品牌、也浪費訊息費用);可接受極少量失敗但要可查。
- 交易型訊息 p99 數秒內送出。
- 遵守上游(LINE、FCM/APNs)速率限制與政策。
估算
- 1,000 萬人 ÷ 1,800 秒 ≈ 5,600 則/s。
- 若內容相同、可用 LINE multicast(一次最多 500 位收件者):約 20,000 個請求,每秒約 11 個請求即可。
- 若內容個人化(每人不同),每人一個 push 請求 → 5,600 req/s,很可能超過上游限制 → 系統必須以上游限制為準排程,並回報預估完成時間;或把「內容相同的人」分組後用 multicast。
- 發送紀錄:1 億則/天 × 約 200 bytes ≈ 20 GB/天。
- 回流:發送後數分鐘內點擊湧入,tracker 要能承受尖峰(例如 1,000 萬 × 5% 點擊在 10 分鐘內 ≈ 830/s,峰值更高)。
API 設計(take-home 會考的部分)
# 單筆 / 交易型:非同步,回 202 + message_id;Idempotency-Key 防止 client 重試造成重複
POST /v1/messages
Idempotency-Key: 6f1c...-order-123-shipped
{"channel": "line", "to": {"user_id": "u-1"}, "template_id": "order_shipped",
"variables": {"order_id": "123"}, "priority": "high"}
→ 202 {"message_id": "msg_88", "status": "queued"}
GET /v1/messages/msg_88 → {"status": "sent", "sent_at": "...", "provider_request_id": "..."}
# 大量 / 行銷 campaign
POST /v1/campaigns
{"name": "雙11預熱", "segment_id": "seg_cart_abandon_30d", "channel": "line",
"template_id": "promo_1111", "schedule": {"type": "at", "time": "2026-11-10T20:00:00+08:00"},
"throttle_per_sec": 3000, "respect_quiet_hours": true}
→ 201 {"campaign_id": "cmp_7", "status": "SCHEDULED", "estimated_recipients": 8423110}
POST /v1/campaigns/cmp_7:pause POST /v1/campaigns/cmp_7:resume POST /v1/campaigns/cmp_7:cancel
GET /v1/campaigns/cmp_7/stats → {"targeted": 8423110, "sent": 5120033, "failed": 2310,
"skipped_unsubscribed": 40211, "clicked": 120334, "eta": "..."}
take-home 的「發送 LINE 訊息 API」最常被追問的三件事:(1) 為什麼回 202 而不是 200(發送是非同步的,API 只保證已排入佇列);(2) Idempotency-Key 如何實作(存 key → 回應的對應,TTL 24 小時,同 key 不同 body 回 422);(3) LINE API 回 429 或逾時時你怎麼辦(退避重試 + retry key,不能無腦重送)。
資料模型
| Table / Store | 關鍵欄位 | 說明 |
|---|---|---|
| campaigns(MySQL) | id、tenant_id、status(DRAFT/SCHEDULED/RUNNING/PAUSED/DONE/CANCELED)、scheduled_at、segment_snapshot_uri、throttle | 狀態機,狀態轉換用條件更新(WHERE status = 'SCHEDULED') |
| send_batches | batch_id、campaign_id、user_ids(或 S3 範圍)、retry_key、status、attempts | 扇出的工作單位,可續傳 |
| deliveries(Cassandra/DynamoDB/分區 MySQL) | (campaign_id, user_id) 唯一、status、provider_request_id、sent_at、error | 去重依據 + 查詢個別使用者狀態 |
| user_channels | user_id → line_uid、push tokens、email、退訂/封鎖旗標 | 由 profile 服務提供(見情境題 5) |
| idempotency_keys(Redis/DB) | key → request_hash、response、TTL | API 層冪等 |
| message_events(Kafka → OLAP) | sent / failed / click / open / conversion | 統計報表,沿用情境題 1 的管線 |
高層架構
深入 1:分眾快照與排程
- 名單快照:在發送開始時把 segment 解析成固定名單(user_id 列表寫到 S3),之後所有 batch 都基於這份快照。好處:進度可計算、暫停/續傳可定位、重跑不會因分群資料變動而發給不同的人。
- 排程器:多個 scheduler 實例以 DB 鎖領取到期 campaign,避免重複觸發:
-- MySQL 8 / PostgreSQL:多實例同時執行也不會領到同一個 campaign
START TRANSACTION;
SELECT id FROM campaigns
WHERE status = 'SCHEDULED' AND scheduled_at <= NOW()
ORDER BY scheduled_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- 對選到的 id:
UPDATE campaigns SET status = 'RUNNING', started_at = NOW()
WHERE id IN (/* 上面選到的 id */) AND status = 'SCHEDULED';
COMMIT;
- 依使用者時區發送(「每個人當地晚上 8 點」):把名單依時區分組,產生多個子排程。
- 勿擾時段與頻次:fan-out 時過濾;被勿擾時段擋下的可延後到允許時段,而不是直接丟棄(依 campaign 設定)。
- 大名單扇出:fan-out worker 讀取快照,以 500 人一組(配合 multicast 上限)或每人一則產生 batch,寫入
send_batches與 Kafka;本身也要可續傳(記錄已處理到快照的哪個位置)。
深入 2:上游 rate limit 與優先級
- 全域限流:上游限制是針對「整個 channel 帳號」的,但 sender 有很多台,所以限流器必須是分散式的:Redis token bucket(Lua 原子操作,見經典題:rate limiter),或由協調者把總速率平均分配給各 worker(各自本地限流,worker 數變動時重新分配)。
- 自適應:收到 429 時採 AIMD(加法增、乘法減):立即把速率減半,之後慢慢加回,類似 TCP 擁塞控制。尊重
Retry-After。 - 優先級隔離:交易型與行銷型分開 topic 與 worker pool,或在 token bucket 中保留一部分額度給高優先級,避免 1,000 萬則行銷訊息堵住 OTP。
- 多租戶公平性:同一平台上多個品牌各自有自己的 LINE 帳號與限額;共用的 worker 要避免大客戶 campaign 佔滿所有 worker → 依 tenant 分 partition 或 weighted fair queuing。
深入 3:重試、去重與「不確定結果」
| 情況 | 是否重試 | 處理 |
|---|---|---|
| 429 Too Many Requests | 是 | 退避 + 降低速率,尊重 Retry-After |
| 5xx / 連線錯誤 | 是 | 指數退避 + jitter,次數上限後進 DLQ |
| 逾時(不知道對方是否收到) | 是,但必須冪等 | 使用同一個 retry key 重送,讓上游去重 |
| 400(格式錯誤、無效收件者) | 否 | 標記失敗,修正模板或資料 |
| 401 / 403(token 失效、權限) | 否(需人工) | 暫停該帳號的發送並告警 |
| 使用者封鎖帳號 | 否 | 更新 user_channels 旗標,之後不再發送 |
去重的三道防線:(1) API 層 Idempotency-Key;(2) 系統內 (campaign_id, user_id) 唯一鍵:發送前寫入 deliveries 狀態為 SENDING(條件寫入,已存在就跳過);(3) 對上游使用 retry key:LINE Messaging API 的 push/multicast 等端點支援 X-Line-Retry-Key header(UUID),在有效期間內用同一個 key 重送不會重複發送,若該 key 已被接受會回 409。retry key 必須在建立 batch 時產生並持久化,而不是每次重試才產生,否則失去意義。
import asyncio, random
import aiohttp
MULTICAST_URL = "https://api.line.me/v2/bot/message/multicast"
MAX_ATTEMPTS = 6
async def send_batch(session, limiter, account, batch):
"""batch: {"batch_id", "user_ids" (最多 500), "messages", "retry_key"(建立 batch 時產生)}"""
for attempt in range(MAX_ATTEMPTS):
await limiter.acquire(account.id) # 分散式 token bucket
try:
async with session.post(
MULTICAST_URL,
json={"to": batch["user_ids"], "messages": batch["messages"]},
headers={"Authorization": f"Bearer {account.access_token}",
"X-Line-Retry-Key": batch["retry_key"]},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
status = resp.status
retry_after = float(resp.headers.get("Retry-After", 0))
except (aiohttp.ClientError, asyncio.TimeoutError):
status, retry_after = None, 0 # 結果不確定 → 用同一 retry key 重送
if status in (200, 409): # 409:此 retry key 已被接受過
return await mark_batch(batch, "SENT")
if status == 429:
limiter.decrease(account.id) # AIMD:速率減半
elif status is not None and 400 <= status < 500:
return await mark_batch(batch, "FAILED", f"http {status}") # 不可重試
backoff = random.uniform(0, min(60, 2 ** attempt)) # full jitter
await asyncio.sleep(max(backoff, retry_after))
await dead_letter.put(batch) # 重試耗盡:人工 / 稍後重放
深入 4:送達與開啟追蹤
- 送出(sent):上游 API 回 200 即記錄,並保存上游回傳的 request id 以便追查。
- 送達 / 開啟:各渠道能力不同。App push 可由 SDK 回報「收到」與「開啟」事件(走情境題 1 的事件管線);Email 常用追蹤像素(但會受隱私保護機制影響,數字偏高或不準);LINE 等平台通常提供彙總層級的統計 API,而非逐人送達回條,所以實務上以點擊追蹤為主。
- 點擊追蹤:訊息中的連結替換成短網址/轉址服務(
https://trk.example.com/c/abc123),轉址時記錄 campaign、user、時間後 302 到原始網址;短碼內含簽章避免竄改。 - 轉換歸因:點擊後一段時間內的購買事件,依歸因規則(例如 7 天內最後點擊)記到 campaign。
面試官可能的追問
發到一半行銷人員按了「取消」,怎麼停下來?
campaign 狀態改為 CANCELED(條件更新)。fan-out worker 每處理一批就檢查狀態;sender 在送出前檢查 campaign 狀態(本地快取幾秒,避免每則都查 DB),已在 Kafka 中的 batch 被消費時直接標記為 CANCELED 並跳過。所以取消是「盡快停止」而非「瞬間停止」,會有少量在途訊息仍送出,要在 UI 上說明。暫停/續傳同理,因為有名單快照與 batch 狀態,可以從中斷處繼續。
Sender worker 送出成功後、更新狀態前崩潰,會重複發送嗎?
Kafka 會把該 batch 重新交給其他 worker(offset 未 commit)。第二次送出時帶同一個 retry key,上游會回 409(已接受),我們視為成功,不會重複發送。若上游不支援冪等機制,只能靠「送出前寫 SENDING 狀態」縮小窗口,並在重試前查詢上游狀態(若有 API),剩餘的極小機率重複需要被業務接受。
每個人的內容都不同(個人化),還能用 multicast 嗎?
先看個人化的程度:若只有少數幾種變體(例如依會員等級三種文案),把同變體的人分組後各自 multicast;若真的每人不同(含姓名、專屬折扣碼),只能逐一 push,發送速率就受上游限制,需要在建立 campaign 時就算出預估完成時間並提示行銷人員,或建議改用「通用訊息 + 個人化落地頁」的設計。
怎麼合併兩個 JSON 檔(take-home 的另一半)?
把兩份 List[dict] 依 id 建 dict(O(n+m)),對相同 id 的 record 依衝突規則合併欄位(例如以較新的 updated_at 為準、非空值優先、陣列取聯集),只出現在一邊的照常保留。要說明時間與空間複雜度、輸入 id 重複或缺失時的處理、以及檔案大到放不進記憶體時改用外部排序或依 id hash 分桶。完整程式碼見情境題 5。
情境題 5:合併多來源使用者資料(Identity / Profile 整合) 高頻 深入
題目來源:Appier coding 題「合併兩個 List[dict] 中相同 id 的資料」與追問「兩份資料如何 merge?資料量到上億筆怎麼辦?」放大後就是 MarTech 平台的核心元件:Customer Data Platform(CDP)的身分解析(identity resolution)與統一使用者輪廓(unified profile)。面試時可以從 coding 題一路延伸到系統設計,展現「從演算法到分散式」的連貫思考。
需求釐清
功能需求
- 資料來源:CRM(會員編號、email、電話)、Web SDK(cookie id,登入後有 user_id)、App SDK(device id、user_id)、LINE(綁定後的 LINE uid)、線下 POS。
- 身分解析:判斷哪些識別碼屬於同一個人,給出穩定的
unified_id。 - 屬性合併:同一人在不同來源的屬性有衝突時,依規則產生單一 profile,並保留來源(provenance)。
- 線上查詢:用任一識別碼查到 unified profile(給推薦、推播、廣告使用)。
- 離線批次:每日全量重算;即時:新登入/綁定事件數秒到分鐘內生效。
- 依使用者要求刪除(隱私法規)。
非功能需求(假設)
- 約 3 億個識別碼、對應約 1 億人。
- 線上查詢 p99 約 10ms,峰值 1–2 萬 QPS。
- 錯誤合併(把兩個人併成一個)比漏合併更糟:會把 A 的個資推播給 B → 預設保守,只用確定性(deterministic)規則。
- 全量批次需在數小時內完成。
估算
- ID 對應表:3 億 × 約 100 bytes ≈ 30 GB → 分散式 KV,或 Redis Cluster 全放記憶體。
- Profile:1 億 × 約 2 KB ≈ 200 GB → 分散式 KV(DynamoDB、Cassandra、ScyllaDB、HBase),熱資料加 cache。
- 原始來源資料:每日全量或增量檔數十到數百 GB → 存 S3,Spark 處理。
- 批次 join:上億筆 × 多來源 → 單機記憶體放不下,需要依 id 分區的分散式處理。
從 coding 題開始:合併兩個 List[dict]
面試的 coding 版本通常是:給兩個 list,每個元素是含 id 的 dict,把相同 id 的資料合併。先講清楚衝突規則再寫程式(這本身就是加分):誰覆蓋誰?空值怎麼辦?陣列欄位要合併還是取代?
from typing import Any
MULTI_VALUED = {"tags", "emails"} # 多值欄位:取聯集
def merge_two(old: dict, new: dict) -> dict:
"""以 updated_at 較新的為主;空值不覆蓋;多值欄位取聯集。"""
if new.get("updated_at", "") >= old.get("updated_at", ""):
newer, older = new, old
else:
newer, older = old, new
out = dict(older)
for field, value in newer.items():
if field in MULTI_VALUED:
out[field] = sorted(set(older.get(field) or []) | set(value or []))
elif value not in (None, ""):
out[field] = value # 較新且非空才覆蓋
return out
def merge_lists(a: list[dict], b: list[dict], key: str = "id") -> list[dict]:
merged: dict[Any, dict] = {} # 保持插入順序(Python 3.7+ dict 有序)
for rec in (*a, *b):
k = rec.get(key)
if k is None:
continue # 無 id 的資料:記錄後另外處理,不要默默丟掉
merged[k] = merge_two(merged[k], rec) if k in merged else dict(rec)
return list(merged.values())
a = [{"id": 1, "name": "Amy", "email": None, "tags": ["vip"], "updated_at": "2026-10-01"}]
b = [{"id": 1, "name": "Amy Lin", "email": "amy@x.com", "tags": ["line"], "updated_at": "2026-10-05"},
{"id": 2, "name": "Bob", "updated_at": "2026-09-01"}]
print(merge_lists(a, b))
# [{'id': 1, 'name': 'Amy Lin', 'email': 'amy@x.com', 'tags': ['line', 'vip'], 'updated_at': '2026-10-05'},
# {'id': 2, 'name': 'Bob', 'updated_at': '2026-09-01'}]
複雜度:時間 O(n + m)(假設每筆欄位數為常數),空間 O(n + m)。追問「同一個 list 內 id 重複怎麼辦」:上面的寫法自然處理(同樣走 merge_two)。追問「保持 a 的順序」:dict 保留插入順序,a 的 id 會先出現。
追問:資料量到上億筆怎麼辦?
| 方法 | 適用規模 | 原理 | 注意 |
|---|---|---|---|
| 記憶體 dict | 放得進單機記憶體(數百萬到數千萬筆) | hash join | Python dict 每筆額外開銷大,實際記憶體可能是資料量的數倍 |
| 外部排序 + merge join | 單機磁碟放得下 | 兩檔各自依 id 外部排序(sort 指令或分塊排序 + k-way merge),再像 merge sort 一樣雙指標串流合併 | 記憶體 O(1),IO 為主;適合一次性作業 |
| Hash 分桶(grace hash join) | 單機磁碟放得下 | 依 hash(id) % N 把兩檔切成 N 個桶,同 id 必在同桶;逐桶載入記憶體合併 | N 要讓每桶放得進記憶體;hash 要穩定 |
| Spark / 分散式 join | 上億到數百億筆 | 依 id shuffle 到各 executor(分散式的 hash 分桶) | 資料傾斜(某 id 特別多)需 salting;小表用 broadcast join |
| 資料庫 bulk load + upsert | 需要持續增量更新 | 先 bulk load 到 staging table,再 INSERT ... ON DUPLICATE KEY UPDATE / MERGE | 大量 upsert 時注意 index 維護與 binlog 量 |
import json, zlib
def bucket_of(record_id, n):
# 不要用內建 hash():字串的 hash 每個 process 隨機(PYTHONHASHSEED),跨程序不穩定
return zlib.crc32(str(record_id).encode()) % n
def split_into_buckets(path, out_dir, tag, n):
outs = [open(f"{out_dir}/{tag}-{i}.jsonl", "w", encoding="utf-8") for i in range(n)]
try:
with open(path, encoding="utf-8") as f:
for line in f: # 串流讀取,不整檔載入
outs[bucket_of(json.loads(line)["id"], n)].write(line)
finally:
for fh in outs:
fh.close()
def merge_buckets(out_dir, n, out_path):
with open(out_path, "w", encoding="utf-8") as out:
for i in range(n): # 每次只有一個桶在記憶體中
merged = {}
for tag in ("a", "b"):
with open(f"{out_dir}/{tag}-{i}.jsonl", encoding="utf-8") as f:
for line in f:
rec = json.loads(line)
k = rec["id"]
merged[k] = merge_two(merged[k], rec) if k in merged else rec
for rec in merged.values():
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
# PySpark 版本:每個欄位取「最新且非空」的值(max_by 需 Spark 3.3+)
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("profile-merge").getOrCreate()
crm = spark.read.parquet("s3://lake/crm/dt=2026-11-03/").withColumn("src", F.lit("crm"))
app = spark.read.parquet("s3://lake/app/dt=2026-11-03/").withColumn("src", F.lit("app"))
rows = crm.unionByName(app, allowMissingColumns=True)
fields = [c for c in rows.columns if c not in ("id", "updated_at", "src")]
merged = rows.groupBy("id").agg(
# 值為 null 時排序鍵設為 null → 不會被選中,等同「非空值中最新的那個」
*[F.max_by(F.col(c), F.when(F.col(c).isNotNull(), F.col("updated_at"))).alias(c)
for c in fields],
F.max("updated_at").alias("updated_at"),
)
merged.write.mode("overwrite").parquet("s3://lake/profile_merged/dt=2026-11-03/")
追問「上億筆」時,面試官想看你是否理解「同一個 key 必須落在同一個處理單元」這個核心觀念:不論是單機分桶、外部排序或 Spark shuffle,本質都是依 key 分區,讓每一份可以獨立在記憶體中處理。再補充穩定 hash、資料傾斜、增量處理(只處理今天變動的 id)就很完整。
身分解析:Identity Graph 與 Union-Find
真實情況不是「兩份資料有相同的 id」,而是每個來源有不同的識別碼:CRM 有 email + 會員編號;App 登入後有 user_id + device_id;Web 登入後 cookie 與 user_id 綁定。把每個識別碼當成圖的節點,「同一筆紀錄中同時出現」就連一條邊,一個連通分量(connected component)就是一個人。
上圖有兩個連通分量:左邊 7 個識別碼屬於同一人(Amy),右邊兩個屬於另一個尚未登入的匿名使用者。
class UnionFind:
def __init__(self):
self.parent, self.size = {}, {}
def find(self, x):
if x not in self.parent:
self.parent[x], self.size[x] = x, 1
root = x
while self.parent[root] != root:
root = self.parent[root]
while self.parent[x] != root: # 路徑壓縮
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return ra
if self.size[ra] < self.size[rb]: # union by size,樹高 O(log n)
ra, rb = rb, ra
self.parent[rb] = ra
self.size[ra] += self.size[rb]
return ra
def resolve(records, max_degree=50):
"""records: 每筆是一個來源紀錄中同時出現的識別碼,例如 {"email": ..., "device": ...}"""
degree = {}
for r in records:
for k, v in r.items():
if v:
degree[(k, v)] = degree.get((k, v), 0) + 1
uf = UnionFind()
for r in records:
ids = [(k, v) for k, v in r.items() if v and degree[(k, v)] <= max_degree] # 排除超級節點
for other in ids[1:]:
uf.union(ids[0], other)
if ids:
uf.find(ids[0])
groups = {}
for node in uf.parent:
groups.setdefault(uf.find(node), []).append(node)
return list(groups.values())
- 超級節點(super node):共用裝置(網咖電腦)、測試帳號(
test@test.com)、預設值(電話0000000000)會把成千上萬人串成一個分量。對策:度數上限、黑名單、只信任特定類型的邊(例如 email 邊比 IP 邊可靠)。上面程式中的max_degree是度數上限的簡化版(實務上通常依識別碼類型分別設定)。 - 確定性 vs 機率性比對:確定性(相同的雜湊 email、登入綁定)準確;機率性(相同 IP + 相似裝置指紋)覆蓋率高但會誤合併。預設只用確定性規則,機率性結果另存且標記信心分數,供廣告受眾擴展等容錯度高的場景使用。
- 分散式連通分量:上億節點時用 Spark GraphFrames 的 connected components,或自行實作迭代式 label propagation(每輪每個節點取鄰居的最小 label,直到不再變化)。
- 正規化:email 轉小寫並去空白、電話轉 E.164 格式、再做 SHA-256 雜湊;正規化不一致會造成漏合併。
屬性衝突規則
| 規則 | 適用欄位 | 範例 |
|---|---|---|
| 來源優先序(source priority) | 有權威來源的欄位 | 姓名、生日以 CRM 為準,其次 App,最後 Web 表單 |
| 最新者勝(last-write-wins) | 會變動的狀態 | 地址、偏好語言、最後活躍時間 |
| 非空優先 | 所有欄位的基本規則 | 新資料該欄位為空時不覆蓋舊值 |
| 聯集 | 多值欄位 | 興趣標籤、擁有的裝置、訂閱的渠道 |
| 聚合 | 行為統計 | 總消費金額(加總)、最近購買日(取最大) |
| 最常出現(majority) | 雜訊大的推論欄位 | 推論性別、常用城市 |
| 同意/退訂取最嚴格 | 隱私相關 | 任一來源退訂即視為退訂 |
每個欄位保留 provenance(值來自哪個來源、何時更新),方便除錯與向客戶解釋「為什麼這個人的生日是這個」。規則要可設定(每個租戶不同),以設定檔驅動合併引擎,而不是寫死在程式碼。
API 與資料模型
GET /v1/profiles:lookup?id_type=email_sha256&id_value=9f86d0... → 200 {"unified_id": "P123", "profile": {...}}
GET /v1/profiles/P123
POST /v1/profiles:batchGet {"unified_ids": ["P1", "P2", ...]} # 推播 fan-out 時批次查
POST /v1/identities:link {"ids": [{"type": "user_id", "value": "u-9"},
{"type": "device_id", "value": "iPhone-A"}],
"source": "app_login", "observed_at": "..."}
DELETE /v1/profiles/P123 # 隱私刪除:非同步,回 202 + 追蹤 id
| Store | Key | Value |
|---|---|---|
| id_map(KV) | tenant + id_type + id_value | unified_id |
| profiles(KV) | tenant + unified_id | 合併後屬性 + provenance + version |
| identity_edges(S3 / 圖儲存) | (id_a, id_b) | 來源、類型(確定/機率)、首次/最後觀察時間 |
| id_alias(KV) | 舊 unified_id | 合併後存活的 unified_id |
| source_records(S3 Parquet) | 來源 + 日期分區 | 原始紀錄,重算用 |
高層架構:離線批次 + 線上增量
- 離線全量:每天從 S3 重算完整 identity graph 與 profile,結果寫入新版本的表,驗證後原子切換(版本指標或表別名)。全量重算能修正增量路徑累積的錯誤,也是規則變更後的回補方式。
- 線上增量:使用者登入時產生 link 事件 → Flink 查兩個識別碼目前的 unified_id:都沒有 → 建立新的;一個有 → 把另一個加入;兩個不同 → 合併兩個 profile(保留較舊的 unified_id 作為存活者,另一個寫入 id_alias,更新 id_map,屬性依規則合併)。同一個 unified_id 的更新依 key 分區序列化,避免併發寫入衝突。
- 批次與串流的銜接:快照代表時間點 T 的狀態;切換後,Flink 從 T 之前一點的 offset 重新套用增量事件(操作要冪等),確保快照之後的變更不遺失。
- 穩定 ID:每天重算時,新的連通分量要盡量沿用昨天的 unified_id(例如取分量中最舊的 unified_id),否則下游(推播紀錄、受眾名單)會全部對不上。
面試官可能的追問
錯誤合併了兩個人,怎麼拆開(unmerge)?
這就是為什麼要保存原始邊與來源紀錄,而不是只存合併後的結果。拆分流程:把造成錯誤的邊(例如某個測試 email)加入黑名單 → 對受影響的分量重新跑連通分量 → 產生兩個 profile(一個沿用原 unified_id,另一個新建)→ 屬性依來源重新合併。如果只保存合併結果,資料無法還原。這也是預設保守合併的理由:錯誤合併的修復成本遠高於漏合併。
Spark join 時某些 id(例如空字串、預設值)特別多,造成資料傾斜怎麼辦?
先在來源清掉無效值(空字串、null、unknown、測試帳號),這通常就解決大部分傾斜。真正的熱門 key 可用 salting:在大表的 key 後加隨機後綴 0..N-1,小表對應複製 N 份,join 後再彙總;Spark 3 的 AQE(Adaptive Query Execution)也能自動拆分傾斜分區。小表(例如黑名單、維度表)用 broadcast join 避免 shuffle。
線上查詢 1–2 萬 QPS、p99 10ms,怎麼做?
id_map 與 profile 放在低延遲分散式 KV(單次點查約 1–5ms),熱門 profile 再放 Redis 或本地 cache(TTL 短,profile 更新時發送失效事件)。lookup 需要兩次查詢(id → unified_id → profile),可以把常用欄位反正規化到 id_map 的 value 裡省一次往返;批次查詢 API 讓推播 fan-out 一次取上百筆。容量上,2 萬 QPS 對分散式 KV 不大,重點在 p99 穩定:避免大 value、熱點 key、GC。
使用者要求刪除個資,系統要怎麼處理?
建立刪除工作流程:(1) 線上:刪除 profile 與 id_map、加入「已刪除識別碼」清單,避免下次批次又把資料建回來;(2) 離線:S3 的歷史 Parquet 需要重寫移除該使用者(成本高,可批次每週處理),或採用每位使用者獨立加密金鑰、刪除金鑰使資料不可讀(crypto-shredding);(3) 下游:發出刪除事件讓推播、推薦、廣告受眾等系統同步刪除;(4) 留下稽核紀錄(只記錄刪除動作,不留個資)。
經典題精簡版 高頻 進階
以下九題是系統設計面試的「基本功」,Appier 的情境題常以它們為零件(例如推播需要 rate limiter 與 unique ID、報表需要分散式快取)。每題只列出核心需求、關鍵設計與最常被追問的點。
1. URL Shortener(短網址)
需求:長網址 → 短碼;短碼轉址;可選自訂短碼、過期時間、點擊統計。估算:每月 1 億個新短網址(約 40/s 寫入),讀寫比 100:1(約 4,000/s 讀,峰值數倍);5 年 60 億筆。Base62 7 碼可表示 627 ≈ 3.5 兆個,足夠。每筆約 500 bytes → 5 年約 3 TB。
- 短碼產生:(a) 全域唯一數字 ID → Base62 編碼(無碰撞,但連續 ID 可被猜測,可先做可逆的位元混淆);(b) 對長網址做雜湊取前 7 碼(需檢查碰撞、重試加鹽);(c) 預先產生短碼池。面試推薦 (a) + 號段發號(每台服務一次向 DB 取 1,000 個 ID 區間)。
- 301 vs 302:301 永久轉址會被瀏覽器快取,降低伺服器負載但無法統計每次點擊;需要統計時用 302。
- 讀多:cache-aside + Redis,熱門短碼命中率高;不存在的短碼要快取空結果或用 Bloom filter 擋(cache penetration)。
- 同一長網址是否回同一短碼:需要 long_url → code 的反向索引(雜湊後索引),依需求決定。
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode62(n: int) -> str:
if n == 0:
return ALPHABET[0]
out = []
while n:
n, r = divmod(n, 62)
out.append(ALPHABET[r])
return "".join(reversed(out))
def decode62(s: str) -> int:
n = 0
for ch in s:
n = n * 62 + ALPHABET.index(ch)
return n
assert decode62(encode62(3_521_614_606_207)) == 3_521_614_606_207 # 62**7 - 1 → "ZZZZZZZ"
2. 分散式 Rate Limiter(Redis + Lua)
| 演算法 | 原理 | 優點 | 缺點 |
|---|---|---|---|
| Fixed window | 每個時間窗一個計數器(INCR + EXPIRE) | 最簡單、省記憶體 | 窗邊界可能瞬間通過 2 倍流量 |
| Sliding window log | 存每個請求時間戳(ZSET),移除窗外的再計數 | 精確 | 記憶體與請求數成正比 |
| Sliding window counter | 前一窗計數 × 重疊比例 + 本窗計數 | 省記憶體、近似平滑 | 近似值 |
| Token bucket | 以固定速率補充 token,請求消耗 token | 允許突發(bucket 容量)、平均速率受控 | 需存 token 數與時間 |
| Leaky bucket | 請求進佇列,以固定速率流出 | 輸出平滑 | 突發請求會排隊延遲 |
分散式的關鍵是「讀取 → 計算 → 寫回」必須原子,否則多台 gateway 同時讀到相同 token 數會超賣。Redis 執行 Lua 腳本時不會穿插其他命令,因此天然原子:
import redis
r = redis.Redis()
TOKEN_BUCKET = r.register_script("""
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒補充幾個 token
local capacity = tonumber(ARGV[2]) -- bucket 容量(允許的突發量)
local requested = tonumber(ARGV[3])
local t = redis.call('TIME') -- 用 Redis 伺服器時間,避免各 client 時鐘不一致
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + (now - ts) / 1000 * rate)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000)
return allowed
""")
def allow(client_id: str, rate: float = 10, capacity: int = 20, cost: int = 1) -> bool:
try:
return TOKEN_BUCKET(keys=[f"rl:{{{client_id}}}"], args=[rate, capacity, cost]) == 1
except redis.RedisError:
return True # fail-open:限流器故障時放行(保護性限流可改為 fail-closed)
- 放在哪:API gateway / Nginx / Envoy(全域)+ 服務內(細粒度,例如每租戶每功能)。
- 回應:429 +
Retry-After,常見還會回X-RateLimit-Limit、X-RateLimit-Remaining。 - 效能:每個請求一次 Redis 往返;超高 QPS 時可用「本地 token bucket + 定期向 Redis 申請配額」的混合方案(與廣告預算租約同一思路),犧牲少量精確度換延遲。
- Redis Cluster:key 用 hash tag
{client_id},確保腳本存取的 key 在同一 slot。
3. 分散式 Unique ID
| 方案 | 長度 | 有序 | 優點 | 缺點 |
|---|---|---|---|---|
| UUID v4 | 128 bit | 否 | 無需協調、任何地方都能產生 | 長;作為 B-tree 主鍵時隨機插入造成頁分裂與快取效率差 |
| UUID v7(RFC 9562) | 128 bit | 大致依時間 | 無需協調且對索引友善 | 仍是 128 bit;洩漏產生時間 |
| DB auto-increment | 64 bit | 是 | 簡單 | 單點與寫入瓶頸;多主可用不同 offset/step 但擴充困難 |
| 號段模式(segment) | 64 bit | 大致有序 | 每次向 DB 取一段(如 1,000 個),DB 壓力小 | 服務重啟會浪費號段;跨節點非嚴格遞增 |
| Snowflake | 64 bit | 依時間大致有序 | 無中央協調、每節點每毫秒 4,096 個 | 依賴時鐘(時鐘回撥);worker id 需分配 |
Snowflake 結構:1 bit 符號 + 41 bit 毫秒時間戳(約 69 年)+ 10 bit 機器 ID(1,024 台)+ 12 bit 序號(每毫秒 4,096 個)。
import threading, time
EPOCH_MS = 1_704_067_200_000 # 自訂起點 2024-01-01 UTC,延長可用年限
WORKER_BITS, SEQ_BITS = 10, 12
MAX_WORKER = (1 << WORKER_BITS) - 1
MAX_SEQ = (1 << SEQ_BITS) - 1
class Snowflake:
def __init__(self, worker_id: int):
if not 0 <= worker_id <= MAX_WORKER:
raise ValueError("worker_id out of range")
self.worker_id, self.last_ms, self.seq = worker_id, -1, 0
self.lock = threading.Lock()
def next_id(self) -> int:
with self.lock:
now = int(time.time() * 1000)
if now < self.last_ms:
raise RuntimeError("clock moved backwards") # 小幅回撥可等待追上
if now == self.last_ms:
self.seq = (self.seq + 1) & MAX_SEQ
if self.seq == 0: # 本毫秒 4096 個已用完
while now <= self.last_ms:
now = int(time.time() * 1000)
else:
self.seq = 0
self.last_ms = now
return ((now - EPOCH_MS) << (WORKER_BITS + SEQ_BITS)) | (self.worker_id << SEQ_BITS) | self.seq
追問:worker id 怎麼分配?(K8s 中可用 StatefulSet 序號、或啟動時向 ZooKeeper/etcd/Redis 註冊租約);時鐘回撥怎麼辦?(NTP 只做緩慢調整(slew)、偵測到回撥時等待或拒絕服務、保留備用 worker id)。
4. News Feed / 推薦結果服務
經典 news feed 的核心取捨是 fan-out 時機:
Fan-out on write(push)
發文時寫入每個追蹤者的 feed(Redis list)。讀取 O(1) 快;但大 V 有千萬粉絲時寫入爆炸,且不活躍使用者的 feed 白算。
Fan-out on read(pull)
讀取時才合併所追蹤者的最新貼文。寫入便宜;讀取慢,需要 k-way merge 與快取。
混合
一般使用者 push、大 V pull,讀取時合併兩者。是業界常見答案。
Appier 版本:推薦結果服務(例如電商「為你推薦」)。模式是「離線預計算 + 線上即時調整」:
- 召回(candidate generation)離線做,排序(ranking)線上做,兼顧品質與延遲。
- 過濾必須即時:缺貨、已購買、價格變動不能等明天的批次。
- 冷啟動與失敗時的 fallback:熱門、同類別熱銷。
- A/B 測試:依 user hash 分流,記錄每次曝光使用的模型版本以便歸因。
5. 即時排行榜
Redis Sorted Set(底層為 skiplist + hash table):ZINCRBY 更新分數、ZREVRANGE 取前 N 名、ZREVRANK 查名次,皆為 O(log N)(取範圍為 O(log N + M))。
import redis
r = redis.Redis()
def add_score(board: str, user: str, delta: float):
r.zincrby(board, delta, user)
def top_n(board: str, n: int = 10):
return r.zrevrange(board, 0, n - 1, withscores=True)
def my_rank(board: str, user: str):
rank = r.zrevrank(board, user)
return None if rank is None else rank + 1 # 0-based → 1-based
def around_me(board: str, user: str, k: int = 5):
rank = r.zrevrank(board, user)
if rank is None:
return []
return r.zrevrange(board, max(0, rank - k), rank + k, withscores=True)
# 每日榜:key 帶日期並設 TTL,例如 board:2026-11-03,週榜可用 ZUNIONSTORE 合併 7 天
- 同分排序:把分數編碼成
score * 10^k + (MAX_TS - ts),先達到者排前面(注意 double 精度上限約 253)。 - 規模:單一 ZSET 存數千萬成員仍可運作,但單 key 成為熱點且無法分散;超大規模可依分數區間分片、或只對前 N 名精確排名,其餘給近似百分位。
- 持久化:分數的 source of truth 放 DB 或事件流,Redis 可由事件重建。
6. 通知系統
與情境題 4 的推播系統高度重疊,差異在於「通知」多為事件觸發、單一使用者的:
- 使用者偏好:每類通知每個渠道可開關、勿擾時段、語言。
- 合併摘要(digest):「你有 12 則新留言」而不是 12 則推播;用延遲佇列聚合一段時間內的同類事件。
- 站內信:每使用者的通知清單(依 user_id 分區、時間排序)與未讀數(Redis 計數器)。
- 可靠性:冪等鍵(事件 id + 渠道)、重試、DLQ、供應商故障時切換備援供應商(例如 SMS 雙供應商)。
7. 分散式快取
設計一個類 Memcached/Redis Cluster 的分散式快取,核心問題:資料怎麼分布、節點增減怎麼辦、怎麼淘汰、怎麼確保可用。
import bisect, hashlib
class ConsistentHashRing:
"""一致性雜湊 + 虛擬節點:增減一個節點只影響約 1/N 的 key。"""
def __init__(self, nodes=(), vnodes=150):
self.vnodes = vnodes
self.ring = [] # 排序後的 hash 值
self.owner = {} # hash 值 -> 實體節點
for n in nodes:
self.add(n)
@staticmethod
def _hash(key: str) -> int:
return int.from_bytes(hashlib.md5(key.encode()).digest()[:8], "big")
def add(self, node: str):
for i in range(self.vnodes): # 虛擬節點讓分布更平均
h = self._hash(f"{node}#{i}")
bisect.insort(self.ring, h)
self.owner[h] = node
def remove(self, node: str):
for i in range(self.vnodes):
h = self._hash(f"{node}#{i}")
self.ring.remove(h)
del self.owner[h]
def get(self, key: str) -> str:
idx = bisect.bisect(self.ring, self._hash(key)) % len(self.ring) # 順時針第一個
return self.owner[self.ring[idx]]
ring = ConsistentHashRing(["cache-a", "cache-b", "cache-c"])
print(ring.get("user:42"))
| 問題 | 現象 | 對策 |
|---|---|---|
| Cache stampede(擊穿) | 熱門 key 過期瞬間大量請求同時打 DB | 互斥鎖/single-flight(只讓一個請求回源)、提前非同步刷新、機率性提前過期 |
| Cache avalanche(雪崩) | 大量 key 同時過期或快取叢集故障 | TTL 加隨機 jitter、多層快取、DB 端限流與熔斷 |
| Cache penetration(穿透) | 查詢不存在的 key 每次都打 DB | 快取空值(短 TTL)、Bloom filter |
| Hot key | 單一節點 CPU/頻寬打滿 | 本地快取、key 複製多份加後綴分散讀取 |
| 一致性 | DB 更新後快取仍是舊值 | cache-aside「先更新 DB 再刪快取」、延遲雙刪、CDC 驅動失效 |
其他要點:淘汰策略(LRU、LFU、TTL);複製(主從,主掛時提升從節點);寫入策略(cache-aside 最常見、write-through、write-behind)。Redis Cluster 實際上用的是 16,384 個 hash slot 而非一致性雜湊環,但目的相同:節點增減只搬移部分資料。
8. Web Crawler
估算:每月抓 10 億頁 → 約 385 頁/s;平均每頁 100 KB → 每月約 100 TB 原始內容。
- 禮貌性(politeness):每個網域一個佇列,同一網域同時只有一個連線並設間隔;遵守 robots.txt。
- 優先級:依 PageRank、更新頻率、網域重要性決定先抓哪些。
- 去重:URL 層用正規化 + Bloom filter(數十億 URL 也只需數 GB),內容層用 hash 精確去重、simhash 近似去重。
- 陷阱:無限生成的 URL(日曆頁)、spider trap → 限制深度與每網域頁數。
- 分散:依網域 hash 分配到 fetcher 節點,讓同網域的禮貌性控制在單一節點完成。
9. 檔案上傳 / 大檔分段上傳
原則:檔案不要經過應用伺服器。API 伺服器只負責授權與中繼資料,檔案本體由 client 透過預簽章 URL(presigned URL)直接上傳到物件儲存。
- S3 multipart upload:除最後一個 part 外每個 part 至少 5 MiB、最多 10,000 個 part;part 可平行上傳、失敗只重傳該 part。
- 續傳:client 記錄 upload_id,斷線後用 ListParts 查詢已完成的 part,只補傳缺少的。
- 完整性:每個 part 帶 checksum,完成後比對整體 checksum。
- 清理:未完成的 multipart upload 仍會佔用儲存費用,用 lifecycle rule 自動中止超過 N 天的上傳。
- 安全:presigned URL 設短效期、限制 content-type 與大小;上傳完成前檔案不可被公開存取;後處理(掃毒、轉檔)透過事件非同步完成。
系統設計常見追問 Q&A 彙整 高頻
不論題目是什麼,面試官的追問常落在下列幾類。每題都準備一段 1–2 分鐘的回答,並能連結到前面的情境題。
SQL 和 NoSQL 你怎麼選?
熱點(hot key / hot partition)怎麼處理?
先判斷是讀熱點還是寫熱點。讀熱點:本地快取、快取多副本(key 加隨機後綴分散到不同節點)、CDN。寫熱點:拆分計數器(把一個計數器拆成 N 個子計數器,讀取時加總)、本地聚合後批次寫入(例如每秒合併一次)、改變分區鍵(加入第二維度,如 hash(app_id, device_id))。監控層面要能找出熱點 key(Redis 的 hotkeys 分析、Kafka 各 partition 流量)。
怎麼讓 API 具備冪等性?
GET/PUT/DELETE 依語意本來就應冪等;POST 需要 Idempotency-Key:client 產生唯一 key,伺服器第一次處理時以 key 為主鍵存下請求摘要與回應(狀態 processing → done),之後同 key 的請求直接回存下的回應;同 key 但 body 不同回 422;處理中則回 409 或等待。key 的儲存要與業務寫入在同一個交易中,或用唯一約束保證。消費訊息端也一樣:以事件 id 去重或讓操作本身冪等(upsert、設定絕對值而非增量)。
快取和資料庫怎麼保持一致?
最常用 cache-aside:讀時 miss 從 DB 載入並寫快取;寫時「先更新 DB,再刪除快取」(刪除而不是更新,避免併發寫入造成舊值覆蓋新值)。仍有極小的競態視窗(讀到舊值的請求在刪除後才寫回快取),可以用延遲雙刪、短 TTL 兜底、或由 DB 的 CDC(binlog)驅動快取失效,確保失效一定發生。若業務無法接受任何舊值(例如餘額),就不要快取或只快取不可變資料。
如何做零停機的資料庫 schema 遷移或換資料庫?
用 expand-contract(擴張-收縮)模式:(1) 先加新欄位/新表(向後相容);(2) 程式改為雙寫(新舊都寫);(3) 回補歷史資料;(4) 影子讀取(同時讀新舊並比對差異);(5) 切換讀取到新的;(6) 停止寫舊的;(7) 最後刪除舊欄位/表。每一步都可回滾。大表 ALTER 在 MySQL 用 online DDL 或 gh-ost/pt-online-schema-change 避免長時間鎖表。
資料庫寫入撐不住了,有哪些選項?
依成本由低到高:(1) 優化:批次寫入、移除不必要的索引、縮小交易;(2) 垂直擴展:更大的機器、更快的磁碟;(3) 非同步化:前面加佇列削峰,worker 以可承受速率寫入;(4) 換儲存:append-only 的事件不該放 OLTP,改寫入 Kafka + 列式儲存;計數類改 Redis 定期落盤;(5) 分片:依 tenant 或 user 分片,代價是跨分片查詢與交易。面試時要說明為什麼選這個而不是下一個。
同步呼叫和訊息佇列,什麼時候用哪個?
呼叫者需要立即得到結果才能繼續(例如 bid 時查 user profile)→ 同步(REST/gRPC),並設 timeout 與降級。不需要立即結果、需要削峰、一個事件有多個消費者、需要可重播 → 佇列。佇列的代價:延遲增加、需要處理重複與順序、debug 變困難(需要分散式追蹤,trace id 要跟著訊息走)。詳見 第 11 章。
跨服務的交易怎麼做(例如扣預算 + 建立訂單)?
避免分散式 2PC(阻塞、協調者單點、效能差)。常用 Saga:把大交易拆成多個本地交易,每步有補償動作,失敗時反向補償。發送事件與寫 DB 要一致時,用 transactional outbox:業務資料與「待發事件」在同一個本地交易中寫入 DB,再由 relay(輪詢或 CDC)把 outbox 的事件送進 Kafka,保證「DB 有寫就一定會發事件」。消費端要冪等。詳見 第 10 章。
如果整個 region 掛掉呢?
先問業務需求:RTO(多久要恢復)與 RPO(能接受遺失多久的資料)。多數服務 multi-AZ 已足夠;需要跨 region 時:無狀態服務在兩個 region 都部署,DNS/全域 LB 做健康檢查切換;資料層用跨區非同步複寫(RPO 數秒)或全球分散式資料庫(延遲較高);Kafka 用 MirrorMaker 之類工具跨區複製。定期演練(game day)才能確認切換真的可行。AdTech 的 bidder 通常各 region 獨立運作,一個 region 掛了只是少了那區的流量。
你會監控哪些指標?
服務層用 RED(Rate、Errors、Duration)或 Google 的四個黃金訊號(latency、traffic、errors、saturation);資源層用 USE(Utilization、Saturation、Errors);佇列看 consumer lag;資料管線看新鮮度與資料量異常;業務指標(出價率、勝率、花費速度、推播成功率)往往最早發現問題。告警要基於 SLO(例如 error budget 消耗速率),避免告警疲勞。詳見 第 9 章。
流量變成 10 倍,你的設計哪裡先壞?
這題要你自己找瓶頸。系統性地沿著資料流檢查:入口(LB 連線數、TLS CPU)→ 無狀態服務(可水平擴展,通常不是問題)→ 共享依賴(DB primary 寫入、Redis 單一熱 key、外部 API 的 rate limit)→ 非同步管線(Kafka partition 數決定最大平行度,事後增加 partition 會改變 key 的分布)→ 儲存成本。回答時點出「最先壞的是 X,因為它是單一寫入點;解法是 Y」。
怎麼降低 p99 延遲?
先量測:分散式追蹤找出哪一段慢,profiling 找 CPU/配置熱點。常見手段:減少串行的網路呼叫(平行化、批次、合併);設定 timeout 並對冗餘副本發 hedged request;避免 GC 停頓與鎖競爭;預熱快取與連線池;把慢的工作移到非同步;控制佇列長度(排隊是尾延遲的主因)。也要注意 fan-out 放大效應:一個請求平行呼叫 100 個節點,只要每個節點有 1% 的機率慢,整體就有約 63% 的機率會慢。
Push 和 pull 模型怎麼選(例如設定下發、資料同步)?
Push(伺服器主動推)即時性好,但伺服器要知道所有接收者、處理接收者離線與背壓;pull(接收者定期拉)簡單、接收者自己控制節奏,但有延遲且空拉浪費資源。常見折衷:long polling 或「push 通知 + pull 資料」(通知有新版本,接收者再拉完整內容)。例如 bidder 的 campaign 索引:定期拉快照(可靠)+ 訂閱變更事件(即時)。
多租戶系統要注意什麼?
(1) 資料隔離:每層帶 tenant_id,由伺服器端從身分推導而非信任參數,必要時用 row-level security 或獨立 schema/資料庫;(2) 資源隔離:每租戶配額、限流、查詢資源上限,避免 noisy neighbor;(3) 大客戶專屬資源(獨立 partition、叢集);(4) 可依租戶設定的規則(schema、合併規則、頻次上限);(5) 依租戶計量計費與監控。Appier 的產品都是 B2B,這點幾乎每題都適用。
自我檢查表與延伸閱讀 基礎
面試中的自我檢查表
畫完架構後,用這張表快速掃一遍自己的設計,通常能找到面試官接下來要問的點:
| 類別 | 檢查問題 |
|---|---|
| 需求 | 我寫下功能/非功能需求與範圍外清單了嗎?數字假設有得到確認嗎? |
| 估算 | 峰值 QPS、儲存量、頻寬算了嗎?有用它們做出至少一個設計決策嗎? |
| API | 寫入 API 冪等嗎?非同步操作回 202 並提供狀態查詢嗎?有分頁、認證、限流嗎? |
| 資料 | 每個 store 的 key、分區方式、access pattern 清楚嗎?哪個是 source of truth? |
| 擴展 | 服務是否無狀態?哪裡是單一寫入點?熱點 key 在哪? |
| 可靠性 | 每個元件掛掉會怎樣?有沒有 timeout、重試(含退避與 jitter)、DLQ、降級? |
| 正確性 | 重複、亂序、遲到的訊息怎麼處理?哪些數字需要精確、哪些可近似? |
| 一致性 | 快取與 DB、批次與串流、跨服務之間的不一致會造成什麼後果?如何補償? |
| 維運 | 如何監控(指標、日誌、追蹤)?如何部署與回滾?如何 backfill? |
| 安全 | 認證授權、多租戶隔離、PII 處理、簽章防竄改? |
| 成本 | 最貴的元件是什麼?有便宜一個數量級的替代方案嗎? |
| 演進 | MVP 長什麼樣?10 倍流量時要改哪裡? |
練習方法
- 每題限時 45 分鐘,計時並大聲講出來(或錄音),模擬真實面試。
- 先畫出本章五個情境題的架構圖(不看筆記),再對照補漏。
- 對每題準備「一句話總結」:例如報表系統 =「SDK 批次至少一次送達 → 無狀態 collector → Kafka → Flink 去重聚合 + S3 原始資料 → ClickHouse 預聚合 → 批次校正」。
- 對每個元件練習回答「為什麼用它?拿掉會怎樣?替代方案是什麼?」。
- 找朋友互相扮演面試官,專門追問故障與邊界情境。
推薦延伸閱讀
- Designing Data-Intensive Applications(DDIA,Martin Kleppmann)
- 分散式資料系統的聖經:複製、分區、交易、一致性與共識、批次與串流處理。本章的 exactly-once、watermark、CDC、partitioning 都能在書中找到完整理論。強烈建議至少讀第 5–9 章與第 10–11 章。
- System Design Interview – An Insider's Guide Vol. 1 / Vol. 2(Alex Xu 等)
- Vol. 1 涵蓋 rate limiter、一致性雜湊、unique ID、URL shortener、web crawler、通知系統、news feed、chat 等;Vol. 2 有 ad click event aggregation(與本章情境題 1 高度相關)、即時排行榜、分散式 message queue、支付系統等。是面試題型的最佳練習本。
- 經典論文
- Amazon Dynamo(最終一致 KV、一致性雜湊)、Google Bigtable、Kafka 原始論文、Google 的 The Dataflow Model(event time、watermark 的理論基礎)、The Tail at Scale(尾延遲與 hedged request)、HyperLogLog 原始論文與 Google 的 HyperLogLog in Practice、Yahoo 的 Indexing Boolean Expressions(廣告 targeting 索引)、Google 的 Ad Click Prediction: a View from the Trenches 與 Facebook 的 Practical Lessons from Predicting Clicks on Ads at Facebook(CTR 預估工程)。
- 公司工程部落格(類型)
- 大型網路公司(Netflix、Uber、LinkedIn、Pinterest、Airbnb、Discord、Cloudflare)的技術部落格常寫真實的擴展故事與事故檢討;AdTech/MarTech 公司的部落格可看 RTB、bidder、即時競價與資料管線的實務;雲端供應商(AWS Architecture Blog、Google Cloud Blog)的參考架構可學習託管服務的組合方式。面試前也建議搜尋 Appier 公開的技術分享與產品文件,了解其產品線(CrossX、AIQUA、AIXON、AIDEAL)的使用情境。
- 工具文件
- Kafka、Flink、ClickHouse、Redis 官方文件中的「設計」或「架構」章節(例如 Flink 的 event time 與 watermark、ClickHouse 的 MergeTree 家族),比部落格文章更精確。
面試自測題
1. 每天 5 億筆事件、每筆 800 bytes,請估算平均與峰值 QPS、每日原始資料量、峰值入流頻寬。
平均 QPS = 5 × 108 ÷ 86,400 ≈ 5,800/s;以 4 倍估峰值約 23,000/s。每日原始資料 = 5 × 108 × 800 B = 400 GB。峰值頻寬 ≈ 23,000 × 800 B ≈ 18.4 MB/s ≈ 147 Mbps。結論:入口服務只需少量機器,但每日 400 GB、保留一年近 150 TB(未壓縮)→ 需要物件儲存 + 列式壓縮 + 預聚合,而不是放在 OLTP 資料庫。
2. 99.9% 與 99.99% 可用性每月分別允許停機多久?三個 99.9% 的服務串聯後整體可用性約多少?
以 30 天計:99.9% 約 43 分鐘、99.99% 約 4.3 分鐘。串聯可用性相乘:0.9993 ≈ 0.997,即約 99.7%,每月約 2.2 小時停機。所以關鍵路徑上的依賴越少越好,非關鍵依賴要能降級(失敗時回預設值而不是整個請求失敗)。
3. SDK 事件報表系統中,重複事件可能從哪些地方產生?你如何去重?
來源:SDK 重送(沒收到回應就重試)、collector 寫入 Kafka 成功但回應前崩潰、consumer 處理完但 commit offset 前崩潰。做法:client 端產生 event_id(UUID),重送時不變;串流層以 event_id 為 key 的 keyed state + TTL 去重(或 Bloom filter 節省空間);批次層對每日分區做精確 distinct;sink 以聚合 key upsert 讓重複輸出冪等。整體是 at-least-once 傳遞 + 冪等處理。
4. 什麼是 watermark?如何處理離線好幾天才上傳的手機事件?
watermark 是串流處理器對「event time 進度」的估計:watermark = W 表示認為 event time 早於 W 的事件已到齊,可以關閉並輸出結束時間早於 W 的視窗。常見設定為「看過的最大 event time 減去容許延遲」。稍晚的事件可透過 allowed lateness 更新已輸出的結果;超過容許範圍的事件走 side output 寫入 S3,由每日批次重算校正對應日期的報表。另外要過濾時間明顯錯誤的事件,避免把 watermark 推到未來。
5. 為什麼 unique users 不能直接把每天的數字相加?HyperLogLog 能解決哪些、不能解決哪些?
同一個人可能在多天出現,相加會重複計算。HLL 用固定大小(Redis 實作每個 key 約 12 KB)估算基數,標準誤差約 0.81%,且可以合併(聯集),所以可以存每日 HLL、查詢任意日期區間時合併,得到區間 UU。不能做的:相減(無法算「新使用者」)、可靠的交集(A 與 B 受眾重疊)、精確值(計費)。這些需要 bitmap(如 Roaring)或批次精確計算。
6. 為什麼 collector 要等 Kafka ack 才回 202?如果 Kafka 不可用怎麼辦?
202 代表「資料已持久化」,SDK 收到後就會刪除本地資料;若在寫入 Kafka 前就回 202,collector 崩潰就會遺失資料。使用 acks=all + min.insync.replicas=2 確保多副本持久化。Kafka 不可用時回 503,讓 SDK 依退避策略重試(SDK 有持久化佇列,不會遺失);或 collector 寫本地 spool 檔稍後補送,但要考慮容器磁碟的生命週期。
7. RTB bidder 的 100ms 延遲預算你會怎麼分配?有哪些關鍵手段?
扣除與 exchange 之間的網路往返(數十 ms),bidder 內部只剩數十 ms:解析與前置過濾約 1ms、平行查詢 user profile 與頻次 2–5ms、記憶體倒排索引召回約 1ms、批次模型推論 5–10ms、出價計算約 1ms,其餘為尾延遲餘裕。手段:整體 deadline 傳遞、下游以剩餘時間當 timeout、平行化、能放記憶體就不遠端查、限制候選數、減少 GC,超時直接回 no-bid。
8. 上百台 bidder 同時為同一 campaign 出價,如何避免嚴重超支又不讓中央服務成為瓶頸?
預算租約:budget service 用原子操作(Redis Lua)把剩餘預算切成小額度發給各 bidder,bidder 本地扣減,用完再申請,預算愈少發得愈小;實際花費以 win notice 事件經串流累加,作為 ledger 的 source of truth;預算耗盡時廣播停止。超支上限約為所有未結清額度加在途出價,可透過額度大小控制。再搭配 pacing(依計畫花費曲線調整出價機率)避免太早花完。
9. 用 Redis 實作 frequency capping,讀寫時機為何?Redis 故障時怎麼辦?
每個 user 一個 Hash(field = campaign:日期),bid 時一次 HMGET 取回候選 campaign 的計數並過濾;曝光事件到達時才 HINCRBY 並設 TTL(出價不等於曝光)。這會有短暫時間差,可能超出上限一兩次,通常可接受。Redis 故障:依 campaign 設定 fail-open(照常出價,可能過度曝光)或 fail-closed(不出價),並告警;也可把計數與 user profile 存在同一個 KV 以減少依賴。
10. Chatbot 平台的 webhook 為什麼要立即回 200?如何保證同一對話的訊息順序?
渠道平台對 webhook 回應有時限,逾時會重送甚至停用 webhook;LLM 回應需要數秒,不能在 webhook 請求內同步處理。gateway 只驗簽、正規化、寫入 Kafka 後立即回 200。順序:以 conversation_id 為 partition key,同對話進同 partition;worker 端用 keyed executor 讓「不同對話並行、同對話序列」,避免 head-of-line blocking,offset 只 commit 到最小未完成的位置;出站同樣依對話分區並帶 seq。
11. 如何控制 chatbot 的 LLM 成本?
分層路由(按鈕/規則 → 小模型意圖辨識 → 必要時才用 LLM);FAQ 類問題做回答快取或語意快取;精簡 prompt、用滾動摘要取代完整歷史;利用 provider 的 prompt caching;依問題難度選擇模型大小;debounce 合併連續訊息;每租戶配額與全域併發上限;監控每租戶 token 用量並計費。
12. 大量推播時,如何避免同一使用者收到重複訊息?
三道防線:(1) API 層 Idempotency-Key,防止呼叫端重試建立重複任務;(2) 系統內以 (campaign_id, user_id) 為唯一鍵,發送前以條件寫入標記 SENDING,已存在則跳過;(3) 對上游使用冪等機制,例如 LINE Messaging API 的 X-Line-Retry-Key,在 batch 建立時產生並持久化,逾時重試時沿用同一個 key,上游回 409 代表已接受過、視為成功。並以名單快照確保重跑時對象不變。
13. 上游 API 有速率限制,而你有 50 台 sender worker,怎麼做全域限流?收到 429 怎麼辦?
用 Redis + Lua 的 token bucket 作為集中式限流器,每次發送前取 token(原子操作,用 Redis TIME 避免時鐘差異);或由協調者把總速率平均分配給各 worker 做本地限流,worker 數變動時重新分配(延遲更低)。收到 429 時尊重 Retry-After,並以 AIMD 降低速率(減半後緩慢回升);交易型訊息與行銷訊息分開佇列或保留額度,避免互相影響。
14. 合併兩個 List[dict](依 id),資料量到上億筆時怎麼辦?
小規模:以 id 建 dict,O(n+m) 合併,先定義衝突規則(較新者勝、非空優先、多值聯集)。上億筆:核心是「相同 id 落在同一個處理單元」。單機可用 hash 分桶(穩定 hash 如 crc32,不可用 Python 內建 hash)後逐桶合併,或外部排序後雙指標 merge join;分散式用 Spark 依 id shuffle(groupBy/join),注意資料傾斜(清理無效 id、salting、AQE)與只處理增量。若需線上查詢,結果 bulk load 到 KV。
15. 身分解析中的「超級節點」是什麼?為什麼錯誤合併比漏合併更危險?
超級節點是被大量使用者共用的識別碼(網咖電腦的裝置 ID、test@test.com、預設電話號碼),在 identity graph 中會把成千上萬人串成同一個連通分量。對策:度數上限、黑名單、只信任可靠類型的邊。錯誤合併會把 A 的個資、訂單、推播發給 B,造成隱私事故與客訴,而且拆分(unmerge)困難;漏合併只是少了一些個人化。所以預設採確定性規則,並保存原始邊以便重算與拆分。
16. 比較 token bucket 與 sliding window counter,並說明為何分散式限流要用 Lua。
Token bucket 以固定速率補充 token,允許最多 bucket 容量的突發,長期平均速率受控,適合 API 限流與上游配額;sliding window counter 以「前一窗計數 × 重疊比例 + 本窗計數」近似滑動視窗,省記憶體、無固定視窗的邊界突刺問題,但為近似值。分散式時「讀取狀態 → 計算 → 寫回」若分成多個命令,多台 gateway 會讀到相同狀態而超賣;Redis 執行 Lua 腳本期間不會穿插其他命令,因此整段是原子的。
17. Snowflake ID 的結構?時鐘回撥會造成什麼問題?
64 bit:1 bit 符號、41 bit 毫秒時間戳(約 69 年)、10 bit 機器 ID(1,024 個)、12 bit 序號(每毫秒每機器 4,096 個)。ID 依時間大致遞增,對 B-tree 索引友善。時鐘回撥時,新產生的時間戳可能與之前相同或更小,加上序號重置就可能產生重複 ID。對策:偵測到回撥時等待追上(小幅)或拒絕產生(大幅)並告警、NTP 設定只做緩慢調整、備用 worker id。
18. 什麼是 cache stampede、avalanche、penetration?各自如何防範?
Stampede(擊穿):熱門 key 過期瞬間大量請求同時回源 → single-flight/互斥鎖只讓一個請求回源、提前非同步刷新。Avalanche(雪崩):大量 key 同時過期或快取叢集故障 → TTL 加 jitter、多層快取、DB 前限流與熔斷。Penetration(穿透):查詢不存在的資料每次都打 DB → 快取空值(短 TTL)、Bloom filter 預先過濾。
19. 報表系統中,串流(Flink)與批次(Spark)為什麼常常同時存在?缺點是什麼?
串流提供分鐘級新鮮度但受遲到資料與故障影響;批次在資料到齊後從 S3 原始資料精確重算,作為最終與計費依據,也方便回補與修 bug。缺點是兩套邏輯可能不一致、維護成本高;緩解方式是共用 SQL/函式庫,或在 Kafka 保留期足夠長、串流作業穩定時改採 Kappa 架構(只用串流,重算時重播)。
20. 系統設計面試前 5 分鐘你會做什麼?請示範一段開場。
宣告流程並釐清需求。示範:「我想先花幾分鐘確認需求與規模,再估算、定 API 和資料模型、畫架構,最後深入兩三個瓶頸。先確認功能:報表需要哪些維度和指標?要即時還是隔日?是否多租戶?非功能:每天大約多少事件?unique users 可以接受近似嗎?資料保留多久?如果沒有特定數字,我先假設每天 10 億事件、報表 5 分鐘延遲、UU 可接受 1% 誤差,您看這樣合理嗎?」把假設寫在白板左上角,後續設計都引用它。