☰
Redis ZSet命令全解:底层结构、写入语义与排行榜限流队列实战
2026/10/26 3:30:01 网站建设 项目流程

Redis 的五大基础类型里,ZSet(Sorted Set,有序集合)是那个平时用得少、真用起来又绕不开的角色。很多人对它的印象停留在"能排序的 Set",于是拿它当排行榜用,敲一句ZADD加一句ZREVRANGE就算完事。可真到生产环境里,延迟队列、滑动窗口限流、带权重的推荐榜单、去重计数,一样样压过来的时候,才会发现 ZSet 的命令族远比表面上复杂:光是写入语义就有 NX、XX、GT、LT、CH、INCR 六个开关,查询命令在 6.2 版本被彻底翻新了一遍,删除和集合运算各自又有一整套变体。这篇内容就是把这套命令从底层结构到实战组合完整捋一遍,从前缀参数的含义讲到多 key 运算在集群下的限制,适合已经会敲几条基础命令、想真正把 ZSet 用稳的人看,也适合刚接触 Redis、想少走弯路的同学按顺序读下来。

1. 先把 ZSet 的骨架看明白,命令才不会用错

1.1 member 与 score 的双维度设计

ZSet 的每一个元素由两部分组成:member是实际存储的成员值,score是一个双精度浮点数,用来决定排序位置。这个设计和普通 Set 的区别在于,Set 只关心"在不在",ZSet 还关心"排在哪"。和 List 的区别更明显:List 是靠插入顺序线性排列的,想按某个业务字段排序只能自己拿出来排;ZSet 在写入的那一刻就把顺序维护好了,读取时直接按 score 升序吐出来,相同 score 的元素再按 member 的字典序排列。

这个"自动维护顺序"的能力,代价是每次写入都要做一次定位插入。所以ZADD的时间复杂度是 O(logN),ZRANK也是 O(logN),而ZSCORE因为走的是哈希表,是 O(1)。理解这三个复杂度,后面判断哪些命令能上生产、哪些只能做运维手段,就有了基本依据。

注意:相同 score 时按 member 的二进制字典序排列,这个规则是确定的,不是随机的。做分页或者做去重的时候可以放心依赖它,但也意味着 member 的命名方式会直接影响到同分情况下的展示顺序。

1.2 底层编码在什么阈值上切换

ZSet 有两种内部编码。元素数量少、单个元素长度也小的时候,用紧凑的线性结构存(早期版本是 ziplist,7.0 之后换成了 listpack),这种结构省内存、遍历快,但插入是 O(N)。一旦超过阈值,就转换成skiplist + dict的组合结构:dict 负责 member 到 score 的 O(1) 查找,skiplist 负责按 score 有序的范围查询和排名计算。

阈值由两个配置控制:

配置项(7.0+)默认值含义
zset-max-listpack-entries128元素个数上限
zset-max-listpack-value64单个 member 字节长度上限

7.0 之前的配置名是zset-max-ziplist-entries和zset-max-ziplist-value,默认值一样,只是前缀变了。这两个条件任意一个被突破,整个 key 就会立刻转成跳表编码,而且转换是单向的——元素删到只剩一个也不会退回去。这一点在做内存评估时特别容易被忽略:一个曾经膨胀到几千元素的 ZSet,即使后来被清理到只剩十几个,它占的内存仍然是跳表结构的内存。

1.3 跳表为什么比平衡树更适合这里

跳表的本质是在有序链表上叠了好几层"快速通道",查找时从最高层往下跳,平均时间复杂度 O(logN),实现上比红黑树简单得多,而且天然支持范围查询——找到起点之后顺着最底层的链表往后走就行,不需要中序遍历那套回溯逻辑。Redis 的实现里每个节点还带一个backward指针,所以逆序范围查询(ZREVRANGE)同样高效,不用倒着建索引。

这个设计带来的一个实际影响是:范围查询ZRANGE key 0 99的复杂度是 O(logN + M),其中 M 是返回的元素个数。所以要点不在于起点在哪,而在于你一次取多少个。取 100 个和取 100 万个,差距是数量级的。

2. ZADD 的六个开关,写错一个结果就变味

