📌 30 秒摘要

這篇用「微信運動每晚 10 點推步數與好友排行」當題目,推演 1 億人規模下的三個難點。

  • 怎麼在 1 分鐘內發完:調度器只把任務送進佇列、發送交給消費者,再用分片與 hash(uid) % 60 把發送攤到每一秒。
  • 每個人的好友榜都不同,該先算好還是查詢時再算:要看好友數 k 的分布與存取頻率 f,不能看平均值。
  • 這 1 億份排名真的要先算嗎:推播只帶自己的步數,排名等使用者點開才算,計算量少一個數量級。

作者標明所有規模都是假設,不猜微信實際的做法。

🔍 查證與評比

可信度 22/30|查證 2026-10-31|✅ 4 ⚠️ 3 ❌ 0|判定:完整收(門檻邊緣,經使用者確認)
來源是 UGC 平台(掘金)。主軸是推演,斷言多為算術,以下逐條核對。

原文斷言查證依據
1 億 ÷ 60 秒 ≈ 每秒 167 萬條✅算術
單機每秒 5000 條要 2×10⁴ 秒 ≈ 5.5 小時✅20000 ÷ 3600 ≈ 5.56
ZINCRBY 是 O(log N)✅Redis 官方指令文件
ZREVRANGE 是 O(log N + M)⚠️複雜度正確,但這個指令自 Redis 6.2 起已標為 deprecated,現在寫 ZRANGE … REV
1 億成員的 ZSet 約十 GB⚠️數量級合理,未能實測
2×10¹⁰ 筆達 TB 級;200×log₂200 ≈ 1500 次比較✅算術
微信好友上限約 1 萬⚠️未能查證,不影響主軸

評分:A 4|B 4|C 4|D 4|E 3(推演是作者自己的,但沒有實測數字)|F 3(庫裡已有扇出、削峰、ZSet 排行榜;新增量是「整點批量發送」與「預算/現算的判準」)。

🗺 心智圖(Canvas)

微信運動排行榜推送的系統設計推演

微信運動排行榜推送的系統設計推演

🚀 難點一:1 億條怎麼發

🎯 什麼情境該想到我

當你要在同一個時間點對大量使用者做同一件事(每晚的推播、月初的帳單、整點的提醒),而單一台排程機「掃出來再一筆筆發」怎麼算都發不完的時候。

⚙️ 怎麼用(步驟 / 公式)

1. 先算帳:速率 = 總量 ÷ 時間窗

原文的例子(數字都是假設值):1 億人、希望 1 分鐘內發完:

1e8 ÷ 60 秒 ≈ 1.67e6 條/秒

如果全部定在 22:00:00 觸發,就是 1 億條落在同一秒。再看單機:每秒 5000 條已經不錯,

1e8 ÷ 5e3 = 2e4 秒 ≈ 5.5 小時

晚上 10 點開始,發完已經凌晨三點半,而且這台機器中途不能掛。先把這兩個數字算出來,就知道「一張任務表+每分鐘掃一次」的做法在哪個規模會破。 量級估算的方法見 工具-容量估算。

2. 把「什麼時候發」和「怎麼發」拆開

  • 調度器到點只做一件事:把這一批 uid 寫進訊息佇列。它不呼叫下游,也不等發送結果。
  • 消費者負責發送,按自己的能力從佇列拉。
  • 這裡佇列的作用不是解耦上下游,是緩衝:上游一次寫進 1 億條,下游慢慢消化,佇列長度會先漲後落。這就是削峰填谷(見 工具-網站高可用與伸縮性設計)。
  • 限流放在消費端:推播通道、簡訊閘道都有速率上限,超過會被批量拒絕;被拒的再重試,又會把積壓放大一輪。所以消費者要按下游的上限發,詳見 工具-限流器設計。

3. 分片:讓每台調度器只管一部分

uid % N 把任務分給 N 個調度實例,每個實例只掃自己那一片,單機壓力降到 1/N。

4. 錯峰:在可接受的時間窗裡把發送時刻攤開

發送秒數 = hash(uid) % 60      # 這個人在 22:00 後的第幾秒發
  • 每秒約 167 萬條,hash 讓分布大致均勻,不會全部擠在第 0 秒。
  • 能這樣做,是因為使用者感知不到秒級差異:大家只知道「十點過一會兒收到通知」。產品允許的時間窗,本身就是可以自己安排的資源。

