Designing Spotify Top K Songs
my-notes/system-design-hld/examples · ZH
https://www.youtube.com/watch?v=HjazbLlrWxI
https://systemdesignschool.io/problems/topk/solution
1. Gather Requirements
2. API Design/Database Design
3. High-Level Design
4. Deep Dive
1. Gather Requirements
Functional Requirements
- K = 100 to 1000
- All time, last time, last hour
Non-functional Requirements
- 10B songs played per day
- 100M total songs
- Latency 100ms to fetch top k songs
- Latency for a song play to be included = 1 min (一首歌放多久才會列入考量)
API Design
GET /top-k?k=1000&time=1mon 這個案例API相對沒有什麼複雜的
High-level Design
Event Stream like (Kafka)
用 Flink 之類的工具進行 Fixed Sliding Window 計數 或是 arbitrary sliding window(事件發生才開始window 動態的)
Tumbling window is a fixed size window that does not slide. Events in the same window are not overlapping 直接用時間段區分實務上也有可能會這樣用,像Spotify推薦播放清單就是24h更新一次
甚至直接用bucket 儲存每一分鐘的歌曲及數量,就不用去維護sliding window外減少的狀況了,直接看範圍內的bucket總和
用 Redis 或資料庫儲存 Flink 資料 提供客戶端API
global all time top k 解法:
用miniHeap 因為最上面是最小數字 如果新資料大於他 直接pop掉替換 再進行排序 資料量大如果每次都要reheapify會很耗時間,另外儲存 hash map Stores (VideoID, HeapIndex) pairs. 當某個元素的 priority(count)改變時,可以: - O(1) 找到它在 heap 裡的位置 - 然後對那個位置做 bubble up / bubble down → O(log K) 這比:
- 每次更新都在 heap 裡線性搜尋那個 video → O(K) 好很多
為什麼一定需要Hashmap
「每一次新的歌曲被播放,你都必須先知道它的最新播放次數。 你若不知道這個數字,就沒辦法跟 Min-Heap 做比較。」
不同window的時間段的 top K 維護方式
資料流用bucket 每分鐘分裝 動態看window涵蓋範圍 or 用pointer指定範圍出來 同樣維護 heap 和 hashmap(資料隨著window pointer移動會有增減)
bucket還是會有window時間增減的處理,如果不這樣就會變成即時運算 bucket如果很短時間 表示數量會多 會算很久,如果像window維護的話 至少不用per event處理 而是一桶一桶批量處理 精準度可能稍有誤差 但會更省成本(bucket精度)
Deep Dives
- Single Point of Failure
- Through put 吞吐量
- Window 時間區間
新增 Worker (處理Flink那段的服務 每個會有一個heap結果),需要在進行一個Snapshot 因為Kafka不一定會儲存長期的資料 通常會設定一個過期時間 而且重播資料流可能會超高成本
快照機制 flink本身就有 該機制可以讓 單點故障後 重啟後不用重播資料 可以快速從上個checkpoint繼續,在大型服務如Spotify 一小時的event資料量就很可怕了。
每個 worker 處理 根據 song_id 做 partition 用hash可能是最好的方式
合併不同worker 會需要有個合併各個partition的Top K 服務 這個資料才能寫入到資料庫or Redis 給使用者
各個worker各自維護各自的heap和hashmap,最終結果需要合併,因為各個結果以這個案例會被放在不同worker去處理,但好處是每首個只會在一個worker的heap中 所以合併單純將每個worker提交的list進行排序即可,就算將全部worker的heap資料合併成一個超大list candidates 再排序也不會太扯
因為 num_workers 通常不會太大(例如 10~100), 而 K(例如 100 或 1000)也不大, 所以 candidates 長度在 1e3~1e5 之間是能接受的。
- 對 candidates 再跑一次 top-K:
- 用一個 global Min-Heap(大小 K)
- 掃過 candidates,維護 global top-K
這個結果就可以寫入redis之類的讓用戶端call API 存取
如果真的太多,也可以分兩層合併,某地區(亞洲、歐洲)的先合併一次,全球的最終合併
也可以降頻更新,每個worker可能10秒上報一次自己的heap就很夠了
大致資料流
[Client]
↓ play events
[Event Collector]
↓
[Kafka topic: play_events]
↓
[Streaming Aggregation]
- update play_count counters
- maintain real-time Top-K
↓
[Redis Sorted Set / KV]
→ 用於「即時榜單」
↓
[Batch Aggregation (每日)]
- re-compute official charts
- fix late events
↓
[Final Top-K table]
- Redis / BigQuery / ClickHouse
↓
[Charts API]
Spotify 的 Top-K 排行榜不是在查詢時即時計算, 而是透過:
-
事件流(Kafka)
-
Streaming 累計(Flink)
-
Batch 校正(Spark)
-
Redis Sorted Set 快取
事前把「各時間窗」的 Top-K 都算好, API 只是讀快取,所以能非常快。
AI 總整理
1️⃣ Global Top-K 與 Sliding Window Top-K 的差異
✔ Global Top-K(全時間)
- HashMap 中的 count 只會累加,不會遞減
- 用於維護「全時間播放數」
- Heap 代表全時間的 Top-K
- 不需要 window eviction,不需要 -1
- 適合:
- Spotify all-time top songs
- YouTube all-time views
- 統計報表、排行榜
✔ Sliding Window Top-K(固定時間視窗)
-
count 會遞增也會遞減(事件進入 +1,離開 -1)
-
每個 window 需要維護自己的:
- HashMap(window count)
- Heap(top-k for that window)
- begin / end 指標(或 offsets)
-
用於:
- 最近 5 分鐘 / 1 小時 / 24 小時熱度
- trending topics
- fraud detection
👉 兩者使用完全不同的 state,不可能共用同一份 HashMap。
2️⃣ 為什麼需要 HashMap + HeapIndex?
✔ HashMap 的兩個用途(重要)
- 存 count(global 或 window count)
- 存 heapIndex → 讓你在 heap 裡 O(1) 找到 video
✔ 沒有 heapIndex 的後果
-
要更新某首歌的 count 時 → 你會不知道它在 heap 裡哪個位置 → 得掃整個 heap O(K)(很慢)
-
有 heapIndex 時: → 立即得到它在 heap 中的 index(O(1) → reheapify(O(log K))
✔ 結論
Heap(Top-K)、HashMap(count)、HeapIndex(位置)是三位一體,不可缺一。
3️⃣ 為什麼 sliding window 一定需要雙指標?
因為 sliding window 的定義是:
count_in_window =
所有 timestamp ∈ [now - W, now] 的 event 次數
所以每次 window 往前滑動,需要:
- 新事件滑入 → +1
- 舊事件滑出 → -1
為了知道「哪件舊事件要被移除」,你需要 事件序列(queue or log):
- end → 指向最新事件的位置
- begin → 指向 window 最舊事件的位置
這就是為什麼 systemdesignschool 用:
- Kafka 的 partition log
- 兩個 offset(begin offset / end offset)
當成 sliding window 的 queue。
4️⃣ 為什麼 Sliding Window 每個 Window 都要有自己的 Heap & HashMap?
因為每個 window 的「包含範圍」不同:
- 5 分鐘 window count ≠ 60 分鐘 window count
- 60 分鐘 window count ≠ 24 小時 window count
因此:
| Window | count_in_window | Heap |
|---|---|---|
| last 1 min | 只計算 60 秒內 | Top-K(1min) |
| last 1 hr | 只計算 3600 秒內 | Top-K(1hr) |
| last 24 hrs | 只計算 24hr 內 | Top-K(24hr) |
| ➡ Heap 必須分開(不能混) | ||
| ➡ count 也必須分開(但可存同一個 state object 裡) |
5️⃣ Heap 無法 100% 精準維護 Sliding Window Top-K?(常見誤區)
這是你提出的一個超重要洞見:
「如果 heap 裡的歌曲一直掉分,但 heap 外有首歌沒掉分,它應該會擠進 heap,但會不會被忽略?」
答案是:
✔ 是,最簡單的 heap 實作
沒辦法保證 100% 精準 top-k
原因:
- heap 只會調整「被事件觸及到」的元素
- heap 外的元素不會自己變強
- 除非事件「+1」它,否則它永遠不會被考慮
👍 真正精準的做法
- 用 TreeMap / skiplist / balanced BST(例如 C++ multiset)
- 或每隔 X 秒做一次 full rebuild
- 或完全接受 approximate(很多大公司都接受近似 top-k)
6️⃣ 固定 Sliding Window(精準) vs Bucket Window(近似)
你提到:
sliding window 不用 bucket 也能做嗎?
可以 → 用雙指標。但:
精準 sliding window(雙指標)
- count 會 +1 / -1
- 重播舊事件
- 成本高但準確
bucket window(常用於實務)
- 將時間切成固定 bucket,例如 1 秒、10 秒
- 每個 bucket 記錄 partial count
- window = 多個 bucket 的合併
- 比較便宜,但不是精準數值
7️⃣ 為什麼全時間(all-time)排行榜不需要 sliding window?
你提出:
all-time top-k 不會保留全部 log,那怎麼算?
大多數公司作法是:
- 事件流在 streaming aggregator 內累積 count(HashMap)
- 定期 snapshot 到 Redis / Cassandra / BigQuery
- 長期 log 會 drop(只保留一段時間)
- next startup 可以透過 snapshots + log replay 恢復
「不保留所有 log」完全沒問題,因為 all-time count 永遠只會增。
8️⃣ 完整 Top-K 資料流程(簡化版)
你的理解已完全正確,可記進筆記:
Event Stream →
Partition hash(videoId) →
Local sliding windows: {1min,1hr,24hr}
Local top-K (per window)
Local global top-K
↓
Coordinator / Aggregator →
Merge all local top-Ks →
Produce final top-K result for each window