2.1 NX 与 XX:控制"新增"和"更新"的边界

ZADD最基础的用法是ZADD key score member,元素不存在就新增,存在就更新分数。加NX之后语义变成"只在成员不存在时新增,已存在则什么都不做",加XX则相反——"只在成员已存在时更新分数,不存在则忽略"。这两个标志是互斥的,同时写上会直接报语法错误。

这个区分在实战里非常关键。举个例子,做每日签到排行榜,用户第一次签到才计入,重复签到不应该覆盖掉之前的时间戳分数,这时候就该用NX;反过来做积分同步任务,只更新那些已经建档的用户,绝不凭空造出新成员,那就该用XX。如果不加限定符,默认行为是"有则更新无则新增",很多数据不一致的问题就是这么埋下的。

# 只在成员不存在时写入 ZADD login:rank NX 1735689600 user:1001 # 只更新已存在的成员,不新增 ZADD user:score XX 880 user:1001

2.2 GT 与 LT:按分数方向决定是否覆盖

GT和LT是 6.2 版本补上的两个标志,语义是"只有新分数大于(或小于)当前分数时才更新"。它们解决的是一个很常见的并发覆盖问题:两个服务同时给同一个用户加积分,如果没有方向性约束,后写的会把先写的覆盖掉。

GT只允许分数往上走,LT只允许往下走。用在"历史最高分"场景里特别顺手——成绩榜只保留最好成绩,后来的低分直接被忽略,不需要先读出来比较再写回,一次ZADD ... GT就完成了,而且是原子的。

有两个细节必须记住:第一,GT、LT、NX三者两两互斥,不能同时出现;第二,GT和LT不阻止新增元素,成员不存在时它们依然会插入,只是插入后不做方向判断。如果你的意图是"只更新不新增",必须把XX和GT组合使用。

2.3 CH 与 INCR:返回值到底代表什么

默认情况下ZADD返回的是"新添加的元素个数",更新已有元素的分数不计入返回值。加上CH(changed 的缩写)之后,返回值变成"新增数 + 分数发生变化的更新数"。这个差别在幂等校验里很有用——批量同步数据时,如果返回 0,说明所有成员要么已存在且分数相同,要么被 NX/XX/GT/LT 挡掉了,一次网络往返就能判断出这批数据有没有实际变更。

INCR则是把ZADD变成类似ZINCRBY的累加操作,返回值也从"个数"变成"新的分数"。它有两个硬性限制:只能有一个 score-member 对,且不能和NX、XX、GT、LT同时使用。

# 返回值是新增数量 ZADD board 100 a 200 b # 返回 2 # 返回值是新增 + 变更数量 ZADD board CH 100 a 300 b # 返回 1(a 分数没变,b 变了) # 返回值是新分数 ZADD board INCR 50 a # 返回 150

2.4 批量写入的原子性和实测体会

一条ZADD可以带多组 score-member 对,整条命令是原子的,中间不会插入其他客户端的操作。这一点和"循环调用 N 次 ZADD"有本质区别:后者在高并发下可能被其他写操作打散,出现中间状态。

我在一次批量导入榜单数据的场景里做过对比:20 万条数据,逐条ZADD大概要二十多秒,按每组 500 条打包成 pipeline 之后降到一秒出头。这里要注意的是 pipeline 和单条多参数的取舍——单条多参数的原子性更强,pipeline 的吞吐更高但不保证这批命令之间没有别的客户端插入。如果业务对原子性没有要求,优先用 pipeline;如果这批数据必须"全进或全不进",就得考虑用 Lua 脚本包一层。

3. 查询命令的代际更替,老写法还该不该用

3.1 老命令族各自的适用边界

6.2 之前的查询命令是按维度拆开的:按排名查用ZRANGE/ZREVRANGE,按分数查用ZRANGEBYSCORE/ZREVRANGEBYSCORE,按字典序查用ZRANGEBYLEX/ZREVRANGEBYLEX。这六条命令功能上没毛病,至今仍然可用,只是从 6.2 起被标记为 deprecated,官方推荐统一走新版ZRANGE。

