案例:動態牆
發文時推給所有追蹤者,還是讀取時才去拉?名人有幾百萬追蹤者時,兩種做法的成本完全相反。
流程
發文和讀取是兩條分開的路:發文經過佇列交給推送 worker,寫進每位追蹤者的動態快取;讀取拿預先算好的動態,再合併沒有推送的名人貼文。選一個流程一步步走;點方塊可以進到那個元件的主題。
亮起來的是這一步執行的程式碼
type Post = { id: number; author: number; time: number }; class FeedService { private outbox = new Map<number, Post[]>(); // each author's posts, oldest first private inbox = new Map<number, Post[]>(); // precomputed feeds, oldest first constructor( private followers: number[][], // followers[a]: who follows a private following: number[][], // following[u]: whom u follows private strategy: "push" | "pull" | "hybrid", private threshold = 1000, ) {} private isCelebrity(author: number): boolean { return this.followers[author].length >= this.threshold; } publish(post: Post): void { const own = this.outbox.get(post.author) ?? []; own.push(post); this.outbox.set(post.author, own); if (this.strategy === "pull") return; if (this.strategy === "hybrid" && this.isCelebrity(post.author)) return; for (const f of this.followers[post.author]) { const feed = this.inbox.get(f) ?? []; feed.push(post); this.inbox.set(f, feed); } } readFeed(user: number, k: number): Post[] { const lists: Post[][] = []; if (this.strategy !== "pull") lists.push(this.inbox.get(user) ?? []); for (const a of this.following[user]) { if (this.strategy === "pull" || (this.strategy === "hybrid" && this.isCelebrity(a))) { lists.push(this.outbox.get(a) ?? []); } } const head = lists.map((list) => list.length - 1); const feed: Post[] = []; while (feed.length < k) { let best = -1; for (let i = 0; i < lists.length; i++) { if (head[i] >= 0 && (best < 0 || lists[i][head[i]].time > lists[best][head[best]].time)) best = i; } if (best < 0) break; feed.push(lists[best][head[best]--]); } return feed; }}做法
500
10
程式碼路徑
追蹤者最多的帳號
4,893
追蹤者中位數
9
超過門檻的名人
37
最慢送達(秒)
4.9
每篇文的總工作量(每篇文配 10 次讀取)
- 寫入時推送40
- 讀取時拉300
- 混合134
工作量 = 每篇文寫入的動態份數 + 每篇文的讀取次數 × 每次讀取抓的串數。
使用者 0 的動態:每則是怎麼來的
- @1 推送
- @3 推送
- @4 推送
- @5 推送
- @6 推送
- @7 推送
- @8 推送
- @9 推送
實際讓 5,000 位使用者各發一篇文(追蹤者越多的越晚發)再讀取。不管選哪種做法,最新的 8 則都一樣,變的只有它們是怎麼來的。
每篇文都複製進每位追蹤者的動態,所以讀取只要拿一串。平均一篇文寫 30 次,但追蹤者最多的帳號一篇要寫 4,893 次:以每秒 1,000 次寫入計算,最後一位追蹤者要晚 4.9 秒才看到。
假設:5,000 位使用者各追蹤 30 個帳號,被追蹤的機率和熱門排名成反比,所以少數帳號拿走大部分的追蹤;一個推送 worker 每秒寫 1,000 份動態。
亮起來的是這一步執行的程式碼
type Post = { id: number; author: number; time: number }; class FeedService { private outbox = new Map<number, Post[]>(); // each author's posts, oldest first private inbox = new Map<number, Post[]>(); // precomputed feeds, oldest first constructor( private followers: number[][], // followers[a]: who follows a private following: number[][], // following[u]: whom u follows private strategy: "push" | "pull" | "hybrid", private threshold = 1000, ) {} private isCelebrity(author: number): boolean { return this.followers[author].length >= this.threshold; } publish(post: Post): void { const own = this.outbox.get(post.author) ?? []; own.push(post); this.outbox.set(post.author, own); if (this.strategy === "pull") return; if (this.strategy === "hybrid" && this.isCelebrity(post.author)) return; for (const f of this.followers[post.author]) { const feed = this.inbox.get(f) ?? []; feed.push(post); this.inbox.set(f, feed); } } readFeed(user: number, k: number): Post[] { const lists: Post[][] = []; if (this.strategy !== "pull") lists.push(this.inbox.get(user) ?? []); for (const a of this.following[user]) { if (this.strategy === "pull" || (this.strategy === "hybrid" && this.isCelebrity(a))) { lists.push(this.outbox.get(a) ?? []); } } const head = lists.map((list) => list.length - 1); const feed: Post[] = []; while (feed.length < k) { let best = -1; for (let i = 0; i < lists.length; i++) { if (head[i] >= 0 && (best < 0 || lists[i][head[i]].time > lists[best][head[best]].time)) best = i; } if (best < 0) break; feed.push(lists[best][head[best]--]); } return feed; }}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 以追蹤關係、發文與讀取樣本比較 fan-out 成本;沒有內容排序模型、隱私過濾或跨區複寫。
什麼時候用
- 任何「把很多人的更新合成一串」的功能:社群動態、通知中心、追蹤清單、訂閱的頻道。
- 讀遠多於寫、而且大家追蹤的人數差不多時,寫入時推送最划算:讀取只要拿一串。
- 追蹤數有極端的長尾(名人)時用混合:一般人推送,名人在讀取時拉。
和其他主題的關係
- 延伸閱讀
- 分割與分片
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 推送:發一篇文 f 是作者的追蹤者數 | O(f) | O(f) |
| 推送:讀取動態 k 是要顯示的則數 | O(k) | O(k) |
| 拉取:發一篇文 | O(1) | O(1) |
| 拉取:讀取動態 g 是追蹤的帳號數;用堆積合併 g 串 | O(g + k log g) | O(g + k log g) |
| 混合:發文/讀取 T 是名人門檻,c 是追蹤的名人數 | O(min(f, T)) | O(c + k log c) |
空間:O(n · g),推送要為每位使用者存一份動態;拉取只存貼文本身
Big O 實測:n 變大時步數怎麼長
數的是:寫入或抓取的次數(n 是作者的追蹤者數,或讀者追蹤的帳號數)
| Big O | n = 100 | n = 1,000 | n = 10,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 推送:發一篇文要寫幾份 | O(n) | 100 | 1,000 | 10,000 | ×100 (×100) |
| 拉取:讀一次要抓幾串 | O(n) | 100 | 1,000 | 10,000 | ×100 (×100) |
| 混合:名人發一篇文 | O(1) | 1 | 1 | 1 | ×1.0 (×1.0) |
實際建一張「一個人被 n 人追蹤、也追蹤這 n 人」的圖跑出來的。混合做法裡名人發文只寫自己那一份,不管有多少追蹤者。
和其他做法比
| 每篇文平均寫入 | 最貴的一篇文 | 每次讀取抓幾串 | 最貴的一次讀取 | 總工作量/篇(10 次讀取) | |
|---|---|---|---|---|---|
| 寫入時推送 | 30 | 4,893 | 1 | 1 | 40 |
| 讀取時拉 | 0 | 0 | 30 | 30 | 300 |
| 混合 | 19.5 | 475 | 11.5 | 19 | 134 |
同一張追蹤關係圖(5,000 人、每人追蹤 30 個),名人門檻 500 位追蹤者,數出來的。推送的平均最省,但最貴的一篇文高出平均上百倍;拉取剛好相反;混合把兩邊最貴的情況都壓住。
真實世界裡的它
- Twitter 早期全部推送,名人發文會讓推送佇列塞好幾分鐘,後來改成名人在讀取時合併。
- Instagram、Facebook 的動態都是預先算好的清單,再加上排序模型重新排序。
- 推送的工作通常放進訊息佇列由背景 worker 慢慢做,發文本身立刻回應。
取捨與陷阱
- 平均值會騙人:推送的平均寫入很低,但一位名人發文就是幾百萬次寫入,延遲和佇列長度都看最大值。
- 很久沒上線的使用者也會被推送,浪費寫入和儲存;常見做法是只推送給最近活躍的人,其他人回來時再拉。
- 刪文或封鎖要回頭把已經推送出去的副本清掉,推送模式下這是另一場扇出。