每天晚上十点多,微信会推一条微信运动的消息,报一下今天的步数,顺便叫你看看排行榜。大家可能注意到过一个细节:推送时间并不是统一的。根据我的体感上来看,大概时间在10点整后的1分钟上下浮动。
这条消息看起来只是"把数读出来发一下",放大到全量用户,它至少牵扯定时、计算、发送三件事。微信内部怎么实现没有公开资料,这里不进行猜测实际的实现原理。我们只从学习角度分析一下这个问题
先算个数
假设日活 1 亿(只是假设值,真实规模没有公开),希望所有提醒在 1 分钟内发完:
60108≈1.67×106
平均每秒 167 万条。这还是摊在 1 分钟里的结果;如果所有任务都定在 22:00:00 触发,就是 1 亿条落在同一秒。
三个问题:1 亿条待发任务怎么调度?每个人的好友排行榜都不一样,怎么算?这 1 亿份排名,是不是真的都要提前算好等着人来看?
难点一:1 亿条推送怎么发出去
单机定时任务卡在哪
最直接的做法是一张任务表,remind_at 一列存时间,定时任务每分钟扫一次:
sql
select uid from user_remind where remind_at <= now();
扫描本身不是问题,加了索引就能顶住,问题在于扫出来之后还得挨个发。单机每秒几千条已经不错,按 5000 条/秒算:
5×103108=2×104 s≈5.5 h
发完凌晨三点半,中间这台机器还不能挂。任务表本身也扛不住:扫描条件随时间往后走,天然是热点;失败没有重试,扩容只能换机器。
定时只负责入队
把"什么时候发"和"怎么发"拆开:调度器到点只做一件事,把这一批 uid 塞进队列,发送交给消费者。队列这儿不是解耦上下游,是缓冲:上游一次性写进 1 亿条,下游按自己的能力拉,队列长度先涨后落
消费端还要限流。下游通道有速率上限,超了会被批量拒绝,重试又会把积压放大一轮,所以限流卡在消费端。
分片和错峰
1 亿条任务按 uid % N 分给 N 个调度实例,每个实例只扫自己那一片,单机压力降到 1/N。
再切一层:1 分钟 = 60 秒,按 hash(uid) % 60 决定这一批用户分到第几秒发,每秒 167 万条,分布由 hash 保证大致均匀,不会扎堆。
回到开头:用户能感知到的只是"十点过一会儿收到通知",精确到秒的差异谁也看不出来。1 分钟内怎么排布发送时刻,是我们自己能定的。
| 单机定时任务 | 分片 + 队列 | |
|---|---|---|
| 任务存储 | 数据库里一行一条 | 队列消息,可重放 |
| 扩容 | 换更好的机器 | 加消费者 |
| 失败处理 | 查日志人肉补 | 重试 / 死信队列 |
| 峰值 | 全压在整点那一秒 | 按秒摊开 |
难点二:每个人的好友排行榜都不一样
全局榜是所有人都看同一份排名,比如"全站步数前 100",ZSet 正好合适:ZINCRBY 更新分数是 O(logN),ZREVRANGE 取前若干名是 O(logN+M)。1 亿成员存一份大概十 GB,内存不小,但方向是对的。
好友榜不是同一件事。我们看的是"我的好友里谁走得最多",每个人的成员集合不同,名次也不同,全局 ZSet 给的名次回答不了这个问题。
一人一份 ZSet 行不行
假设平均 200 个好友:
108×200=2×1010
两百亿个条目,每条几十字节就是 TB 级,而这还是给一天只结算一次的数据准备的常驻内存。
存储还次要,麻烦的是写。一个人步数变了,要改他所有好友榜里那一条,写放大 200 倍。而步数不是晚上 10 点才写,是白天持续上报的,每次增量都变成 200 次操作。
查询时实时排序是另一副样子:拿 200 个好友 uid,批量取步数再排序,比较次数是 200×log2200≈1500 量级,一次查询毫秒以内,写入零成本。
| 方案 | 一次查询 | 一次步数更新 | 空间 |
|---|---|---|---|
| 每人一个 ZSet(预计算) | O(logk),最省 | O(klogk),要改 k 个榜 | O(Nk) |
| 查询时实时排序 | O(klogk) | O(1) | 无 |
k 不是一个定值
选哪条路,看好友数 k 和访问频率 f,而且要看 k 的分布,不是平均值。微信好友上限的公开说法是 1 万左右, k 从几十到一万都有,长尾很长。按平均 200 设计,碰上一个 8000 好友的账号,读成本直接翻 40 倍。
还有一点容易忽略:真正进排行榜的人不等于好友数,没开微信运动的不参与,设置了不公开步数的也不参与。
所以可行的是分场景处理:
- 好友少、访问不频繁:查询时直接算,成本低,也没有一致性问题
- 好友多,或者被反复访问:算一次缓存起来,设短 TTL,下次直接读
- 访问集中、好友又多的少数账号:单独处理
权衡的不是"预计算还是实时算",而是这个用户的 k 乘上访问频率 f 值不值得存一份结果。 k 小的时候存是浪费, k 大的时候不算就扛不住。
难点三:真的需要提前算吗
前面两节都在假设这 1 亿份排行榜要提前算好。但推送和计算本来就是两件事,产品上完全可以拆开。
提醒里如果只有"你今天走了 8324 步",不需要任何排名,只是把自己当天的数据读出来;需要"你在好友里排第 7",才需要排序,而这个排序也可以等用户点开时再做。
收益取决于点开率。假设 10% 的用户会点开(仍然只是假设),就是 1000 万次排序,比给 1 亿份都算一遍少一个数量级;更要紧的是没点开的那 90%,一次都没算。
但按需计算不是白赚的,它把压力从发送侧搬到了读取侧,还多出几个新问题:
| 问题 | 表现 | 常规对策 |
|---|---|---|
| 读洪峰 | 推送后几分钟用户集中点开,QPS 陡增 | 入口限流、排队、骨架屏 |
| 缓存冷启动 | 第一批请求全部回源 | 提前预热热点、合并请求 |
| 依赖放大 | 一次请求要读好友列表 + 批量取步数 | 好友列表本地缓存、批量接口 |
| 降级 | 高峰时算不出来 | 先只返回自己的步数,排名稍后加载 |
这里提醒一句:排序本身不贵,贵的是排序之前要拿的两样东西------好友列表和每个人的步数。所以按需方案优化的重点不在排序,在这两处读取。
不管选哪条路,核心的设计思想是同一个:最有效的优化,往往不是让计算更快,而是让不需要的计算根本不发生。1 亿次计算提速 10 倍,和只算 1000 万次,省下来的是同一个量级,但后者还省机器、省钱。
总结
- 集中到来的工作,先用分片和队列摊开,再谈怎么发得快。
- 排行榜怎么算,取决于 k 的分布和访问频率 f,不取决于"排行榜"这个功能名。
- 能用业务流程消掉的负载,就别用架构去硬扛。
但每一条都换来额外的复杂度:队列要运维,缓存会不一致,按需计算要处理冷启动和降级。1 万用户的时候,一个 crontab 加一次内存排序就够了,多一层队列反而是负担;到了 1 亿,前面每一步的复杂度又都省不掉。