真正需要留意的是ZRANGEBYLEX的使用前提:它要求集合里所有元素的 score 完全相同,否则返回结果未定义。这个约束很多人不知道,拿一个正常排名的 ZSet 去调ZRANGEBYLEX,得到的结果看起来"能用",实际上是踩在未定义行为上。它的设计初衷是给"用 member 本身做排序键"的场景准备的,比如按手机号前缀查号段、按用户名首字母查列表。

3.2 Redis 6.2 之后的 ZRANGE 统一入口

新版ZRANGE把所有维度收进了一条命令:

ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] [WITHSCORES]

参数组合决定了行为:

参数组合等价的老命令
ZRANGE k 0 -1ZRANGE k 0 -1
ZRANGE k 0 -1 REVZREVRANGE k 0 -1
ZRANGE k min max BYSCOREZRANGEBYSCORE k min max
ZRANGE k max min BYSCORE REVZREVRANGEBYSCORE k max min
ZRANGE k [a [z BYLEXZRANGEBYLEX k [a [z
ZRANGE k + - BYLEX REVZREVRANGEBYLEX k + -

有一个容易翻车的点:使用BYSCORE或BYLEX时,start和stop的含义从"下标"变成了"分数区间"或"字典序区间",这时LIMIT才有意义。而在默认的下标模式下,LIMIT是不被接受的,写了会报语法错误。

3.3 区间写法的开闭符号,一个括号差之千里

按分数查的时候,区间端点默认是闭区间,ZRANGE k 10 20 BYSCORE会包含 10 和 20。想排除端点就在数字前面加左括号:(10、(20。无穷大用-inf和+inf表示。这一套在ZCOUNT、ZREMRANGEBYSCORE里是完全一致的。

字典序查询的符号体系又不太一样:-表示最小,+表示最大,[value表示包含该值,(value表示不包含。注意这里没有"裸写"的写法,ZRANGEBYLEX k a z是错的,必须写成ZRANGEBYLEX k [a [z。

# 分数在 100 到 200 之间,含端点 ZRANGE board 100 200 BYSCORE WITHSCORES # 分数大于 100 且小于等于 200 ZRANGE board (100 200 BYSCORE # 字典序在 a 到 z 之间,含 a 不含 z ZRANGE board [a (z BYLEX

3.4 分页时 offset 的真实代价

LIMIT offset count看起来像数据库的LIMIT,但代价模型完全不同。Redis 需要先定位到起始位置,然后跳过 offset 个元素,这个跳过是实打实的遍历。所以LIMIT 100000 10的开销远比LIMIT 0 10大,深分页在 ZSet 上同样是个坑。

排行榜场景里常见的做法是限制用户只能翻到第 N 页(比如前 100 名),或者干脆用游标式分页——把上一页最后一个元素的 score 和 member 记下来,下一页从(lastScore开始取,配合LIMIT 0 count使用,每次都从区间起点开始,性能稳定。

4. 删除、计数与集合运算,被低估的一组命令

4.1 按排名、分数、字典序批量清理

删除单个成员用ZREM,支持一次传多个 member。批量清理则有三条按维度划分的命令:ZREMRANGEBYRANK按排名区间删,ZREMRANGEBYSCORE按分数区间删,ZREMRANGEBYLEX按字典序区间删。

其中ZREMRANGEBYSCORE是过期清理的主力。延时队列的实现里,任务到期后需要从 ZSet 里摘掉,就可以用ZREMRANGEBYSCORE key -inf now一次性清掉所有已到期的。这条命令的复杂度是 O(logN + M),M 是被删元素的数量,所以清理量大的时候要分批做,别一次性删几十万条把主线程卡住。

# 删除分数在 0 到当前时间戳之间的所有已到期任务 ZREMRANGEBYSCORE delay:queue -inf 1735689600 # 只保留前 100 名 ZREMRANGEBYRANK board 100 -1

ZREMRANGEBYRANK board 100 -1这个写法在排行榜里用得极多,语义是"把第 100 名之后的全部干掉"。注意排名是从 0 开始的,所以100 -1保留的是前 100 个。

4.2 ZCOUNT 与 ZLEXCOUNT 的区间陷阱

ZCOUNT key min max统计分数落在区间内的元素个数,复杂度 O(logN),因为它只需要定位两个端点然后相减,不需要真的遍历。这个特性让它可以放心地用在实时统计上,比如"统计分数在 90 分以上的用户数",一次调用就出结果。

ZLEXCOUNT key min max则是按字典序统计,同样要求所有元素 score 相同。这两个命令的区间写法跟前面说的完全一致,闭区间裸写、开区间加括号、无穷用-inf/+inf或-/+。踩坑最多的就是把ZCOUNT写成ZCOUNT key 10,20这种带逗号的写法——Redis 不认识逗号,会直接报错。

4.3 ZUNIONSTORE 与 ZINTERSTORE 的权重聚合

多个 ZSet 做合并,用ZUNIONSTORE(并集)、ZINTERSTORE(交集)、ZDIFFSTORE(差集)。完整语法是:

ZUNIONSTORE dest 2 zset1 zset2 WEIGHTS 1 2 AGGREGATE SUM

WEIGHTS给每个输入集合指定乘数,参与运算时先把 score 乘上权重再聚合;AGGREGATE决定相同 member 的分数怎么合并,可选SUM(默认)、MIN、MAX。

这个能力在多维榜单里特别好用。假设有"播放量榜"和"互动量榜",想合成一个综合热度榜,给播放量权重 1、互动量权重 3,用SUM聚合,一条命令就出来了。如果没有WEIGHTS,就得自己把两个集合拉出来在应用层算,数据量大时网络传输和内存开销都很可观。

参数可选值默认作用
WEIGHTS每个输入集合一个浮点数全为 1聚合前给 score 乘系数
AGGREGATESUM/MIN/MAXSUM同一 member 的分数合并方式

4.4 6.2 之后的只读版本与集群限制

ZUNIONSTORE这一族必须指定一个目标 key 来存结果,这在只想"算一下看看"的场景里就很别扭——为了看一眼结果,还得造一个临时 key 再删掉。6.2 补上了只读版本ZUNION、ZINTER、ZDIFF,直接把结果返回给客户端,不落盘。7.0 又加了ZINTERCARD,只返回交集元素的个数,比先算交集再ZCARD省一次写入。

在集群模式下有个硬性限制:所有参与运算的 key 必须落在同一个 hash slot 里,否则直接报 CROSSSLOT 错误。常用的解法是给相关 key 加相同的 hash tag,比如{rank}:daily、{rank}:weekly,花括号里的内容会参与 slot 计算,保证它们分到同一个节点。

5. 把 ZSet 当队列用,弹出命令的门道

5.1 为什么延时队列偏爱 ZSet

用 List 做队列只能先进先出,做不了"延迟到某个时间点再消费"。ZSet 的 score 天然可以承载时间戳,ZADD delay:queue <执行时间戳> <任务ID>写进去,消费者轮询时用ZRANGEBYSCORE key 0 <当前时间戳> LIMIT 0 1捞一条到期的,处理完再ZREM掉。整整一套延时队列的骨架就搭起来了,不需要引入额外的中间件。

这个方案的核心优势是"到期判断"完全由 Redis 完成,应用侧只需要定时轮询。缺点是轮询本身有延迟,精度取决于轮询间隔;而且"捞取"和"删除"是两步操作,中间如果消费者挂了,任务就会丢——所以生产上一般会配合 Lua 脚本把"取+删"做成原子操作,或者再加一个"处理中"的备份集合做补偿。

5.2 ZPOPMIN / ZPOPMAX 与阻塞版本

如果只是需要一个"按分数优先级出队"的结构,用ZPOPMIN就行,它原子地弹出分数最小的元素并返回。带count参数可以一次弹多个。ZPOPMAX同理,只是方向相反。

轮询版本的问题是空转。6.2 加了阻塞版BZPOPMIN/BZPOPMAX,没有元素时会阻塞等待,一旦有数据写入立刻返回,超时时间到了返回 nil。7.0 又补了ZMPOP和BZMPOP,支持同时监听多个 key,返回格式也从三元组变成了[key, [[member, score], ...]]这种结构化数组。

# 从多个队列中取优先级最高的一个,最多等 5 秒 BZMPOP 5 2 queue:high queue:low MIN COUNT 1 # 非阻塞版本,一次弹 10 个 ZMPOP 2 queue:high queue:low MIN COUNT 10

5.3 精度、并发与消费确认的取舍

用 ZSet 做队列,绕不开三个现实问题。第一是时间精度,score 是浮点数,时间戳建议用毫秒而不是秒,否则同一秒内的任务顺序会退化成按 member 字典序排。第二是多消费者并发,ZPOPMIN是原子的,多个消费者同时弹不会拿到同一条,这一点比"先 ZRANGE 再 ZREM"安全得多。第三是消费确认,弹出即删除意味着没有 ACK 机制,消费者拿到任务后崩溃,任务就永久丢失了。

我的处理方式是加一个"处理中"的 ZSet 做中转:弹出的任务立刻写进processing集合,处理成功后从processing里删掉,另起一个巡检任务扫描processing中超时未完成的记录重新投回主队列。多了一次写入,但换来了至少一次投递的语义。

6. 排行榜、限流与去重,三个能直接抄的组合

6.1 多周期榜单的 key 拆分与过期策略

排行榜最常见的需求是按天、按周、按月各出一份。做法是按周期拆 key:board:20240115、board:week:2024W03、board:month:202401。写入时用 pipeline 一次性往几个 key 里各写一遍,读取时按需取对应的那个。

ZADD board:20240115 1200 user:1001 ZADD board:week:2024W03 1200 user:1001 ZADD board:month:202401 1200 user:1001

过期策略上,日榜可以设 7 天 TTL,周榜设 30 天,月榜设 90 天,用EXPIRE在第一次写入时设置。这里容易犯的错误是每次写入都重置 TTL,导致榜单永远不过期,历史数据越堆越多。正确做法是用ZADD之后的返回值判断是否为首次创建,或者干脆用EXPIRE的NX选项,只在没有过期时间时才设置。

6.2 滑动窗口限流的最小实现

固定窗口限流有边界突刺问题,滑动窗口能解决,而滑动窗口在 Redis 上最简洁的实现就是 ZSet:member 存请求的唯一标识(比如 UUID 或者请求 ID),score 存请求时间戳。每次请求进来先删掉窗口外的记录,再统计窗口内的数量,没超限就写入。

# 删除 60 秒之前的记录 ZREMRANGEBYSCORE limit:user:1001 -inf 1735689540 # 统计窗口内请求数 ZCARD limit:user:1001 # 未超限则写入当前请求 ZADD limit:user:1001 1735689600 req:8f3a2c

这套逻辑要包在 Lua 脚本里才能保证原子性,否则"统计"和"写入"之间会插入别的请求,计数就不准了。另外一定要给这个 key 设过期时间,值为窗口长度加一点余量,避免冷用户的 key 长期占着内存。

6.3 同分排序:把时间戳编进 score

纯分数排序有个绕不过的问题:同分怎么排?默认按 member 字典序,这个顺序对用户来说毫无意义。常见的解法是把时间戳编进 score 里——整数部分放业务分数,小数部分放时间戳的倒数或者直接用"大数减时间戳"的方式,让先达成的排在前面。

比如业务分数 0 到 100,时间戳是毫秒级的 13 位数字,可以用score = 业务分数 * 10^13 + (10^13 - 时间戳)。这样既保留了主排序,又让同分时早到者靠前。代价是 score 变成了 15 位以上的数,而双精度浮点数只有 53 位有效二进制位,大约对应 15 到 16 位十进制精度,再往上就会丢精度。所以这个技巧有明确的天花板,业务分数加时间戳的总位数必须控制在 15 位以内。

注意:score 是双精度浮点,不是定点小数。拿它存金额这类精确值一定会出问题,0.1 + 0.2 在浮点里不等于 0.3,聚合之后误差还会累积。要存金额就先用整数分做单位,存成整数再当 score 用。

7. 生产环境里 ZSet 最容易翻车的几个地方

7.1 大 key 与 ZRANGE 0 -1

ZRANGE key 0 -1是个危险动作,它会一次性把整个集合拉出来。元素上万的时候,这一次命令的返回体会撑爆客户端缓冲区,同时阻塞 Redis 主线程若干毫秒。线上出过的事故里,不少就是某个调试脚本或者监控采集顺手写了ZRANGE 0 -1,把整个实例拖慢。

正确的替代方式有三个:要做全量遍历就用ZSCAN,它基于游标分批返回,单次开销可控;要统计总数用ZCARD,O(1) 就够;确实需要全量数据就用ZRANGE key 0 999 LIMIT式的分批拉取,每批控制在几百到一千。

ZSCAN有一个特性必须知道:它是增量遍历,遍历期间如果有元素增删,可能会重复返回某些元素,也可能漏掉一些。所以消费端必须做幂等处理,不能假设"扫一遍就能拿到准确全量"。

7.2 集群下多 key 运算的槽位约束

前面提过,ZUNIONSTORE、ZINTERSTORE、ZMPOP这类多 key 命令在集群模式下要求所有 key 在同一 slot。很多人在单机环境写完测试通过,上集群就报 CROSSSLOT,排查半天。规避方式就是统一加 hash tag:

# 这三个 key 会落在同一个 slot ZUNIONSTORE {rank}:total 2 {rank}:play {rank}:like WEIGHTS 1 3 AGGREGATE SUM

hash tag 的代价是这些 key 会被打到同一个节点上,如果数据量大,会形成热点。所以 tag 的粒度要设计好,别所有榜单都塞进一个 tag 里。

7.3 内存估算,别等 OOM 了才算账

一个跳表编码的 ZSet,每个元素的开销大致包括:跳表节点的多层指针(平均 1.33 层,每层两个指针)、backward 指针、member 的 SDS 字符串、score 的 double(8 字节),再加上 dict 里的一份 member 和指针。粗算下来,一个 20 字节左右的 member,实际占用在 80 到 120 字节之间,是原始数据的好几倍。

做容量规划时的经验值是:按每个元素 100 字节估算,然后留 30% 余量。如果发现某个 ZSet 元素数远超预期,先看看是不是排行榜的 key 没有设置过期,或者清理逻辑没生效。

7.4 热 key 带来的 CPU 抖动

排行榜这类场景天然会产生热 key——全站用户都在读同一个榜单。读操作本身是 O(logN + M),单次不慢,但 QPS 上到几万的时候,单个节点的 CPU 会被这一条命令吃满。缓解手段有两个方向:一是加本地缓存,榜单数据本身可以容忍几十秒的延迟,读缓存比读 Redis 划算得多;二是按用户 ID 做分片,把一个大榜单拆成若干子榜单,读取时再合并,但这会引入合并开销,只在榜单极大时才值得。

写侧的热 key 更麻烦,因为ZADD会修改跳表结构。如果某个活动开场瞬间有大量用户同时写入同一个榜单,可以用消息队列削峰,把写操作排队后批量提交,避免瞬时冲击。

7.5 分数更新导致的排名抖动

用户分数频繁变动时,排名会不断跳动,前端展示会出现"名次突然从 5 掉到 20"的观感问题。这不是 Redis 的锅,是业务逻辑本身决定的。缓解方式是给榜单加一个刷新周期——后台定时算出快照存到另一个 key,前端只读快照,实时榜单作为内部参考。这样既保证了展示稳定,也避免了高频读打到同一个 key 上。

另外要注意ZADD更新分数后,该元素在跳表里的位置会重新调整,这是 O(logN) 的操作。如果一个用户在一秒内被更新几千次分数,累计开销不容小觑。能合并的更新就合并,比如用ZINCRBY累加而不是反复ZADD覆盖,能省掉不少结构重排。


我个人在实际操作中的体会是,ZSet 的命令数量看着多,但真正高频使用的就那么七八条:ZADD、ZRANGE、ZREVRANGE、ZRANK、ZSCORE、ZINCRBY、ZREMRANGEBYSCORE、ZCARD。把这八条的前缀参数和区间写法记牢,剩下那些变体基本都是它们的组合衍生,用到的时候翻一眼文档就能上手。真正需要提前想清楚的,反而是数据模型层面的事——score 用什么单位、同分怎么排、key 怎么拆周期、过期怎么设。这几件事定下来之后,命令本身反而是最简单的部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询