设计一个排行榜系统(如抖音热榜)
提出问题
"抖音热榜"、"微博热搜"这类实时排行榜,背后既要应对海量用户行为(点赞、播放、分享),又要保证榜单秒级更新,同时还要防刷、做时间衰减。面试官问这个题,表面上是考 Redis ZSet 的熟练度,但实际上想听的是:单 ZSet 的瓶颈在哪、怎么分层、同分怎么处理、多维度榜单怎么设计。生产上,一个百万级日活的产品,Hot Key 打在主 ZSet 上就是灾难,Facebook 就曾因为排行榜缓存穿透打崩过 Redis 集群。
分析问题
核心方案:Redis ZSet
最直接的做法是 Redis ZSet,每个元素是一个 Member(视频 ID),score 是热度值。ZINCRBY 更新分数,ZREVRANGE 0 99 取 Top 100 榜单。
# 用户点赞一次,增加视频热度
> ZINCRBY hot:total 1 video:12345
> ZINCRBY hot:total 1 video:67890
# 取 Top 5
> ZREVRANGE hot:total 0 4 WITHSCORES
1) "video:67890"
2) "5000"
3) "video:12345"
4) "3200"单 ZSet 在百万级数据量下 O(log N) 的插入和查询是没问题的。真正的瓶颈在写放大:假设有 100 万条视频、每秒 10 万次行为更新,每个 ZINCRBY 都要操作跳表 + 字典,Redis 单线程扛不住这种写入吞吐,IO 打满,CPU 100%。
分层合并策略
将热数据按时间分桶,写入压力分散到多个小 ZSet,查询时合并。
时间维度分桶:
hot:20260721-14 ← 小时桶,写入
hot:20260721-13 ← 过期小时桶,只读
hot:20260721 ← 天桶,每小时合并
hot:week-29 ← 周桶,每天合并写入流程:用户行为 → ZINCRBY hot:20260721-14 video:12345 1,只写这个小时桶。 查询流程:ZUNIONSTORE hot:tmp 2 hot:week-29 hot:day-20260721 WEIGHTS 1 1,然后从临时 ZSet 取 Top N。
# 合并小时桶到天桶(跑定时任务,每小时一次)
> ZUNIONSTORE hot:day-20260721 24 hot:20260721-00 hot:20260721-01 ... hot:20260721-23 AGGREGATE SUM
# 查询当天 Top 100
> ZREVRANGE hot:day-20260721 0 99 WITHSCORES这个架构的核心代价是实时性:新内容进小时桶后,最快也要到下个合并周期才能在总榜出现。如果业务要求秒级总榜,可以用两层架构:第一层用 Redis Hash 做实时计数(HINCRBY 比 ZINCRBY 快 3-5 倍,因为不用维护跳表),第二层每秒将 Hash 增量刷到 ZSet。牺牲 1 秒实时性,换来 10 倍写入吞吐。
深入原理:ZSet 底层为什么扛不住高写入?
Redis ZSet 底层是 ziplist(小数据量)+ 跳表(skiplist)+ 字典(dict) 的组合。
- 当元素个数 < 128 且元素大小 < 64 bytes 时,用 ziplist(压缩的双向链表),插入 O(N)。
- 超过阈值后,升级为跳表 + 字典二重结构:跳表负责按 score 排序和范围查询,字典负责按 member 精确查找,
ZINCRBY时先走字典 O(1) 找到旧 score,再通过跳表 O(log N) 删除旧节点、插入新节点。
所以每次 ZINCRBY 背后是:字典查找 O(1) + 跳表删除 O(log N) + 跳表插入 O(log N)。单线程 Redis 每秒钟能处理约 5-10 万次这种操作(取决于机器)。超过这个量,CPU 100%,请求排队,延迟飙升。
真实案例:我上一个项目做实时活动排行榜,QPS 8 万时发现 Redis 的 used_cpu_sys 飙到 80%,ZINCRBY 平均耗时从 0.1ms 涨到 3ms。排查发现单 ZSet 有 200 万 member,跳表高度 20+ 层,每次插入都要从顶层逐层下降,恰好又是高并发写入,Redis 单线程全部串行排队。最终方案是切到两层架构 + 时间分桶,写入 QPS 从 8 万降到每桶 1-2 万,打散到 24 个桶。
时间衰减与降权
纯热度排序容易被刷榜。一个 2020 年的视频靠累计播放量一直霸榜,新内容永远上不去。引入时间衰减:
score = 点赞数 × score_decay_factor ^ ((current_time - publish_time) / half_life)衰减因子取 0.8-0.95,half_life 根据业务定(抖音热榜可能 2 小时,微博热搜可能 30 分钟)。每次查询时实时计算 score,不存衰减后的值——因为衰减因子随时间变化,存了会过期。
# 衰减因子计算伪代码
import math
def hot_score(likes, publish_ts, now_ts, half_life=3600, decay=0.9):
elapsed = max(0, now_ts - publish_ts)
return likes * (decay ** (elapsed / half_life))实践中更常见的做法是离线计算:每小时用 Spark/Flink 扫一遍全量数据,计算衰减后的 score 写入 ZSet。在线只做增量更新(点赞这种行为),离线再算全量衰减,两种榜同时提供给客户端。
这儿有个坑:衰减因子 decay 设在 0.5 以下的话,2 个 half_life 后分数就掉到 25%,新内容还没攒够曝光量,会出现"榜单全是新内容、没人看的老内容上不去"的极端情况。抖音热榜的 half_life 大约是 2 小时,decay 取 0.85,这样 24 小时后一条热门视频的分数衰减到原来的 0.85^(24/2) ≈ 0.14,既给新内容机会,又不让有价值的旧内容瞬间消失。
同分处理与多维度排行榜
多个内容热度相同但 Top K 需要固定顺序。加一个微小的随机值:
final_score = hot_score + 1e-10 * (1 - 1 / (user_id + 1))这个值在 0 到 1e-10 之间,不影响排名,但能保证同分时固定顺序,且同一用户多次刷新不会看到榜单跳动。
更进阶的做法:用 UUID 做 member 后缀。比如 video:12345#uuid,每个写入操作生成唯一后缀,保证 ZSet 内的 member 天然不重复,不会出现同分覆盖。但代价是 ZSet 总大小会膨胀,因为同一个内容可能有多条记录——需要定期清理过期后缀。
多维度榜单(24 小时榜、好友榜、地区榜)每个维度一个 ZSet。写入时用 Lua 脚本保证原子性:
-- Redis Lua 脚本:一次写入多个维度
redis.call('ZINCRBY', 'hot:24h', 1, KEYS[1])
redis.call('ZINCRBY', 'hot:region:' .. ARGV[1], 1, KEYS[1])
redis.call('ZINCRBY', 'hot:category:' .. ARGV[2], 1, KEYS[1])
return 1为了减少客户端延迟,Top 100 榜单结果可以缓存到本地内存(5-10 秒更新一次),因为榜单变化不会那么快,且用户频繁刷新时缓解 Redis 压力。
热搜榜的特殊设计
热搜榜和普通排行榜有本质区别:热搜的关键是"突然变热",而不是"一直最热"。
抖音热榜的算法逻辑近似:
- 基础热度 = 播放量 × 0.3 + 点赞量 × 1 + 分享量 × 2 + 评论量 × 3(权重根据业务调整)
- 爆发力因子 = 当前时段热度 / 历史时段热度均值。如果某视频在 10 分钟内热度突然暴涨 5 倍,爆发力因子 = 5
- 最终热度 = 基础热度 × 爆发力因子
这个脉冲检测的实现方式:每个视频维护一个 5 分钟和 60 分钟的滑动窗口计数,用 Redis 的 EXPIRE 自动过期。如果 5 分钟窗口内的增量是 60 分钟窗口的 10 倍以上,触发"爆"信号,临时提高该视频在榜单中的权重。
# 微博热搜伪代码:突发热度检测
def detect_burst(video_id, window_5min, window_60min):
if window_60min < 100: # 数据量太少,不触发
return 1.0
burst_ratio = window_5min / (window_60min / 12) # 归一化到 5 分钟
if burst_ratio > 5:
return min(burst_ratio * 1.5, 20) # 爆发力上限 20 倍
return 1.0对比不同方案的写性能
| 方案 | 单次写入耗时 | 每秒最大写入 | 实时性 | 实现复杂度 |
|---|---|---|---|---|
| 单 ZSet 直接写 | 0.1-3ms | 3-8 万次 | 秒级 | 低 |
| 时间分桶 + 分层合并 | 0.02-0.1ms | 10-20 万次 | 分钟级 | 中 |
| 两层架构(Hash + ZSet) | 0.01-0.05ms | 30-50 万次 | 1-2 秒 | 中高 |
| 离线计算(Spark 定时) | 不适用 | 无限(批处理) | 5-10 分钟 | 高 |
生产避坑要点:
- 用
ZREVRANGE而不是ZRANGE(别取反了排名) - 大规模 ZSet 的
ZUNIONSTORE是 CPU 杀手,拆分 + 定时合并。实测 100 万 member 的 ZSet 做一次ZUNIONSTORE大概需要 50-100ms,如果客户端查询高峰期触发,直接影响正常请求延迟 - 本地缓存榜单时一定带 TTL,避免客户端看到过期数据。TTL 推荐 5-10 秒,太短缓存失效打回 Redis,太长用户看到榜单不更新
- 线上不要直接
ZUNIONSTORE几十个桶,用渐进式合并(小时桶先合并到天桶,天桶再合并到周桶) - 如果使用 Redis Cluster,ZSet 的 member 全部落在同一个 slot 上(因为同一个 key),所以 ZSet 不能跨节点分片。一个 ZSet 的热点就是单节点的瓶颈。解决方案是人工分片:
hot:shard0~hot:shard15,写入时video_id % 16路由到对应分片,查询时合并 16 个分片的 Top N
参考:Redis ZSet 源码分析(src/t_zset.c)、抖音热榜/微博热搜架构设计分享、时间衰减算法(Exponential Time Decay)实现