一个"积分排行榜"看起来是 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. 自测
ZREVRANK和ZCOUNT分别解决"我的名次"的哪种语义?同分时哪个更合适?- 为什么
points * 1e13 + 时间戳这种写法容易出错?上界怎么算? - 日榜 key 忘了设
EXPIREAT,一个月后会发生什么? - "单 key + member 前缀"的多周期方案,为什么做不了分页排名?
- 分片榜单归并 Top N 时,按分数段分片和按用户哈希分片,各自的代价是什么?
- 为什么榜单重建要写临时 key 再
RENAME,而不是直接清空重写?