设计一个实时排行榜

深入系统设计场景题高频约 13 分钟读完

一句话回答

实时排行榜的核心是 Redis ZSet:ZINCRBY 加分,ZREVRANGE 取前 N 名,ZREVRANK 查自己的名次,复杂度都是 O(log N) 级别。同分排序靠把时间编码进 score:score = 分数 × 2^28 + (2^28 − 1 − 达到该分数的时间),先达到的排前面,总值不能超过 2^53,更新时要用 Lua 脚本原子地"解码、加分、重新编码"。日榜、周榜、总榜用不同的 key,按周期命名并设置过期时间。成员上千万时单个 ZSet 是大 Key,可以按用户分片再合并,或者只精确维护头部、其余用分段统计给出近似排名。Redis 不是数据源:分数流水落库,Redis 可以重建,发奖以数据库里的快照为准。

详细解析

第一步:澄清需求

  • 场景:游戏或活动的积分榜,分数只增不减
  • 功能:实时更新分数;查前 100 名;查自己的名次和分数;查自己前后各 5 名;日榜、周榜、总榜;同分时先达到的排前面
  • 量级估算(假设值):
    • 总榜 1000 万玩家,日活 200 万;每人每天得分 50 次:1 亿次更新/天,平均约 1160 次/秒,峰值按 10 倍算约 1.2 万次/秒
    • 内存:每个成员按 100 字节估算(成员 ID、score、跳表节点和哈希表的开销,实际用 MEMORY USAGE 测),1000 万 × 100 字节 ≈ 1 GB;日榜只有日活用户,约 200 MB
    • 1 GB 的 ZSet 已经是大 Key,删除、迁移、持久化都会受影响,见大 Key 和热 Key

第二步:整体架构

文本
游戏服务 / 业务服务 ──> 得分事件(消息队列,每个事件带唯一 ID)
                          ▼
                     排行榜服务(消费者)
                       1 写分数流水和用户总分到 MySQL,按事件 ID 去重
                       2 更新 Redis 的日榜、周榜、总榜(Lua 脚本,同分按时间排)
客户端 ──> 排行榜服务(查询)
            前 100 名:ZREVRANGE,结果在本地缓存 1 秒(榜首被所有人查看,是热 Key)
            我的名次:ZREVRANK + ZSCORE
            附近的人:ZREVRANK 得到名次 r,再 ZREVRANGE 取 r-5 到 r+5
定时任务:对账(Redis 和 MySQL 的总分);周期结束时生成榜单快照,用于发奖

ZSet 的底层是跳表 + 哈希表,跳表节点记录了每层跨过的节点数,所以查名次也是 O(log N),见 Redis 的数据类型和跳表的原理。

第三步:核心模块和数据模型

榜单的 key:

榜单 key 过期时间
日榜 lb:g1:day:20261011 当天结束后再保留 3 天(假设),用于展示昨日榜单
周榜 lb:g1:week:2026-W41 本周结束后再保留 2 周
总榜 lb:g1:total 不过期
  • 每次得分同时更新三个榜,用 Pipeline 一次发出;过期时间用 EXPIREAT 设成绝对时间,每次写入都设置同一个值,重复设置没有副作用
  • 也可以只写日榜,周榜用 ZUNIONSTORE 合并 7 个日榜生成。省了写入,但合并大集合很耗时,会阻塞 Redis,只适合定时生成、不要求实时的榜单

同分排序:ZSet 同分时按成员的字典序排,和时间无关。要让先达到的排前面,把时间编进 score:

文本
score = 分数 × 2^28 + (2^28 − 1 − 距活动开始的秒数)
  分数高的 score 一定更大;分数相同时,时间越早,后半部分越大
  2^28 秒约 8.5 年,够一个榜单的周期
  score 是双精度浮点数,只能精确表示 2^53 以内的整数,所以分数不能超过 2^25 − 1(约 3355 万)
  分数更大时,减少时间的位数,比如时间只精确到分钟

