2898 字
约 9 分钟
13
S12-X10 · 榜单系统设计:ZSet 的能力边界

一个"积分排行榜"看起来是 Redis 最不用动脑的需求:ZADD 加分,ZREVRANGE 取前百。真正上线后问题才来:同分的人顺序每天不一样、日榜和周榜的 key 越攒越多、用户点"我的排名"要等好几秒、运营说榜单和后台数据对不上。

ZSet 能覆盖榜单的八成需求,剩下两成要么靠编码技巧,要么就该换存储。下面把能力和边界一起画出来。


1. 底层:跳表 + 字典

ZSet 内部是跳表(skiplist)负责按 score 排序 + 字典(dict)负责 member → score 的 O(1) 查找。这决定了两类操作的代价完全不同:按分数取区间很便宜,按成员查分数也很便宜,但"按成员做复杂过滤"没有索引可用。

命令 复杂度 用途
ZADD O(log N) / 元素 加分、更新分数
ZINCRBY O(log N) 增量加分(原子)
ZSCORE O(1) 查某人分数
ZCARD O(1) 榜单总人数
ZREVRANGE O(log N + M) 取前 M 名
ZREVRANK O(log N) 我的名次
ZCOUNT O(log N) 分数区间人数(算排名区段)
ZRANGEBYSCORE O(log N + M) 按分数段取人

注意 ZADD 一次写多个成员是 O(M·log N),批量导入时用管道而不是循环单条。


2. 需求拆成命令

需求 实现
总榜 rank:all,ZINCRBY 累加,ZREVRANGE 0 99 取前百
日/周/月榜 每周期一个 key,rank:d:20261109,带过期
我的排名 ZREVRANK rank:all <uid>,返回 0 基下标,+1 即名次
同分排序 分数编码里带时间或 uid(见第 3 节)
分页 ZREVRANGE start stop
前 N 名截断 写入时 ZREMRANGEBYRANK 0 -(N+1),只保留前 N
ZINCRBY rank:all 15 8848               # 加分
ZREVRANGE rank:all 0 9 WITHSCORES      # 前十
ZREVRANK rank:all 8848                 # 我的名次(0 基)
ZREMRANGEBYRANK rank:all 0 -101        # 只留前 100(负数下标表示保留尾部)

3. 同分排序:别把时间戳直接塞进 score

ZSet 在 score 相同时按 member 的字典序排列。所以同分问题有两个解法:把次序编码进 score,或者把次序编码进 member。

// 方案 A:主分数 × 10^6 + 次序位(次序位取 1e6 - 秒级时间戳的低位)
double score = points * 1_000_000.0 + (1_000_000 - (ts / 1000 % 1_000_000));

// 方案 B(推荐):分数保持干净,次序交给 member 字符串
String member = String.format("%013d:%d", Long.MAX_VALUE - ts, uid);  // 先到者字典序更小
编码方式 优点 陷阱
points * 1e6 + seq 单值排序,查询简单 points * 1e6 超过 2^53 后精度丢失,名次开始乱
直接 points * 1e13 + (maxTs - ts) 精确到毫秒 主分数必须小于约 900 才不溢出
member 里拼时间戳 + uid 无精度问题,可读 需要定长补零,否则字典序错
score + 微小随机数 打散同分 破坏"同分同名次"的语义,且会累积漂移

double 的整数精度只有 53 位(约 9×10^15)。毫秒时间戳现在是 1.7×10^12 量级,乘 1000 就到 1.7×10^15,再乘任何系数都会越界。凡是"时间戳 × 倍数"的写法,都要先算一遍上界。


4. 多周期榜单的两种做法

# 做法一:每周期一个 key,过期时间设在周期结束后一段时间
ZINCRBY rank:d:20261109 15 8848
EXPIREAT rank:d:20261109 1794326400      # 周期结束 + 24 小时

# 做法二:单 key + member 前缀(不推荐用于需要分页排名的场景)
ZINCRBY rank:period 15 "d:20261109:8848"
做法 排名查询 内存 坑
每周期一 key 直接 ZREVRANGE 周期数 × 人数,靠过期回收 忘了设过期 → key 无限增长
单 key + 前缀 无法直接分页(不同周期分数混排) 单 key 膨胀成大 key 需要额外按前缀聚合,退化成扫描

选做法一,并且过期时间必须显式设置:ZINCRBY 不会自动带 TTL,新 key 默认永不过期。周榜、月榜、赛季榜的 key 数量按"周期 × 榜单维度"增长,漏一个维度就是内存泄漏。


5. 我的排名与分页边界

# 精确名次:0 基下标 + 1
ZREVRANK rank:all 8848