5. 兩種做法對照

單機定時任務分片+佇列
任務存在哪資料庫裡一行一筆佇列訊息,可以重放
怎麼擴容換更好的機器加消費者
失敗怎麼辦查 log、人工補發重試、死信佇列
峰值全壓在整點那一秒按秒攤開

🧪 我實際套用的紀錄

  • 2026-10-31:(待填)

⚠️ 注意 / 什麼時候不適用

  • 量小時別上這套。原文自己說:1 萬個使用者時,一個 crontab 加一次記憶體排序就夠了,多一層佇列反而是負擔。佇列要運維、要監控積壓。
  • 佇列是至少一次投遞,同一則推播可能被發兩次。消費端要用「uid+日期」這類業務鍵去重,見 工具-MQ消費端防禦三原則(經驗推論,原文沒提)。
  • 重試要有上限和退避,否則下游一拒絕,重試就會變成第二波尖峰。
  • 這張卡處理的是「很多人在同一時刻要做同一件事」。如果是「每筆各自在不同時間到期」(訂單 30 分鐘沒付款就取消),要看 工具-延遲任務方案選型。
  • 錯峰只能在產品允許的時間窗內做。如果需求是「整點準時」(例如開賣),就不能攤開,要改從容量與限流下手。

🔗 相關工具

調度只送進佇列 分片 uid % N 錯峰 hash(uid) % 60

🏆 難點二:每人一份好友榜

🎯 什麼情境該想到我

當每個使用者看到的結果都不一樣(好友排行榜、個人化動態、「你的朋友也買了」),你在猶豫「要先幫每個人算好存起來」還是「等他來看時再算」的時候。

⚙️ 怎麼用(步驟 / 公式)

1. 先分清楚:全域結果 vs 每人一份

  • 全域榜:所有人看同一份,例如「全站步數前 100」。一個 Redis ZSet 就夠:ZINCRBY 更新分數是 O(log N),取前幾名是 O(log N + M)(原文寫 ZREVRANGE,這個指令自 Redis 6.2 起已標為 deprecated,現在寫 ZRANGE key 0 99 REV,見 工具-Redis單執行緒下的阻塞風險)。
  • 每人一份:「我的好友裡誰走最多」,每個人的成員集合不同、名次也不同,全域 ZSet 回答不了。

2. 兩條路的代價對照(k=一個人的好友數,N=使用者數)

方案一次查詢一次更新空間
預算:每人一個 ZSetO(log k),最省要改 k 個榜,O(k log k)O(N·k)
現算:查詢時才排序O(k log k)O(1)不額外佔

代入原文的假設值(1 億人、平均 200 個好友):

預算的空間:1e8 × 200 = 2e10 筆,每筆幾十位元組 → TB 級常駐記憶體
預算的寫入:一人步數變一次,要改 200 個榜(寫放大 200 倍),而步數是整天持續上報的
現算的查詢:200 × log2(200) ≈ 200 × 7.6 ≈ 1500 次比較 → 毫秒以內

這組數字下,現算明顯划算。

3. 決策看「k 的分布 × 存取頻率 f」,不看平均值

  • k 不是定值,是長尾:好友數從幾十到上萬都有。用平均 200 設計,碰上一個 8000 好友的帳號,讀取成本直接翻 40 倍。
  • 真正進榜的人比好友數少:沒開功能、不公開的人不參與。
  • 依場景分開處理:
    • k 小、不常看 → 查詢時直接算,成本低,也沒有一致性問題。
    • k 大,或被反覆查看 → 算一次放快取,設短 TTL。
    • k 大又被集中存取的少數帳號 → 單獨處理。
  • 判準一句話:這個人的 k 乘上 f,值不值得存一份結果。

4. 再往上問一層:這份結果真的需要嗎?

推播和計算本來是兩件事,產品上可以拆開:

  • 推播只寫「你今天走了 8324 步」,不需要任何排名。
  • 「你在好友裡排第 7」等使用者點開時才算。假設 10% 會點開,就只要算 1000 萬次,少一個數量級;沒點開的 90% 一次都不用算。

按需計算不是白賺的,壓力從發送端搬到了讀取端:

新問題表現常見對策
讀取洪峰推播後幾分鐘內大家一起點開入口限流、排隊、骨架畫面
快取冷啟動第一批請求全部回源預熱熱點、合併同樣的請求
依賴放大一次請求要讀好友列表+批量取步數好友列表本地快取、批量介面
高峰算不出來—降級:先只回自己的步數,排名稍後載入

