微信运动每天晚上 10 点推送排行榜,技术上到底有多难?

每天晚上十点多,微信会推一条微信运动的消息,报一下今天的步数,顺便叫你看看排行榜。大家可能注意到过一个细节:推送时间并不是统一的。根据我的体感上来看,大概时间在10点整后的1分钟上下浮动。

这条消息看起来只是"把数读出来发一下",放大到全量用户,它至少牵扯定时、计算、发送三件事。微信内部怎么实现没有公开资料,这里不进行猜测实际的实现原理。我们只从学习角度分析一下这个问题


先算个数

假设日活 1 亿(只是假设值,真实规模没有公开),希望所有提醒在 1 分钟内发完:
10860 ≈1.67×106 \frac{10^8}{60} \approx 1.67 \times 10^6 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 条/秒算:
1085×103 =2×104 s≈5.5 h \frac{10^8}{5 \times 10^3} = 2 \times 10^4\ \text{s} \approx 5.5\ \text{h} 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(log⁡N)O(\log N) O(logN),ZREVRANGE 取前若干名是 O(log⁡N+M)O(\log N + M) O(logN+M)。1 亿成员存一份大概十 GB,内存不小,但方向是对的。

好友榜不是同一件事。我们看的是"我的好友里谁走得最多",每个人的成员集合不同,名次也不同,全局 ZSet 给的名次回答不了这个问题。

一人一份 ZSet 行不行

假设平均 200 个好友:
108×200=2×101010^8 \times 200 = 2 \times 10^{10} 108×200=2×1010

两百亿个条目,每条几十字节就是 TB 级,而这还是给一天只结算一次的数据准备的常驻内存。

存储还次要,麻烦的是写。一个人步数变了,要改他所有好友榜里那一条,写放大 200 倍。而步数不是晚上 10 点才写,是白天持续上报的,每次增量都变成 200 次操作。

查询时实时排序是另一副样子:拿 200 个好友 uid,批量取步数再排序,比较次数是 200× log⁡2 200≈1500 200 \times \log_2 200 \approx 1500 200×log2200≈1500 量级,一次查询毫秒以内,写入零成本。

方案 一次查询 一次步数更新 空间
每人一个 ZSet(预计算) O(log⁡k)O(\log k) O(logk),最省 O(klog⁡k)O(k \log k) O(klogk),要改 k 个榜 O(Nk)O(Nk) O(Nk)
查询时实时排序 O(klog⁡k)O(k \log k) O(klogk) O(1)O(1) O(1) 无

k 不是一个定值

选哪条路,看好友数 kk k 和访问频率 ff f,而且要看 kk k 的分布,不是平均值。微信好友上限的公开说法是 1 万左右, kk k 从几十到一万都有,长尾很长。按平均 200 设计,碰上一个 8000 好友的账号,读成本直接翻 40 倍。

还有一点容易忽略:真正进排行榜的人不等于好友数,没开微信运动的不参与,设置了不公开步数的也不参与。

所以可行的是分场景处理:

  • 好友少、访问不频繁:查询时直接算,成本低,也没有一致性问题
  • 好友多,或者被反复访问:算一次缓存起来,设短 TTL,下次直接读
  • 访问集中、好友又多的少数账号:单独处理

权衡的不是"预计算还是实时算",而是这个用户的 kk k 乘上访问频率 ff f 值不值得存一份结果。 kk k 小的时候存是浪费, kk k 大的时候不算就扛不住。


难点三:真的需要提前算吗

前面两节都在假设这 1 亿份排行榜要提前算好。但推送和计算本来就是两件事,产品上完全可以拆开。

提醒里如果只有"你今天走了 8324 步",不需要任何排名,只是把自己当天的数据读出来;需要"你在好友里排第 7",才需要排序,而这个排序也可以等用户点开时再做。

收益取决于点开率。假设 10% 的用户会点开(仍然只是假设),就是 1000 万次排序,比给 1 亿份都算一遍少一个数量级;更要紧的是没点开的那 90%,一次都没算。

但按需计算不是白赚的,它把压力从发送侧搬到了读取侧,还多出几个新问题:

问题 表现 常规对策
读洪峰 推送后几分钟用户集中点开,QPS 陡增 入口限流、排队、骨架屏
缓存冷启动 第一批请求全部回源 提前预热热点、合并请求
依赖放大 一次请求要读好友列表 + 批量取步数 好友列表本地缓存、批量接口
降级 高峰时算不出来 先只返回自己的步数,排名稍后加载

这里提醒一句:排序本身不贵,贵的是排序之前要拿的两样东西------好友列表和每个人的步数。所以按需方案优化的重点不在排序,在这两处读取。

不管选哪条路,核心的设计思想是同一个:最有效的优化,往往不是让计算更快,而是让不需要的计算根本不发生。1 亿次计算提速 10 倍,和只算 1000 万次,省下来的是同一个量级,但后者还省机器、省钱。


总结

  1. 集中到来的工作,先用分片和队列摊开,再谈怎么发得快。
  2. 排行榜怎么算,取决于 kk k 的分布和访问频率 ff f,不取决于"排行榜"这个功能名。
  3. 能用业务流程消掉的负载,就别用架构去硬扛。

但每一条都换来额外的复杂度:队列要运维,缓存会不一致,按需计算要处理冷启动和降级。1 万用户的时候,一个 crontab 加一次内存排序就够了,多一层队列反而是负担;到了 1 亿,前面每一步的复杂度又都省不掉。

相关推荐
ServBay1 小时前
PHP 突爆 9.2 分高危漏洞,你升级了吗?
后端·php
武子康2 小时前
撤回发生在重建途中,旧索引还能切回来吗
人工智能·后端·agent
码事漫谈2 小时前
双十一,我为什么想买一台编码显示器
后端
喵个咪3 小时前
GoWind Kanban 拆解:看板拖拽背后——LexoRank、乐观锁与尽力而为广播
后端·架构·go
喵个咪3 小时前
GoWind OA 拆解:用 3000 行 Go 写一个够用的审批工作流引擎
后端·架构·go
喵个咪3 小时前
GoWind Ledger 拆解:单式记账的余额一致性——confirm、补偿,以及没进事务的那些缝隙
后端·架构·go
喵个咪3 小时前
GoWind Shop 拆解:订单超时关单——一个事务里的四联动
后端·架构·go
我命由我123453 小时前
Python 开发 - Python 虚拟环境
运维·开发语言·后端·python·http·https·java-ee
梦影_3 小时前
MySQL进阶教程(一)—— 储存过程与触发器(上)
开发语言·数据库·后端·sql·mysql·中间件