# 同分多人时的"并列名次":数比我高的人数 + 1
ZCOUNT rank:all (15 +inf        # 分数严格大于 15 的人数

# 深分页:第 1001–1020 名
ZREVRANGE rank:all 1000 1019 WITHSCORES
需求 代价 建议
我的精确名次 O(log N) 直接 ZREVRANK,别用"遍历前 N 找自己"
并列名次 O(log N) ZCOUNT 数更高分人数,天然处理同分
前 100 名 O(log N + 100) 主力接口,可加本地缓存
第 1000 页以后 仍要取 M 个元素 产品上限制翻页深度,或只保证 Top K 精确
亿级用户的"前 1%" 单 key 内存吃紧 分数桶近似:按 100 分一档 ZCOUNT 估算区段

真实产品里"我的排名"往往只需要展示"前 5%"或"第 1200 名左右",这类需求用分数桶 + ZCOUNT 就能在 O(log N) 内给出区段,不必维护精确名次。


6. 对账:榜单不是真相源

// 定时重建:从 DB 汇总后分批 ZADD 覆盖,避免与实时写互相打架
try (Jedis jedis = pool.getResource()) {
    Pipeline p = jedis.pipelined();
    for (ScoreRow r : mapper.selectTopScores(batchSize)) {
        p.zadd("rank:all", r.getPoints(), String.valueOf(r.getUid()));
    }
    p.sync();
}
机制 作用 注意
异步落库 榜单写 Redis,明细异步写 DB 队列积压会导致榜单领先于 DB
定时全量重建 修正漂移、清理脏数据 重建要写临时 key 再 RENAME,避免读到半成品
抽样核对 比对 ZSCORE 与 DB 值 只抽样 Top N + 随机样本,成本可控
双写校验 写入路径上比对两边结果 影响写性能,只在高价值榜单用

榜单是派生数据,DB 才是真相。所有"榜单和后台对不上"的事故,最后都是因为有人把榜单当成了唯一记录。


7. 超大规模的分片与归并

分片方式 做法 归并代价
按分数段 rank:0(0–999 分)、rank:1(1000–1999 分)… 从高段往下取,凑满 N 个即停
按用户哈希 rank:{0..15},每人落一个分片 全局名次 = 各分片 ZCOUNT 之和(可并行)
按业务维度 按地区/品类分榜 各自独立,无需归并

按用户哈希分片后,"我的名次"要查 16 个分片再求和,单次请求 16 次 ZCOUNT 在 pipelines 下可以接受,但它把 O(log N) 变成了 O(分片数 × log N)——分片数别超过几十个。按分数段分片则天然支持"只要 Top N",代价是段边界要动态调整,否则热点集中在最高段。


8. 超出 ZSet 能力的需求

需求 为什么 ZSet 不行 换什么
多维度组合排序(分数 + 地区 + 等级) 只有一个 score 维度 预计算复合分,或 ES / ClickHouse
榜单内复杂过滤("只看未封禁用户") 没有过滤索引,只能取回后过滤 维护黑白名单 ZSet 做集合运算,或用支持条件的存储
时间窗口滑动的实时榜(近 1 小时) 需要按事件时间聚合 时间分桶 ZSet 求和,或流式计算
十亿级成员 + 高频写 单 key 内存与单线程写瓶颈 分片 + 离线批算,Redis 只存 Top N

一句话:ZSet 擅长"单一维度排序 + 取区间",不擅长"带条件的排序"。需要过滤的时候,先把过滤结果预计算成另一个 ZSet,是成本最低的折中。


9. 常见坑

坑 后果 正确做法
score 精度丢失 同分或相邻分的人名次随机跳 上界先算 2^53,或改用 member 编码
每周期 key 不设过期 内存持续上涨,某天突然 OOM 建 key 时同事务设 EXPIREAT
实时写 DB 拖慢榜单 写路径 RT 翻倍,Redis 与 DB 互相等 榜单只写 Redis,明细异步落库
榜单与库不一致且无对账 运营发现数据对不上,无法定位 定时重建 + 抽样核对
ZADD 覆盖而非累加 多次加分被最后一次覆盖,分数变低 用 ZINCRBY,或确认写入语义
前 N 截断用 ZREMRANGEBYSCORE 边界分数的人被整批删掉 用 ZREMRANGEBYRANK 按名次截断

10. 排错

现象 原因 处理
同分用户顺序每天变 只按 score 排,member 顺序变了 加时间或 uid 次序位,定长编码
名次比预期大很多 名次算成 1 基与 0 基混用 统一 +1,接口层固定语义
"我的排名"为空 用户不在榜(未上榜返回 nil) 显式处理 nil,返回"未上榜"
榜单人数只增不减 过期没设,或截断条件写错 检查 TTL 与 ZREMRANGEBYRANK 参数
高峰期 ZINCRBY 变慢 单 key 热写,或大 key 频繁更新 分片写入 + 定时归并
重建后数据少了 重建用 ZADD 覆盖了实时增量 重建写临时 key 后 RENAME

11. 边界

场景 说明
榜单要作为发奖依据 必须落库 + 快照固化,Redis 只做展示
需要历史任意时刻的榜单 按周期存快照,ZSet 只保留当前周期
成员数上亿且要求精确名次 单实例扛不住,改为分片 + 近似名次
榜单更新频率高于读频率 缓存价值低,考虑直接查 DB 或物化视图

ZSet 的边界很清楚:它是一份"已经排好序的、只按一个维度排的"数据。凡是需要二次排序、条件过滤、历史回溯的需求,都该在它之前或之后另建一层。


12. 自测

  1. ZREVRANK 和 ZCOUNT 分别解决"我的名次"的哪种语义?同分时哪个更合适?
  2. 为什么 points * 1e13 + 时间戳 这种写法容易出错?上界怎么算?
  3. 日榜 key 忘了设 EXPIREAT,一个月后会发生什么?
  4. "单 key + member 前缀"的多周期方案,为什么做不了分页排名?
  5. 分片榜单归并 Top N 时,按分数段分片和按用户哈希分片,各自的代价是什么?
  6. 为什么榜单重建要写临时 key 再 RENAME,而不是直接清空重写?
S12-X10 · 榜单系统设计:ZSet 的能力边界
https://www.clxhxhhr.top/posts/5821/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。