ZSet 排序分页与排名细节
排行榜是 ZSet 的主场,但 score 的 double 本质、并列分数的排序规则、分数游标分页的漏行问题,是三个高频翻车点。本文以 Redis 7 为基准,从 score 的表示讲到排名与分页的正确姿势。
一、score 是 double:精度与并列规则
ZSet 的 score 在底层是 64 位 double,能精确表示的整数上限是 2^53。把 64 位雪花 ID 直接当 score,超过 2^53 的部分会丢精度,出现"分数莫名其妙变了"的诡异现象;确实要用大整数排序时,拆成多个字段或让 member 承担排序职责。
同一分数的成员按 member 的字节序(二进制比较)升序排列,这个规则是确定性的,可以被利用:分数相同想让后注册的排前面,可以把 member 设计成"取反的时间戳加 ID";纯字典序范围查询则直接用 ZRANGEBYLEX(要求所有成员同分)。利用同分按 member 排序的特性,可以把全部成员设成同一分数,用 ZRANGEBYLEX 做前缀搜索,例如自动补全:ZRANGEBYLEX words [ab (ac 返回所有以 ab 开头的词条,[ 是闭区间、( 是开区间。这个技巧简单可靠,词条量在十万级以内时表现很好。score 支持 -inf 和 +inf 字面量,配合 ZCOUNT 做区间统计很方便。底层上,ZSCORE 这类点查走 dict 索引,ZRANK/ZRANGE 走 skiplist,这是"点查 O(1)、排名 O(log N)"的来源;listpack 编码的小 ZSet 统一线性扫描,量小反而更快,不要迷信复杂度结论。
二、排名命令与 ZADD 选项
ZRANK 返回成员按分数升序的 0 基名次,ZREVRANK 返回降序名次,两者都是 O(log N);第一名是 ZREVRANK 返回 0,业务展示名次要加 1。ZSCORE 取分数是 O(1)(底层有 dict 索引),ZMSCORE(Redis 6.2+)批量取分,刷榜单详情页时用后者一次拿整页分数,配合 pipeline 还能把 ZSCORE 与 ZREVRANK 打包,详情页一次往返拿全。
ZADD 的选项组合是精确控制更新行为的关键:
# 仅当新分数更高时才更新,CH 让返回值变成"新增 + 修改"的数量
127.0.0.1:6379> ZADD board:day CH GT 95 user:1001
(integer) 1
NX 表示不更新已存在成员,XX 表示只更新已存在成员,GT/LT(Redis 6.2+)表示新分数更高/更低才更新;GT 与 NX 互斥,同传直接报错。防作弊场景用 GT 可以保证旧数据迟到时不会把用户的最高分刷低。ZINCRBY 等价于 ZADD INCR,返回自增后的新分数。ZPOPMIN/ZPOPMAX 原子弹出分数最小或最大的成员,"抢第一名"发奖、任务派发这类"取走即结束"的场景用它,比先 ZRANGE 再 ZREM 少处理一次并发竞态。
三、分页:下标游标与分数游标
两种分页姿势对应两种产品形态。
下标分页适合经典榜单翻页。ZRANGE key 0 9 REV WITHSCORES 取第 1 页,第 2 页把区间换成 10 19(旧写法 ZREVRANGE 仍可用)。每页成本是定位加 M 个元素的遍历,与页深基本无关,成本可控。但要清楚翻页期间榜单在被并发加分,下标会整体后移,某个成员可能在两页都出现或被跳过——这是按名次翻页的固有抖动,榜单类产品一般容忍;要求严格时先用 ZUNIONSTORE board:snap 1 board:day 生成快照,对快照 key 翻页。
分数游标分页适合无限下滑流。以"上一页最后一条的分数"为起点继续向后取:
# 取分数小于 95 的前 10 名,( 表示开区间,排除 95 本身
ZRANGE board:day +inf (95 BYSCORE REV LIMIT 0 10 WITHSCORES
这里的坑在并列分数:分数相同的成员按 member 字节序排列,而游标只记了分数,上一页里与游标同分的成员会被跳过或重复。解决办法有两个:把 score 唯一化(如用 主分数 * 1000 + (999 - 同分序号) 拼接),或者游标同时带上 member,客户端对边界分数做二次过滤。任何只按分数做游标的方案都要先确认业务上分数不会并列。
一个可落地的唯一化方案:score 存 业务分 * 1000 + (999 - min(999, 同分递增序号)),member 另存业务 ID,展示时对 score 整除 1000 还原业务分;也可以把"业务分 + 反转的时间戳低位"直接拼进 score,思路相同。无论哪种,都要保证分数生成全局唯一,并且 ZADD 写入侧与展示侧共用同一套编码函数,两边口径不一致同样会翻页错乱。
四、榜单维护与周期滚动
榜单要做的是"有界":用 ZREMRANGEBYRANK 定期裁剪,例如天榜只保留前 1 万名——ZSet 按分数升序排列,删掉排名 0 到 -10001 之外的区间即保留头部:
ZREMRANGEBYRANK board:day 0 -10001
周期榜滚动用 ZUNIONSTORE 聚合,比如昨日榜与今日榜按权重合并出周榜:ZUNIONSTORE board:week 2 board:day1 board:day2 WEIGHTS 1 1,支持 WEIGHTS 加权与 AGGREGATE 聚合方式(SUM/MAX/MIN)。聚合是大集合上的 O(N) 操作,放到从库或低峰期执行,结果 key 设好 TTL。ZCOUNT 统计分数区间内的人数,可以低成本回答"超过 90 分的有多少人"这类门槛问题。
榜单整期重置用 RENAME 原子换名:新榜先写进 board:day:next,到期一条 RENAME board:day:next board:day 完成切换,不会出现清空后几分钟的空榜窗口。日/周/月多张榜各自独立 key 并设不同 TTL,ZUNIONSTORE 只聚合必要的两三张榜,避免把全量榜单卷进一次大聚合。旧榜单在重置前可以先 RENAME 成 board:day:prev 留档,需要回溯"上期榜单"的业务用得着,留档 key 同样记得设 TTL。
常见坑 / 最佳实践
- score 用 double,超过 2^53 的整数会丢精度。 不要把完整雪花 ID、纳秒时间戳直接当 score,排序依据要提前设计。
- 分数游标分页必须处理并列分数。 用 score 唯一化或游标附带 member 二次过滤,否则翻页漏行、重行在数据量大后必然出现。
- ZRANK/ZREVRANK 返回 0 基名次。 展示层统一加 1,并在团队内约定用 ZREVRANK 对接"第几名"语义,避免升降序搞反。
- 榜单必须有长度上限。 定期 ZREMRANGEBYRANK 裁剪,避免 ZSet 无限增长成大 key;裁剪频率与榜单更新频率对齐。
- GT/LT 与 NX 不能同时传给 ZADD。 组合前确认版本(6.2+ 支持 GT/LT)与选项兼容性,客户端参数拼错会直接报错。
小结
- score 是 64 位 double,精确整数上限 2^53;同分成员按 member 字节序确定性排列。
- ZREVRANK 是 O(log N) 的排名查询,ZADD 的 NX/XX/GT/LT/CH/INCR 选项可以精确控制更新语义。
- 榜单翻页用下标分页,无限下滑用分数游标,并列分数必须特殊处理。
- 榜单要有界,ZREMRANGEBYRANK 裁剪、ZUNIONSTORE 滚动周期榜,大聚合放低峰执行。
- 点赞
- 收藏
- 关注作者
评论(0)