Redis 的五大基础类型里ZSetSorted 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 有两种内部编码。元素数量少、单个元素长度也小的时候用紧凑的线性结构存早期版本是 ziplist7.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:10012.2 GT 与 LT按分数方向决定是否覆盖GT和LT是 6.2 版本补上的两个标志语义是只有新分数大于或小于当前分数时才更新。它们解决的是一个很常见的并发覆盖问题两个服务同时给同一个用户加积分如果没有方向性约束后写的会把先写的覆盖掉。GT只允许分数往上走LT只允许往下走。用在历史最高分场景里特别顺手——成绩榜只保留最好成绩后来的低分直接被忽略不需要先读出来比较再写回一次ZADD ... GT就完成了而且是原子的。有两个细节必须记住第一GT、LT、NX三者两两互斥不能同时出现第二GT和LT不阻止新增元素成员不存在时它们依然会插入只是插入后不做方向判断。如果你的意图是只更新不新增必须把XX和GT组合使用。2.3 CH 与 INCR返回值到底代表什么默认情况下ZADD返回的是新添加的元素个数更新已有元素的分数不计入返回值。加上CHchanged 的缩写之后返回值变成新增数 分数发生变化的更新数。这个差别在幂等校验里很有用——批量同步数据时如果返回 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 # 返回 1a 分数没变b 变了 # 返回值是新分数 ZADD board INCR 50 a # 返回 1502.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 -1ZRANGE k 0 -1 REVZREVRANGE k 0 -1ZRANGE k min max BYSCOREZRANGEBYSCORE k min maxZRANGE k max min BYSCORE REVZREVRANGEBYSCORE k max minZRANGE k [a [z BYLEXZRANGEBYLEX k [a [zZRANGE 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 BYLEX3.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 -1ZREMRANGEBYRANK 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 SUMWEIGHTS给每个输入集合指定乘数参与运算时先把 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 105.3 精度、并发与消费确认的取舍用 ZSet 做队列绕不开三个现实问题。第一是时间精度score 是浮点数时间戳建议用毫秒而不是秒否则同一秒内的任务顺序会退化成按 member 字典序排。第二是多消费者并发ZPOPMIN是原子的多个消费者同时弹不会拿到同一条这一点比先 ZRANGE 再 ZREM安全得多。第三是消费确认弹出即删除意味着没有 ACK 机制消费者拿到任务后崩溃任务就永久丢失了。我的处理方式是加一个处理中的 ZSet 做中转弹出的任务立刻写进processing集合处理成功后从processing里删掉另起一个巡检任务扫描processing中超时未完成的记录重新投回主队列。多了一次写入但换来了至少一次投递的语义。6. 排行榜、限流与去重三个能直接抄的组合6.1 多周期榜单的 key 拆分与过期策略排行榜最常见的需求是按天、按周、按月各出一份。做法是按周期拆 keyboard: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 上最简洁的实现就是 ZSetmember 存请求的唯一标识比如 UUID 或者请求 IDscore 存请求时间戳。每次请求进来先删掉窗口外的记录再统计窗口内的数量没超限就写入。# 删除 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 -1ZRANGE key 0 -1是个危险动作它会一次性把整个集合拉出来。元素上万的时候这一次命令的返回体会撑爆客户端缓冲区同时阻塞 Redis 主线程若干毫秒。线上出过的事故里不少就是某个调试脚本或者监控采集顺手写了ZRANGE 0 -1把整个实例拖慢。正确的替代方式有三个要做全量遍历就用ZSCAN它基于游标分批返回单次开销可控要统计总数用ZCARDO(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 SUMhash tag 的代价是这些 key 会被打到同一个节点上如果数据量大会形成热点。所以 tag 的粒度要设计好别所有榜单都塞进一个 tag 里。7.3 内存估算别等 OOM 了才算账一个跳表编码的 ZSet每个元素的开销大致包括跳表节点的多层指针平均 1.33 层每层两个指针、backward 指针、member 的 SDS 字符串、score 的 double8 字节再加上 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 怎么拆周期、过期怎么设。这几件事定下来之后命令本身反而是最简单的部分。
阅读完成 · 觉得有帮助?