不能直接 ZINCRBY 加"分数 × 2^28":后半部分还停留在第一次得分的时间,而不是达到当前分数的时间。要用 Lua 脚本读出旧 score、解出分数、加分、按当前时间重新编码,整个过程是原子的(见代码示例)。

数据库:score_log(事件 ID 唯一、用户、加分、时间)和 user_score(用户、总分、达到总分的时间)。数据库是数据源,Redis 只是为了实时查询而维护的视图。

第四步:关键难点

超大规模:分片与近似排名:

方案 做法 优点 缺点 适用场景
单个 ZSet 所有用户放在一个 key 简单,名次精确 千万级以上是大 Key,只能放在一个节点 百万级以内
按用户分片 按用户 ID 哈希到 N 个 ZSet 分散内存和请求 前 N 名要合并各分片;查名次要问遍所有分片 千万级,要精确名次
头部精确 + 分段近似 ZSet 只保留前 1 万名(写入后用 ZREMRANGEBYRANK 裁掉多出的);全体用户按分数段计数 内存小、查询快 头部以外的名次是近似值 亿级,只有头部需要精确
  • 分片后取前 N 名:每个分片 ZREVRANGE 0 N-1,合并后取前 N
  • 分片后查名次:在每个分片上 ZCOUNT key (myScore +inf 统计比我高的人数(myScore 是我编码后的 score),求和再加 1,用 Pipeline 一次发出
  • 分段近似:用 Hash 记录每个分数段(比如每 100 分一段)有多少人,用户跨段时旧段减 1、新段加 1。名次 ≈ 更高分段的人数之和 + 本段人数 × 本段内比我高的比例(按均匀分布估算)。界面上显示"超过了 87% 的玩家"比显示一个不准的名次更合适

持久化与对账:

  • Redis 的持久化(见 RDB 和 AOF)只能减少丢失,不能代替数据库;Redis 数据丢了,从 user_score 分批 ZADD 重建
  • 消息会重复投递:先按事件 ID 写数据库,写成功了再更新 Redis,重复的事件在数据库这一步就被拦下,见重复消费与幂等
  • 写完数据库、更新 Redis 前进程崩溃,Redis 会少加一次分:定时对账,以数据库为准修正
  • 周期结束时把最终榜单快照写入数据库,发奖按快照,不读实时的 Redis

查自己的名次和附近的人:ZREVRANK 返回从 0 开始的名次,不在榜上时返回 nil,界面上显示"未上榜";附近的人取 max(0, r - 5) 到 r + 5。这两步不是原子的,中间名次可能变化,对排行榜来说可以接受。

第五步:扩展与优化

  • 热点:前 100 名被大量用户同时查看,在服务本地缓存 1 秒,或者定时算好后推到缓存
  • 好友榜:好友数量有限,取出好友列表后批量 ZSCORE(或 Redis 6.2 起的 ZMSCORE)查分数,在应用里排序
  • 多维排序:分数相同再比等级、再比时间,都可以编码进 score,前提是总位数不超过 53 位

代码示例

关键的 Redis 命令(不考虑同分排序时)。ZREVRANGE 从 Redis 6.2 起被标为过时,新代码可以写成 ZRANGE key 0 9 REV WITHSCORES,效果相同:

Shell
redis-cli ZINCRBY lb:g1:total 30 user:1001             # 加 30 分,返回新的分数
redis-cli ZREVRANGE lb:g1:total 0 9 WITHSCORES         # 前 10 名,带分数
redis-cli ZREVRANK lb:g1:total user:1001               # 名次,从 0 开始;不在榜上返回 nil
redis-cli ZSCORE lb:g1:total user:1001                 # 分数
redis-cli ZREVRANGE lb:g1:total 37 47 WITHSCORES       # 假设名次是 42,取前后各 5 名
redis-cli ZCOUNT lb:g1:total:0 '(8053332114455' +inf   # 分片时:这个分片里比我高的人数,"(" 表示不含边界
redis-cli EXPIREAT lb:g1:day:20261011 1791993600       # 日榜在北京时间 2026-10-15 00:00 过期

同分按时间排序的加分脚本(Lua),以及查询时把 score 解码成分数(JavaScript):

Lua
-- KEYS[1]:榜单 key;ARGV[1]:用户 ID;ARGV[2]:本次加的分;ARGV[3]:活动开始的 Unix 秒数
local R = 268435456 -- 2^28
-- 先调用 TIME 再写入:Redis 5 起脚本默认按效果复制,允许这样做;更早的版本要先调用 redis.replicate_commands()
local now = tonumber(redis.call('TIME')[1]) - tonumber(ARGV[3]) -- 距活动开始的秒数,用 Redis 服务器的时间
local old = tonumber(redis.call('ZSCORE', KEYS[1], ARGV[1])) or 0 -- 不在榜上时 ZSCORE 返回 false
local points = math.floor(old / R) + tonumber(ARGV[2])
-- score 直接以数字传给 redis.call,不要先 tostring:Lua 默认的格式只保留 14 位有效数字
redis.call('ZADD', KEYS[1], points * R + (R - 1 - now), ARGV[1])
return points
JavaScript
const TIME_RANGE = 2 ** 28
const MAX_POINTS = Math.floor(Number.MAX_SAFE_INTEGER / TIME_RANGE) // 2^25 - 1

function encodeScore(points, seconds) {
  if (points < 0 || points > MAX_POINTS) throw new RangeError('分数超出可编码范围')
  if (seconds < 0 || seconds >= TIME_RANGE) throw new RangeError('时间超出可编码范围')
  return points * TIME_RANGE + (TIME_RANGE - 1 - seconds)
}

function decodeScore(score) {
  const points = Math.floor(score / TIME_RANGE)
  return { points, seconds: TIME_RANGE - 1 - (score - points * TIME_RANGE) }
}

console.log(encodeScore(100, 10) > encodeScore(100, 20)) // true:同为 100 分,先达到的排前面
console.log(encodeScore(101, 999) > encodeScore(100, 0)) // true:分数高的永远在前
console.log(decodeScore(Number('8053332114455'))) // { points: 30000, seconds: 1000 }:Redis 返回的 score 是字符串

面试官可能追问

为什么不直接用 MySQL 的 ORDER BY 做排行榜?

取前 100 名,在分数上建索引、ORDER BY score DESC LIMIT 100 还能接受;但查"我排第几"要 COUNT(*) WHERE score > 我的分数,要扫描所有比我高的行,用户多了就很慢。分数频繁更新也意味着频繁维护索引。ZSet 查名次是 O(log N),更新也是 O(log N),更适合实时榜单。

周期结束发奖,怎么保证榜单准确、公平?

截止时刻之后,等一个宽限期,让还在消息队列里的得分事件处理完,再生成快照写入数据库,之后到达的事件不计入本期。快照生成前用数据库的分数流水对账一遍。头部玩家的分数再做一次反作弊审核,确认后按快照发奖。

分数可以减少(扣分)时,这套设计还成立吗?

基本成立:给 Lua 脚本传入负的加分即可,但脚本里要检查扣完不能小于 0,因为编码要求分数非负。同分排序的语义变成"最后一次变动得早的排前面",要和产品确认这个规则。分段近似统计在分数下降时,同样要把用户从旧分段移到新分段。

易错点

  • 以为同分时按时间先后排,实际上 ZSet 同分按成员的字典序排
  • 把时间编进 score 后还用 ZINCRBY 加分,时间部分停留在第一次得分的时间
  • 编码后的 score 超过 2^53,精度丢失,排序出错
  • 把 Redis 当唯一的数据源,发奖直接读实时榜单

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。