Spotify 熱門歌曲排行 (Design Spotify Trending Songs)
設計一個 收集海量收聽事件 → 依維度(country / genre)產出 Top K 熱門歌曲 的分析系統。本質是 write-heavy ingest(~1.2M events/sec)+ 重量級分析聚合(每小時掃 ~4.2B 筆) 的組合題,難點不在 CRUD 而在 「怎麼把分析工作負載和線上寫入解耦」。
招牌取捨:不直接掃 OLTP,而是把資料經 CDC 送進 data lakehouse、用 OLAP + 批次(Spark)聚合——放棄分鐘級新鮮度(整點後等 10~20 分鐘),換取「42 億筆聚合能在時限內跑完、且不和線上 event writes 搶資源」。若要把新鮮度壓到 < 1 分鐘,再升級成 Flink streaming(增量計算 + materialized view),犧牲架構簡單性與離線重算便利。其餘決勝點:高吞吐 ingest(Cassandra/Dynamo + 批次上傳 + 離線暫存)、有效播放定義(30s sessionization 去除 over-count)、Top K 預計算 + cache、OLAP 依 dim 分片 + sub-sharding 解 hot shard。
配速(35 min 預算):~5 min 釐清需求 → ~5 min 容量估算 → ~3 min API + ~3 min 資料模型 → ~7 min 高階架構(ingest + pipeline)→ ~8 min deep dive(新鮮度 / 有效播放 / 擴展)→ ~3 min 擴展瓶頸 → ~1 min 收尾。開場先一句話複述題目、宣告配速、把「有效播放」「新鮮度」標記為待 deep dive——宣告配速本身就是高分動作。
1. 釐清需求與範圍 (Clarify & Scope ~5 min)
開場後先框定邊界。這題最容易被帶歪的地方是 誤把它當成一般 CRUD / search 題,務必先確認「核心是分析聚合」。建議問題:
| 釐清問題 | 為何要問 / 影響到哪個決策 | 本題假設(依來源) |
|---|---|---|
| 要回報什麼?事件長怎樣? | 決定 ingest schema 與去重邏輯 | 收聽事件:event_id, session_id, track_id, position_ms, state... |
| Top K 要依哪些維度切? | 決定聚合 group-by 與 OLAP 分片 key | dim = country / genre 等;K 例如 100 |
| 規模多大? | 直接驅動估算與「能不能直接掃 DB」 | ~7 億 MAU、1 億 tracks |
| 新鮮度要求?整點後多久要有結果? | 決定 batch vs stream | ASAP;可接受分鐘級,理想 < 1 min |
| 資料準確性要求? | 決定去重 / 有效播放 / reconcile | 高準確:避免 over-count / under-count |
| 「一次播放」怎麼算?整首?片段? | 決定有效播放(effective play)定義 | 至少播 30 秒才算一次 stream |
| 離線 / 弱網使用者的事件要不要保證送達? | 決定 client 端暫存與批次 | 要:離線先存本地,連線後 flush |
「有效播放怎麼定義 和 怎麼把新鮮度壓到 1 分鐘 我先標記,等 deep dive 再展開——現在先把 ingest → pipeline → top-K serving 的端到端骨架串起來。」把兩個最硬的點推遲,保住架構敘事連貫。
2. 功能性與非功能性需求 (FR / NFR 與設計北極星)
-
功能性需求 (FR)
- 從使用者端 收集收聽指標(listening metrics)。
- 依 不同維度(country / genre …) 提供 Top K(例如 100) 歌曲。
- 每小時 / 每日結束後,盡快(ASAP) 產出並提供 Top K。
-
非功能性需求 (NFR)
- 超高寫入吞吐:~7 億 MAU → 估 ~1.2M events/sec(見估算)。
- 資料準確性:避免 over-counting / under-counting(去重 + 有效播放)。
- 盡快產出 Top K:可接受分鐘級,理想 < 1 分鐘 新鮮度。
- 隱含:read 模式固定(就那幾種 Top K 查詢)→ 可預計算 + cache。
設計北極星(NFR 直接導出後續所有選型的判準):
| NFR | 導出的設計北極星 | 後續對映選型 |
|---|---|---|
| 超高寫入吞吐 | ingest 路徑要能水平擴展、寫入優先的儲存 | stateless server + Cassandra/Dynamo、client 批次 |
| 重量級聚合(掃 ~42 億/hr) | 分析必須離線、和線上寫入解耦、用 column-oriented 引擎 | OLAP + CDC + Spark(不直接掃 OLTP) |
| 高準確(不可多算少算) | 去重 + 有效播放定義 + 可離線重算 reconcile | event_id 去重、30s sessionization、保留 batch layer |
| ASAP / <1min 新鮮度 | 要更快就走 streaming 增量計算 | Flink streaming + materialized view + cache |
| 讀模式固定 | 預計算 Top K,讀直接命中 cache | OLAP materialized view → worker 刷 cache |
「這是一個 write-heavy ingest + 離線重量級聚合 + 固定模式讀 的系統」——記住這三段,後面每個選型都能推回去:寫要分散、聚合要解耦進 OLAP、讀要預計算 + cache。
3. 容量估算 (Back-of-Envelope ~5 min)
估算的目的是 驅動「能不能直接掃 OLTP」這個關鍵決策。回連 02-Distributed-Systems/05-Numbers-to-Know。
每小時事件量(來源給的核心算式)
假設 700M MAU 中 20% 每天聽 1 小時
每使用者 1 小時 → 3600 秒 ÷ 5 秒/事件 = 720 events/hour
活躍使用者數 = 700M × 20% = 140M
每小時事件 = 140M × 720 ÷ 24(分散到一天 24 小時)
≈ 4.2B events / hour
換算每秒 ingest QPS
4.2B events/hour ÷ 3600 s ≈ 1.2M events / sec (ingest 的真正壓力源)
即使 client 端批次(每 5 筆送 1 request),server 仍需每秒處理數十萬請求
下列數字皆為 推導值:來源給定的輸入是 700M MAU / 1 億 tracks / 20% 活躍 / 每 5 秒 1 事件 / 至少 30s 算一次 stream / K=100;4.2B/hr、1.2M/sec、單小時掃描量皆由此推算。
| 維度 | 數字 | 對後續決策的意義 |
|---|---|---|
| Ingest 吞吐 | ≈ 1.2M events/sec(推導值) | 寫入是主壓力 → 寫優先儲存 + stateless server 水平擴展 |
| 每小時聚合掃描量 | ≈ 4.2B events/hour(推導值) | 直接掃 OLTP 不可行 → 必須 OLAP + 批次(招牌取捨的起點) |
| 維度數(group-by) | country / genre …(少數) | 聚合輸出量遠小於 raw → Top K 結果可預計算 + cache |
| 讀(Top K 查詢) | 模式固定、量遠小於寫 | 讀不是瓶頸 → 預計算 + cache 即可 |
4.2B/hour 這個數字直接否決「讓 workers 每小時直接掃 OLTP」。傳統 OLTP 是 row-based、為 point lookup 最佳化,掃這種量會 timeout,且分析查詢與線上 event writes 搶同組資源是 antipattern。能說清「為什麼不能掃 OLTP」就贏了一半——這正是引入 OLAP 的理由。對映 06-Database-Tech/04-OLTP-vs-OLAP。
4. 用到的積木 / Building Blocks Used
架構即目錄:先給導覽圖,再展開。下表每個決策都對映一個既有 concept note(無 alias 連結);本表即「本案例練到哪些積木」的單一事實來源(single source of truth)。
| 設計決策 | 積木 | 在本題的作用(≤1 行) |
|---|---|---|
| 容量估算驅動選型 | 02-Distributed-Systems/05-Numbers-to-Know | 算出 ~1.2M/sec、~4.2B/hr,否決「直接掃 OLTP」 |
| 分析與線上工作負載解耦 | 06-Database-Tech/04-OLTP-vs-OLAP | OLTP 接寫入、OLAP(column store)扛聚合查詢 |
| 寫優先的事件儲存 | 06-Database-Tech/03-DynamoDB | 高寫入吞吐用 Dynamo/Cassandra(LSM、寫優先) |
| 寫入路徑底層引擎 | 04-Database-Indexing/03-LSM-Tree | LSM 讓 1.2M/sec 寫入吞吐可行 |
| 批次 / 串流聚合管線 | 09-Messaging-Coord/01-Data-Pipeline | CDC→Kafka→lakehouse→Spark/Flink 的整套 pipeline |
| 事件解耦 / 重播 / 串流來源 | 09-Messaging-Coord/03-Kafka | CDC change log 與 raw events 的 buffer / 串流輸入 |
| 高吞吐 ingest 緩衝 | 09-Messaging-Coord/02-Queue | server 直接發事件到 queue,吸收尖峰、解耦消費 |
| 不丟事件 / 去重一次計數 | 12-Ops-and-Reliability/03-Reliable-Delivery | event_id 去重 + 離線暫存 flush,保證 exactly-once 計數 |
| stateless ingest 水平擴展 | 02-Distributed-Systems/03-Scalability | server 無狀態,auto-scaling 依 CPU/memory 擴縮 |
| OLAP 分片 + hot shard 處理 | 05-Database-Advanced/02-Sharding | 依 dim 分片,US 等熱點再 sub-shard(US-1…US-N) |
| Top K 結果讀加速 | 07-Caching-Storage/01-Caching | 固定查詢預計算後快取,讀直接命中、避免重複打 OLAP |
| 流量分散 | 11-Infrastructure/02-Load-Balancer | LB 把 ingest 請求隨機分到多台 stateless server |
5. API 設計 (API Design ~3 min)
只有兩個核心 endpoint:一個收事件(write),一個取榜(read)。理據見 03-API-Design/02-REST。
| Method | Path | 用途 | 重點 |
|---|---|---|---|
POST |
/tracks/events |
回報播放事件(可批次) | body 帶 {event_id, session_id, track_id, position_ms, state, ...};event_id 供去重 |
GET |
/tracks/top_tracks?dim={dim}&num_tracks={num_tracks} |
取指定維度過去一小時 Top K | dim=country/genre,num_tracks=K(例 100);直接命中 cache |
POST 設計成 可帶一批事件(client 每累積 5 筆送 1 request)——這把 1.2M/sec 的請求數壓低,節省網路與連線成本;event_id 是去重的關鍵欄位,讓重送(離線 flush / retry)也不會多算。
6. 資料模型與儲存選型 (Data Model & Storage Choice ~3 min)
資料沿著 pipeline 變形三次:raw event(OLTP)→ lakehouse(Parquet/Iceberg)→ 聚合結果(OLAP)。
RawEvent(寫入端,OLTP / Cassandra / Dynamo)
--------------------------------------------------
event_id STRING 唯一識別,供 deduplication
session_id STRING 一次連續收聽,供 sessionize / 有效播放判斷
track_id STRING 被播放的歌曲
position_ms BIGINT 播放位置(毫秒)
state ENUM play / pause / stop / heartbeat …
ts, device, region ... 輔助欄位(地區 → 之後的 dim)
--------------------------------------------------
append-only;~1.2M/sec 寫入
PlayFact(有效播放,stream processor 產出 → OLAP)
--------------------------------------------------
session_id, track_id, dim(country/genre), event_time
→ 每滿 30 秒有效播放產生一筆,去除 over-count
AggCount(OLAP materialized view,按粒度預聚合)
--------------------------------------------------
(dim, time_bucket[1m/1h/1d], track_id) → play_count
→ Top K worker 依此排序取前 K,刷進 cache
儲存選型:
| 階段 | 選項 | 適配? | 理由 |
|---|---|---|---|
| Ingest(raw) | Cassandra / DynamoDB(LSM 寫優先) | ✅ 主 | 1.2M/sec 寫入,需要寫吞吐極高、可水平擴展的儲存 |
| Ingest(raw) | 傳統 OLTP(row-based) | △ | 寫得進,但 不能拿來掃聚合(point-lookup 最佳化) |
| Lakehouse(raw 落地) | Object Storage + Iceberg/Parquet | ✅ 輔 | append-only events 落 S3,Iceberg 處理去重 / snapshot / schema 演進 |
| 分析(聚合查詢) | OLAP(column-oriented) | ✅ 主 | 只讀相關欄位、欄級壓縮 → analytical query 極快 |
→ 積木:OLTP/OLAP 分工見 06-Database-Tech/04-OLTP-vs-OLAP;寫優先儲存見 06-Database-Tech/03-DynamoDB 與底層 04-Database-Indexing/03-LSM-Tree;raw events 落地與整套 pipeline 見 09-Messaging-Coord/01-Data-Pipeline。
Lakehouse 裡資料雖以 Parquet(column-oriented format) 存,但 Parquet 只是檔案格式,放在 S3 上 沒有 index / 低延遲查詢 / SQL 介面。Spark 在此的工作是 大規模聚合(把數十億 raw events 彙總成每分鐘 play count),不是單純格式轉換;聚合結果寫入 OLAP 才能支援後續低延遲查詢。
7. 高階架構 (High-Level Design ~7 min)
兩條主軸:(A) 高吞吐 ingest、(B) 聚合 pipeline → Top K serving。
(A) 事件回報 flow(高吞吐 ingest)
User Device
│ 每 5 秒產生事件;每累積 5 筆批次送出(省網路)
│ 離線 → 先寫 local DB(SQLite) → 連線後 flush
▼
Load Balancer ──► Stateless Servers (N 台, auto-scale)
│ 寫入(event_id 去重)
▼
Event DB (Cassandra / Dynamo, 寫優先 LSM)
(B) 批次聚合 pipeline(招牌路徑)
Event DB ──CDC connector──► Kafka ──► Data Lakehouse (S3 + Iceberg/Parquet)
(change data capture) │ │ (Flink/Spark 整理 raw → sink)
│ ▼
│ ┌──────────────────────────┐
└─────►│ Spark batch job(每~10 min)│ ← map-reduce 並行
│ 把 raw events 聚合到每分鐘 │
└─────────────┬────────────┘
▼
OLAP DB (column store)
│ worker 查詢 + 排序取 Top K
▼
Cache ──► GET /top_tracks
(C) Scaled 架構(端到端)
(write path, ~1.2M/sec)
Devices ─► LB ─► Stateless Servers ─► Event DB(Cassandra/Dynamo)
│
CDC │
▼
Kafka (change log buffer)
│
▼
Data Lakehouse (S3 + Iceberg, Parquet)
│
┌───────────────────┴───────────────────┐
│ Spark batch(~10min,分鐘級新鮮度) │ ← 招牌方案
│ ── 或 ── Flink streaming(秒級,<1min)│ ← deep dive 升級
└───────────────────┬───────────────────┘
▼
OLAP DB(依 dim 分片 + sub-shard 解 hot)
materialized view by 粒度 (1m / 1h / 1d)
│
Top-K worker(定期查詢 + 排序)
▼
Cache ─► GET /top_tracks(讀全命中)
關鍵 hand-off:write path(LB→stateless server→Event DB)和 analytics path(CDC→Kafka→lakehouse→Spark/Flink→OLAP)完全解耦——這是整題的脊椎。線上寫入永遠不和重量級聚合搶資源。
8. 深入探討 (Deep Dives ~8 min)
通用機制(batch vs stream 的本質差異、CDC 細節、LSM 寫入、sharding 原理)只在 concept note 解釋;這裡只講「本題怎麼用、選哪個、為什麼」。本題無 home note 的新材料(有效播放 sessionization、lakehouse + Iceberg、依粒度的 materialized view)才在此 inline 教學並登記到 new_concepts_introduced。
8.1 如何把資料新鮮度壓到 < 1 分鐘(招牌取捨)
問題:batch(Spark)方案能在數分鐘內出結果,但有頻率下限。
為何是瓶頸:每次 batch job 都要付固定成本——啟動 overhead、query planning、檔案 I/O、JVM/GC/cache warmup;scheduler 最小排程通常 1 分鐘,實務上加上 overhead 較合理是每 10 分鐘跑一次。再加上 CDC 把資料匯入、batch 把結果寫進 OLAP,整點後通常要等 10~20 分鐘 才有最新 partition。
| 方案 | 做法 | 新鮮度 | 取捨 |
|---|---|---|---|
| A. Batch(Spark) | CDC→lakehouse→Spark 每 ~10 min 聚合→OLAP | 10~20 min | 簡單、易離線重算 / reconcile;但有 startup overhead 下限 |
| B. Stream(Flink) | server 直接發 Kafka→Flink 逐筆處理→PlayFact 寫 OLAP | <1 min(常 <10s) | 即時;但長存 operator state、運維複雜、重算不如 batch 直接 |
選擇與理由:預設用 A(batch)——對 top songs 這個 use case,10~20 分鐘新鮮度通常可接受,且 batch 易於離線重算與 reconcile。要更新鮮才升級到 B:移除 OLTP、server 直接把事件發到 Kafka,Flink 逐筆 dedup / sessionize、把有效播放(PlayFact)寫入 OLAP;OLAP 對不同粒度(1m / 1h / 1d)預先計算 materialized view(過去 1 小時用 1m 粒度、過去 1 天用 1h 粒度);另一個 worker 以秒級頻率查 OLAP 刷 cache,Top K 直接從 cache 回。streaming 之所以快,是因為 batch 每次都要重付啟動 / planning / commit 成本,streaming 只付一次(long-lived operators 把 state 放在 memory/RocksDB,Kafka 提供連續增量輸入,checkpoint 在背景短間隔執行)。這就是本題的 core_tradeoff:新鮮度 vs 簡單 / 重算便利。
→ 積木:09-Messaging-Coord/01-Data-Pipeline(batch/stream、Lambda vs Kappa)、09-Messaging-Coord/03-Kafka、06-Database-Tech/04-OLTP-vs-OLAP
因為 有很多 abuse detection(人為刷流 artificial streaming)分析無法即時完成,需要離線重新處理並與 serving data reconcile。streaming 給即時近似值,batch 給離線權威值——這正是 Lambda 架構 的動機:保留 batch layer 做最終正確性與重算。見 09-Messaging-Coord/01-Data-Pipeline。
8.2 如何計算「有效播放次數」(effective play counts)
問題:若只對每首歌數 unique events,會 over-count。
為何是瓶頸:使用者播一首歌後立刻 seek 到最後幾秒就按下一首,這不該算一次有效播放;準確性是硬 NFR。
Spotify 對 stream 的定義:「至少播放 30 秒才算一次」。實作方式:在 stream processor 中,為 每個使用者的每個 track 維護 per-session state,每個 session 維護 accumulated_ms。裝置持續送相同 session_id 的事件;當 accumulated_ms 超過 30 秒門檻,才產生一筆 PlayFact event(代表一次有效播放)。
時間線範例:t=0 Play(A,S1) → t=10/20/30 heartbeat(B/C/D,S1) → t=30 達標 → S1 計 1 次 → t=75 Replay(E,S2) → 再次達標可再計。
選擇與理由:用 per-session accumulated_ms + 30s 門檻 在 stream processor 內判定有效播放,產出 PlayFact 再進 OLAP。這只是避免 over-count 的簡化版;真實實作更複雜,且 abuse detection 多半要靠 batch 離線重算(見 8.1)。準確性的另一半是 不重複計數:靠 event_id 去重 + 離線 flush 的冪等寫入。
→ 積木:12-Ops-and-Reliability/03-Reliable-Delivery(去重 / exactly-once 計數)、09-Messaging-Coord/01-Data-Pipeline(stateful stream processing)
不會——關鍵在 event_id 去重。client 離線時把事件暫存 local DB(SQLite),重連 flush;即使同一批被重送,下游靠 event_id 做 dedup,同一事件只計一次。這把「可靠送達」和「不多算」綁在一起。見 12-Ops-and-Reliability/03-Reliable-Delivery。
8.3 如何擴展到承受 1.2M events/sec 的吞吐
問題:~4.2B/hr ≈ 1.2M/sec,即使批次送出,server 仍每秒處理數十萬請求。
為何是瓶頸:ingest、stream 處理、OLAP 三層都可能被吞吐壓垮。
| 層 | 擴展手段 | 取捨 / 注意 |
|---|---|---|
| Server | stateless → horizontal scaling,請求隨機路由,auto-scale by CPU/mem | 無狀態最容易擴;前面擺 LB |
| Stream(Flink) | 每個 worker 維護 session state → 用 session_id 當 partition key |
每 session 事件數有限 → 負載相對平均;用對 key 才不傾斜 |
| OLAP | 增加節點水平擴展,用 dim 當 sharding key(查詢多依 dim filter/group) |
僅以 dim 分片易 hot shard:US 量遠大於 Taiwan |
選擇與理由:三層各自水平擴展。OLAP 的關鍵細節是 hot shard——country 維度下 US 的事件量可能遠高於小國,全壓一個 shard。解法:為預期的 hot shard 加 sub-sharding,把 US 拆成 US-1, US-2, … US-N,讓美國的資料分散到多個 shard,而非集中單一節點。
→ 積木:02-Distributed-Systems/03-Scalability(stateless server)、05-Database-Advanced/02-Sharding(dim 分片 + sub-shard 解 hot)
9. 擴展與瓶頸 (Scaling & Bottlenecks ~3 min)
主動找出系統會先在哪裂開並補強。
| 瓶頸 / 風險 | 補強手段 | 積木 |
|---|---|---|
| Ingest server 算力不足 | stateless + auto-scaling,LB 前置隨機路由 | 02-Distributed-Systems/03-Scalability、11-Infrastructure/02-Load-Balancer |
| Event DB 寫入吞吐瓶頸 | 寫優先儲存(Cassandra/Dynamo, LSM) + 依 key 分散 | 06-Database-Tech/03-DynamoDB、04-Database-Indexing/03-LSM-Tree |
| 直接掃 OLTP 拖垮線上寫入 | CDC 把資料搬到 lakehouse + OLAP 分析,工作負載完全解耦 | 06-Database-Tech/04-OLTP-vs-OLAP、09-Messaging-Coord/01-Data-Pipeline |
| OLAP 單一 dim shard 過熱(US >> 小國) | sub-sharding(US-1…US-N)打散熱點 | 05-Database-Advanced/02-Sharding |
| 新鮮度不夠(batch 10~20 min) | 升級 Flink streaming + materialized view + 秒級刷 cache | 09-Messaging-Coord/01-Data-Pipeline、09-Messaging-Coord/03-Kafka |
| Top K 查詢重複打 OLAP | 預計算結果 cache,固定查詢直接命中 | 07-Caching-Storage/01-Caching |
| 即時計數無法抓刷流 / 偶發錯算 | 保留 batch layer 離線重算 + reconcile(Lambda) | 09-Messaging-Coord/01-Data-Pipeline、12-Ops-and-Reliability/03-Reliable-Delivery |
「系統會 先在『分析查詢』裂開(4.2B/hr 直接掃 OLTP 必死)→ 所以我把分析經 CDC 解耦進 OLAP;ingest 用 stateless server + 寫優先 DB 扛 1.2M/sec;OLAP 的 hot shard 用 sub-sharding 解。要更新鮮就升 streaming,但保留 batch 做權威重算。」先指出最可能裂開處再補強,比逐項羅列更有說服力。
10. 取捨總表與收尾 (Trade-off Recap & Wrap-up ~1 min)
| 決策點 | 選擇 | 放棄了什麼 | 為什麼值得 |
|---|---|---|---|
| 分析架構 | OLAP + CDC + Spark batch | 分鐘級新鮮度(等 10~20 min) | 42 億筆聚合可在時限內完成、不和線上寫入搶資源(招牌取捨) |
| 新鮮度升級 | Flink streaming(可選) | 架構簡單 / 離線重算便利 | 把新鮮度壓到 <1 min(甚至 <10s) |
| 正確性 | 30s sessionization + event_id 去重 + batch reconcile | 純即時的單一真相 | 不 over/under-count、可離線抓刷流 |
| Ingest 儲存 | Cassandra/Dynamo(寫優先) | 關聯式查詢便利 | 扛 1.2M/sec 寫入 |
| OLAP 分片 | dim 分片 + sub-shard | 單純 dim 分片的簡單 | 解 US 這類 hot shard |
| Top K serving | 預計算 + cache | 每次即時查 OLAP | 讀模式固定、低延遲、省 OLAP 負載 |
「整題的單一錨點是 分析工作負載必須和線上寫入解耦:4.2B/hr 不可能直接掃 OLTP,所以我走 CDC → lakehouse → OLAP + Spark batch,預設接受 10~20 分鐘新鮮度。若要 <1 分鐘,我升級成 Flink streaming + materialized view + 秒級刷 cache,但保留 batch layer 做離線重算與抓刷流——這就是 batch 與 stream 的取捨。其餘是標準打法:寫優先 DB + stateless server 扛 1.2M/sec,OLAP 依 dim 分片並對 US 這種熱點 sub-shard,Top K 預計算進 cache。」引用既有決策用連結,不重新推導。
後續追問演練 (Interviewer Follow-ups — "What if…")
每小時 ~4.2B 筆,OLTP 是 row-based、為 point lookup 最佳化,這種 table scan 會 query timeout;就算跑得完,分析查詢和線上 event writes 搶同組資源是 antipattern,會拖垮整體。所以引入 OLAP(column-oriented,只讀相關欄位 + 欄級壓縮) 並用 CDC 解耦。見 06-Database-Tech/04-OLTP-vs-OLAP。
700M MAU × 20% 活躍 = 140M;每人聽 1 小時 = 3600÷5 = 720 events;140M × 720 ÷ 24 ≈ 4.2B events/hour;÷3600 ≈ 1.2M events/sec(皆推導值)。回連 02-Distributed-Systems/05-Numbers-to-Know。
Spotify 定義 至少播 30 秒 才算一次。在 stream processor 為每個 user×track 維護 per-session accumulated_ms,超過 30s 才產出一筆 PlayFact。這擋掉「播一下就 seek 到結尾按下一首」的假播放。見 8.2 與 09-Messaging-Coord/01-Data-Pipeline。
改 streaming:移除 OLTP,server 直接發 Kafka,Flink 逐筆 dedup/sessionize → PlayFact 寫 OLAP,OLAP 按 1m/1h/1d 粒度預算 materialized view,worker 秒級刷 cache,端到端常 <10s。代價是 long-lived operator state(memory/RocksDB)的運維複雜度上升、離線重算不如 batch 直接。見 09-Messaging-Coord/01-Data-Pipeline、09-Messaging-Coord/03-Kafka。
經典 hot shard。解法:對預期熱點做 sub-sharding,把 US 拆成 US-1, US-2, … US-N,讓美國事件分散到多個 shard 而非集中單一節點。其他小國維持單片即可。見 05-Database-Advanced/02-Sharding。
不丟也不重複。離線時事件先寫 local DB(SQLite),連線後 flush;下游靠 event_id 去重,同一事件只計一次(冪等)。可靠送達 + 去重一起保證準確。見 12-Ops-and-Reliability/03-Reliable-Delivery。
因為 abuse detection(artificial streaming / 刷流)等分析無法即時完成,需離線重新處理並與 serving data reconcile。streaming 出即時近似值、batch 出離線權威值——這就是 Lambda 架構。砍掉 batch 等於放棄最終正確性與重算能力。見 09-Messaging-Coord/01-Data-Pipeline。
依序:(1) ingest——stateless server auto-scale + Event DB 依 key 再分散(寫優先 LSM 撐吞吐);(2) stream——以 session_id 為 partition key 增加 Flink worker;(3) OLAP——增節點 + 對更多 hot dim sub-shard;(4) cache——Top K 結果預計算擴容。瓶頸通常先落在 OLAP 聚合與 hot shard。見 05-Database-Advanced/02-Sharding、02-Distributed-Systems/03-Scalability。
因為 raw events 是 append-only,Iceberg 這類 table format 很適合處理 deduplication、snapshot versioning、schema evolution,並提供可重算的歷史快照(batch 重跑的來源)。Parquet 是 column-oriented 檔案格式利於 Spark 大規模聚合。注意 Parquet/S3 本身 沒有 index / 低延遲查詢 / SQL,所以聚合後仍要寫進 OLAP 才能低延遲查。見 09-Messaging-Coord/01-Data-Pipeline。
主動回想:白板重建 (Whiteboard from Scratch)
不看上文,照下面提示把整個設計重建一次。每格都該能 30 秒內口述。
常見陷阱 (Exam Traps for This Problem)
What:用一組 workers 每小時直接掃 OLTP / 主資料庫做 aggregate + sort。
Why:每小時 ~4.2B 筆,OLTP row-based 為 point lookup 最佳化,掃這量會 timeout;分析與線上寫入搶資源是 antipattern。
正確做法:CDC 解耦 → lakehouse → OLAP + Spark 聚合,分析永遠不碰線上寫入路徑。見 06-Database-Tech/04-OLTP-vs-OLAP。
What:直接 count distinct events / 收到事件就 +1。
Why:會 over-count——播一下就 seek 結尾按下一首也被算一次。
正確做法:per-session accumulated_ms ≥ 30s 才產 PlayFact;重送靠 event_id 去重。見 8.2 與 12-Ops-and-Reliability/03-Reliable-Delivery。
What:上了 Flink 就移除所有 batch 作業。
Why:刷流偵測等分析無法即時完成,需要離線重算 + reconcile 確保最終正確性。
正確做法:保留 batch layer(Lambda),streaming 給即時近似、batch 給離線權威。見 09-Messaging-Coord/01-Data-Pipeline。
What:以 dim(country/genre)分片後不再處理熱點。
Why:US 這種大國事件量遠大於小國 → hot shard,單節點被打爆。
正確做法:對預期熱點 sub-sharding(US-1…US-N)打散。見 05-Database-Advanced/02-Sharding。
What:裝置每 5 秒產生事件就立刻各發一個 request,且不處理離線。
Why:1.2M/sec 的請求數會放大網路 / 連線成本;離線使用者事件直接丟失,違反高準確。
正確做法:批次送出(每 5 筆 1 request)+ 離線寫 local DB 連線後 flush,配合 event_id 去重。見 09-Messaging-Coord/02-Queue。
Related Notes
- 02-Distributed-Systems/05-Numbers-to-Know
- 02-Distributed-Systems/03-Scalability
- 06-Database-Tech/04-OLTP-vs-OLAP
- 06-Database-Tech/03-DynamoDB
- 04-Database-Indexing/03-LSM-Tree
- 09-Messaging-Coord/01-Data-Pipeline
- 09-Messaging-Coord/03-Kafka
- 09-Messaging-Coord/02-Queue
- 12-Ops-and-Reliability/03-Reliable-Delivery
- 05-Database-Advanced/02-Sharding
- 07-Caching-Storage/01-Caching
- 11-Infrastructure/02-Load-Balancer
- 03-API-Design/02-REST