跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 案例:動態牆

案例:動態牆

發文時推給所有追蹤者,還是讀取時才去拉?名人有幾百萬追蹤者時,兩種做法的成本完全相反。

流程
存貼文新貼文事件誰追蹤他寫進每位追蹤者推送來的名人貼文使用者負載平衡負載平衡動態服務(無狀態)訊息佇列推送佇列推送 workerSQL 與 NoSQL追蹤關係(鍵值)堆積合併(堆積)快取動態快取每人一串分割與分片貼文(依作者分片)

發文和讀取是兩條分開的路:發文經過佇列交給推送 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. @1 推送
  2. @3 推送
  3. @4 推送
  4. @5 推送
  5. @6 推送
  6. @7 推送
  7. @8 推送
  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 On = 100n = 1,000n = 10,000成長倍數:實測(理論)
推送:發一篇文要寫幾份O(n)1001,00010,000×100 (×100)
拉取:讀一次要抓幾串O(n)1001,00010,000×100 (×100)
混合:名人發一篇文O(1)111×1.0 (×1.0)

實際建一張「一個人被 n 人追蹤、也追蹤這 n 人」的圖跑出來的。混合做法裡名人發文只寫自己那一份,不管有多少追蹤者。

和其他做法比

每篇文平均寫入最貴的一篇文每次讀取抓幾串最貴的一次讀取總工作量/篇(10 次讀取)
寫入時推送304,8931140
讀取時拉003030300
混合19.547511.519134

同一張追蹤關係圖(5,000 人、每人追蹤 30 個),名人門檻 500 位追蹤者,數出來的。推送的平均最省,但最貴的一篇文高出平均上百倍;拉取剛好相反;混合把兩邊最貴的情況都壓住。

真實世界裡的它

  • Twitter 早期全部推送,名人發文會讓推送佇列塞好幾分鐘,後來改成名人在讀取時合併。
  • Instagram、Facebook 的動態都是預先算好的清單,再加上排序模型重新排序。
  • 推送的工作通常放進訊息佇列由背景 worker 慢慢做,發文本身立刻回應。

取捨與陷阱

  • 平均值會騙人:推送的平均寫入很低,但一位名人發文就是幾百萬次寫入,延遲和佇列長度都看最大值。
  • 很久沒上線的使用者也會被推送,浪費寫入和儲存;常見做法是只推送給最近活躍的人,其他人回來時再拉。
  • 刪文或封鎖要回頭把已經推送出去的副本清掉,推送模式下這是另一場扇出。

LeetCode 練習