昨天刷掘金,热榜上那篇《微信运动每天晚上 10 点推送排行榜,技术上到底有多难?》挺火。作者观察到一个细节:推送时间不是整点齐刷刷到的,而是"10点整后的1分钟上下浮动"。文章是分析向的,讲了一堆难点,看得我手痒。
看别人分析"有多难",永远不如自己跑一遍踏实。今天下午我就在那台老掉牙的 1核2G 云机上,把这套系统里最核心的两块复刻了:排行榜(Redis ZSet)和定点推送。先说清楚,微信内部怎么实现的我不知道,人家也没公开过,我做的是"如果这个系统落在我手里,我会怎么做、会踩什么坑"的验证。环境:Ubuntu 22.04,Redis 6.0.16,node-redis 6.3.0。
排行榜本体,快得没话说
排行榜的选型没什么悬念。步数上报是高频写、榜单是高频读,天然就是 ZSet 的形状:ZINCRBY 累加步数,ZRANK 查名次。我模拟了 10 万个用户,每人当天上报 10 次步数,凑成 100 万次 ZINCRBY,用 pipeline 攒批(1000 条一批)打进去:
let multi = client.multi();
let pending = 0;
for (let u = 0; u < UID_TOTAL; u++) {
for (let r = 0; r < REPORTS_PER_USER; r++) {
multi.zIncrBy(KEY, inc, pad(u)); // pad(u): uid 补零成 6 位
if (++pending >= 1000) {
await multi.exec(true);
multi = client.multi();
pending = 0;
}
}
}
结果:100 万次写入总共 4.36 秒,折合每秒 22.9 万次操作。10 万个 member 的 ZSet,MEMORY USAGE 一查,9.2 MB。按这个比例外推,100 万用户也就 92 MB 上下,一台小机器塞得下一个城市的排行榜。查询侧更没什么好说的:取 Top20 均值 0.106 毫秒,查自己的名次 0.062 毫秒,全是微秒级的事。
跑到这里我还挺得意,觉得这波复刻稳了。然后我看了眼自己打出来的榜单。
第一个坑:榜一是 0 步
榜单第一名的步数是 0。
对,0 步。当天步数上限我设了 2 万,怎么轮都轮不到 0 分。排查了半天,发现根本不是 Redis 的事,是我用的 node-redis 客户端——v6.3.0——把我给埋了。
我一开始取 Top20 用的是老写法 zRange(KEY, 0, 19, { REV: true }),直接给我甩了个 ERR syntax error。查文档发现 v6 把选项名改了,就顺手换成了 { reverse: true }——它不报错了,然后给我返回了一个升序列表。也就是说这个选项被静默忽略了,我拿到的"榜一"其实是全榜倒数第一。中途我还试过 zRevRange,方法直接没了,v6 砍掉了。
三个写法三种死法:一个报错,一个静默忽略,一个直接删了。真正难缠的是中间那个,静默忽略不报错,代码看起来完全正常,数据就是错的。这种坑要是混进生产代码,排查成本比报错高一个量级。
最后的正确姿势是拿负索引取尾部再自己反转,v6 下最稳:
const tail = await client.zRangeWithScores(KEY, -20, -1); // 分数最高的20个,升序
const top20 = tail.reverse(); // 手动反成降序
改完榜一就正常了:uid=098055,19999 步。这事给我的教训挺实在的:升级客户端库的时候,选项参数变了不一定会报错,有的会安静地给你返回错的数据。
真正的坑:同分的人怎么排
修好榜一之后,我以为踩坑环节结束了,结果又冒出来一个更麻烦的。 我故意把步数范围压在 2 万以内,10 万个用户,平均每个分数上挤着 5 个人——就是想看看同分的时候 Redis 怎么表现。一查,uid=284 和 uid=435,两个人都是 11990 步:
ZRANK(284) = 59963
ZRANK(435) = 59964
Redis 给的是严格序:同分用户按 member(这里是补零的 uid)的字典序排,一个前一个后,差 1 位。它不会告诉你"这两个人并列第 59964 名"。 这就是排行榜需求里最常见的错位:后端拿 ZRANK 一查,每人一个独立名次;产品那边画的原型是同分并列、名次一样。两个说法都对,但 Redis 原生只有前者,后者——数据库里叫 dense rank 的那个——得自己算。
解法无非两条路。要么在应用层把榜单拉出来自己归并同分(榜单页就 Top 几百人,代价小,但要保证每一屏的口径一致);要么动 score,把名次信息编码进去,比如用 步数 * 1e6 + (999999 - uid) 这种复合分数——同分时 uid 小的排前面,天然稳定,但代价是 score 里再也不能存纯步数,所有读步数的地方都得做除法还原。两条路我都没走到生产,纸上谈兵的部分就不展开了,但"ZRANK 不能直接当名次返回给前端"这一点,值得单独记一笔。
定点推送:那 1 分钟的浮动是设计出来的
排行榜好歹有标准答案,定点推送这块才是真正"有多难"的部分。 先复述一下要解决的问题:10 万人,要在 10 点整推一条"今天的步数和排行榜"。最直觉的做法就是定时任务一到点,把 10 万人怼进队列,开推。我模拟了一次"推送"是 20 毫秒的下发(模拟一次 RPC),并发上限 50,照着这个直觉跑了一遍,结果:
最后用户在触发后 40.4s 收到,P50=20.2s,P99=40.0s
每秒推送量: 2351, 2499, 2450, ... 峰值 2500/s,高位持续 40s 后归零
10 点整之后的 40 秒里,推送通道被死死顶在 2500/秒的峰值上,40 秒后归零。这在技术上完全能跑通,但有两个别扭的地方:基础设施得按峰值建,而峰值每天只出现一次、只持续 40 秒;并且这 40 秒里下游任何一点抖动,都会把尾部用户的等待时间拖长不少。
然后我换了个思路:把 10 万人按 uid 模 60 分桶,10:00:00 放第 0 桶,10:00:01 放第 1 桶,每秒放一桶,桶内还是同样的并发上限。跑完的结果是每秒推送量恒定在 1667 上下,波动区间 [1666, 1667],最后一个用户在 60 秒内收到。
把两条曲线摆在一起看,总时长其实没省——一边 40 秒推完,一边 60 秒推完,还慢了 20 秒。省的是峰值:从 2500/秒的尖峰压到了 1667/秒的平线,只有原来的 67%,而且这条平线是全天可预测、可复用的负载。
这时再回头看那位作者观察到的"10点整后1分钟上下浮动",我基本可以断定:这不是微信的调度出了什么偏差,恰恰是这么设计的。10 万人(实际上是几亿人)不可能也不应该在 10:00:00 同一秒打到推送网关上,错峰调度天然会把送达时间摊在整点后的一段窗口里。那个"1 分钟"大概率就是他们错峰窗口的边界。推送这种事,图钉一样的尖峰是灾难,平线才是好市民。
补一个实操细节:错峰调度最怕服务重启后重推。给每个用户当天置一个幂等键(SET wx:push:done:{当天日期}:{uid} 带过期)就够用,第二次跑直接跳过。这一步很无聊,但缺了它,任何一次发布事故都会变成"用户收到三条排行榜"。
离生产还差什么,以及我拿到的答案
这套复刻离生产还远:没有持久化(实验里 RDB 和 AOF 全关了)、没有集群、没有真实的推送通道、也没模拟"同一时刻有人疯狂上报"的对抗场景。步数这种核心数据放 Redis 单点,真做产品还得加一层落库。这些今天的实验都覆盖不了,我也就不装了。
但两个核心问题算是有了自己的答案:排行榜本体,100 万次更新 4 秒、内存 9.2 MB,性能上根本不构成问题,值得花心思的是同分排名的语义和客户端库的版本陷阱;定点推送,难的不是"能不能推完",而是把 40 秒的尖峰摊成 60 秒的平线——顺便解释了那个所有人都在体验、但很少有人想过为什么的"10点后1分钟浮动"。
至于 node-redis v6 那几个静默坑,就当是我替你们踩的。踩完的感觉说不上好,但比报错好——报错起码告诉你它生气了。