★ 排序本身不貴,貴的是排序前要拿的兩樣東西(好友列表、每個人的步數)。按需方案要優化的是這兩處讀取,不是排序演算法。

★ 最有效的優化,常常不是讓計算更快,而是讓不需要的計算根本不發生:1 億次提速 10 倍,和只算 1000 萬次,省下的是同一個量級,但後者還省機器。

🧪 我實際套用的紀錄

  • 2026-10-31:(待填)

⚠️ 注意 / 什麼時候不適用

  • 這和社群時間線的「寫擴散 vs 讀擴散」是同一個問題:《設計資料密集型應用》第 1 章的 Twitter 例子,負載參數同樣是「每個使用者追蹤者數的分布」;Twitter 的做法是混合式:一般使用者的推文預先寫進追蹤者的時間線,名人的推文則排除在外,讀取時才另外抓來合併(見 工具-可靠可擴展可維護)。這裡的「大帳號單獨處理」就是同一個思路(對照是我加的,原文沒提 Twitter)。
  • 快取的結果會過時:短 TTL 換來的是使用者可能看到幾秒到幾分鐘前的名次。排行榜可以接受,餘額、庫存這類就不行。
  • 1 億成員的全域 ZSet 約十 GB 是原文的估計,我沒實測,實際大小要看成員字串長度與 Redis 的編碼方式(見 工具-Redis資料型別的底層編碼)。
  • 原文的數字(1 億日活、平均 200 好友、10% 點開率)都是作者標明的假設,不是微信的實際數據。換成自己的系統時,要代入自己量到的 k 分布與點開率。

🔗 相關工具

看 k 的分布 × 存取頻率 f 不看平均值

🤔 難點三:真的要先算嗎

推播只帶自己的步數 點開才排名(少一個數量級)

代價:讀取洪峰、冷啟動、 依賴放大 → 準備降級

Link to original

🎯 為什麼存這篇 / 未來想拿它做什麼

  • 下次要做「每晚/每月對所有人發一次」的批量任務,先照它算速率、再決定要不要上佇列。
  • 遇到「每個使用者看到的結果都不一樣」的功能,拿「k 的分布 × 存取頻率」判斷要預算還是現算,不要反射性地全部先算好。
  • 它的結尾是一個好提醒:能用產品流程消掉的負載,就別用架構去硬扛。

🧰 這篇給我的工具

✨ 關鍵重點

  • 先算帳:1 億人、1 分鐘內發完,就是每秒 167 萬條;單機每秒 5000 條要跑 5.5 小時。一張任務表加每分鐘掃一次,在這個規模一定破。
  • 拆開「什麼時候發」與「怎麼發」:調度器到點只把 uid 寫進佇列,消費者按能力拉。佇列在這裡是緩衝,不是解耦;限流要放在消費端,否則下游批量拒絕、重試又放大積壓。
  • 分片+錯峰:uid % N 分給 N 個調度實例;hash(uid) % 60 決定在第幾秒發。使用者感知不到秒級差異,這 1 分鐘怎麼排是自己可以決定的。
  • 全域榜和好友榜不是同一件事:全域榜一個 ZSet 就好;好友榜每人一份,預算要 2×10¹⁰ 筆、TB 級記憶體,而且一次步數更新要改 200 個榜。
  • k 是長尾:照平均 200 設計,遇到 8000 好友的帳號讀取成本翻 40 倍。依 k 與存取頻率分場景:現算、快取短 TTL、大帳號單獨處理。
  • 按需計算:推播只帶自己的步數,點開才排名。假設 10% 點開,計算量少一個數量級;代價是讀取洪峰、快取冷啟動、依賴放大,要準備降級(先回自己的步數、排名稍後載入)。
  • 作者自己標的邊界:1 萬使用者時,一個 crontab 加一次記憶體排序就夠,多一層佇列反而是負擔。

💬 原文摘錄

  • 「最有效的优化,往往不是让计算更快,而是让不需要的计算根本不发生。」
  • 「排行榜怎么算,取决于 k 的分布和访问频率 f,不取决于『排行榜』这个功能名。」
  • 「能用业务流程消掉的负载,就别用架构去硬扛。」

🔗 相關