Spotify 熱門歌曲排行 (Design Spotify Trending Songs)

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

設計一個 收集海量收聽事件 → 依維度(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 預計算 + cacheOLAP 依 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
defer 話術

有效播放怎麼定義怎麼把新鮮度壓到 1 分鐘 我先標記,等 deep dive 再展開——現在先把 ingest → pipeline → top-K serving 的端到端骨架串起來。」把兩個最硬的點推遲,保住架構敘事連貫。

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

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

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

通用機制(batch vs stream 的本質差異、CDC 細節、LSM 寫入、sharding 原理)只在 concept note 解釋;這裡只講「本題怎麼用、選哪個、為什麼」。本題無 home note 的新材料(有效播放 sessionizationlakehouse + 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-Kafka06-Database-Tech/04-OLTP-vs-OLAP

8.2 如何計算「有效播放次數」(effective play counts)

問題:若只對每首歌數 unique events,會 over-count
為何是瓶頸:使用者播一首歌後立刻 seek 到最後幾秒就按下一首,這不該算一次有效播放;準確性是硬 NFR。

新材料:30 秒 sessionization(本案例首次 introduce,已登記 new_concepts_introduced)

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)

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-Scalability11-Infrastructure/02-Load-Balancer
Event DB 寫入吞吐瓶頸 寫優先儲存(Cassandra/Dynamo, LSM) + 依 key 分散 06-Database-Tech/03-DynamoDB04-Database-Indexing/03-LSM-Tree
直接掃 OLTP 拖垮線上寫入 CDC 把資料搬到 lakehouse + OLAP 分析,工作負載完全解耦 06-Database-Tech/04-OLTP-vs-OLAP09-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-Pipeline09-Messaging-Coord/03-Kafka
Top K 查詢重複打 OLAP 預計算結果 cache,固定查詢直接命中 07-Caching-Storage/01-Caching
即時計數無法抓刷流 / 偶發錯算 保留 batch layer 離線重算 + reconcile(Lambda) 09-Messaging-Coord/01-Data-Pipeline12-Ops-and-Reliability/03-Reliable-Delivery
drive 的關鍵句

「系統會 先在『分析查詢』裂開(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…")

為什麼不讓 workers 每小時直接掃 OLTP 算 Top K?

1.2M events/sec 是怎麼估出來的?

「有效播放」怎麼定義、怎麼避免 over-count?

怎麼把新鮮度從 10~20 分鐘壓到 1 分鐘內?代價是什麼?

OLAP 依 country 分片後 US 那片過熱(hot shard),怎麼辦?

使用者離線一段時間,事件會丟嗎?重連後會重複算嗎?

既然 streaming 更快,為什麼還要保留 batch layer?

流量再放大 10x(事件量 / 維度數暴增),哪裡先撐不住?

為什麼 raw events 要落 lakehouse(S3 + Iceberg/Parquet),不直接從 Kafka 餵 Spark?

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

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

常見陷阱 (Exam Traps for This Problem)