搞 Redis 搞到第五篇,终于轮到压轴的 Zset 了。如果你之前已经把 String、List、Hash、Set 都摸过一遍,那 Zset 算是这五兄弟里最"聪明"的一个——它不是简单地存一堆值,而是能让这些值自动排好序,还能快速按名次或分数范围取数据。排行榜、热点榜单、延时队列、滑动窗口限流,这些经典需求在 Redis 里基本都是靠 Zset 一把梭搞定。
这篇我打算换个方式,不按官方文档的条目硬抄,而是从"Zset 能解决什么痛点"出发,把原理、命令、场景、坑一次讲透。无论你是刚学 Redis 的新手,还是工作中正准备用 Zset 换掉一段手写排序逻辑的老手,这篇文章应该都能给你点实在的参考。
1. 认识 Zset:它到底解决了什么问题
1.1 一句话理解 Zset
Zset 的全称是 Sorted Set,中文翻译通常叫"有序集合"。你可以把它理解成"带了分数的 Set"——每个成员(member)都是唯一的,这一点跟 Set 完全一样;但每个成员还必须关联一个分数(score),Redis 会根据这个 score 从小到大地自动维护顺序。
注意:这里的 score 不一定是整数,可以是浮点数。成员的唯一性由 member 本身决定,跟 score 无关。如果两次 ZADD 写入同一个 member,后写入的 score 会覆盖前面的。
你可以在脑子里建立一个画面:一个班级里每个学生都有一个成绩,Zset 就是这个"按成绩升序排好队"的花名册。你随时可以问"张三排第几名""前五名是谁""60 到 80 分之间有哪些人",它都能非常快地回答你。
1.2 为什么非它不可
在 Redis 五大数据类型里,List 和 Set 各有各的短板,而 Zset 恰好补上了"排序"这个空缺。
先看 List。List 本身是有序的,但这个顺序完全靠你维护:在左边 push 的数据必须读的时候按同样顺序取,你想让它"按某个字段排序"就麻烦了,得先把数据全取出来,再到程序里排一遍。当数据量上万,这种"取出再排"的方式在性能上直接被打回原形。
再看 Set。Set 适合做去重、交集并集运算,但它最大的问题是没有顺序。你要实现"积分排行榜",把玩家 ID 放进去之后,根本不知道谁分数高谁分数低,还得另外维护一张分数表,两个结构之间还要手动同步,太容易出错。
Zset 的设计初衷就是把"唯一性约束"和"按分数排序"这两件事在底层数据结构里一次性解决。你只管往里写数据,Redis 帮你把排序、排名、区间查询都处理好了,读取的时候直接拿结果,不用在业务代码里再做任何排序动作。
1.3 三个核心业务的直观对比
为了让你更直观地感受这套组合能力,我把五大数据类型里最容易混淆的 Set、List、Zset 放在一起对比:
| 能力维度 | Set | List | Zset |
|---|---|---|---|
| 成员是否唯一 | 是 | 否 | 是 |
| 是否保持插入顺序 | 否 | 是 | 否(按 score 排序) |
| 是否支持自动排序 | 否 | 否 | 是 |
| 按名次取数据 | 不能 | 可模拟但代价高 | 原生支持 |
| 按分数范围取数据 | 不能 | 不能 | 原生支持 |
| 典型场景 | 标签、去重、好友关系 | 消息队列、最新列表 | 排行榜、延时队列、限流 |
这张表背后的含义是:如果你的需求里同时出现了"去重"和"排序"两个词,那基本可以直接锁定 Zset,不用再纠结其他结构。
2. 核心命令速查与操作要点
2.1 写入与删除类命令
ZADD
向 Zset 里添加元素,每个元素必须带上自己的 score:
ZADD leaderboard 100 "player_1" ZADD leaderboard 200 "player_2" 150 "player_3"这里有个细节:一条 ZADD 可以一次添加多个"score member"键值对,批量操作的 RTT(往返时延)比循环单条添加低得多,线上写代码时尽量用这种批量写法。
ZINCRBY
给某个成员的分数做增量操作:
ZINCRBY leaderboard 50 "player_1"这个命令有多重要?实现排行榜时,用户每赢一局就调一次 ZINCRBY,Redis 内部会原子性地完成分数增加并重新排序,不需要加锁,不用担心并发覆盖。工作中实现积分累计、热度值变化,强烈建议用 ZINCRBY 而不是"先 ZSCORE 查出来再 ZADD 写回去",后一种两步操作在高并发下会丢数据。
ZREM
按 member 删除:
ZREM leaderboard "player_3"ZREMRANGEBYRANK / ZREMRANGEBYSCORE
按排名区间或分数区间删除元素。想清空排名后 100 的玩家:
ZREMRANGEBYRANK leaderboard 0 -101这里 -101 是倒数第 101 个位置,配合 ZRANGE 和 ZCARD 可以灵活实现"只保留前 100 名"的裁剪效果。
2.2 查询类命令
ZRANGE / ZREVRANGE
按排名范围取成员。ZRANGE 从低分到高分取,ZREVRANGE 从高分到低分取:
# 取排名前 3(分数最高) ZREVRANGE leaderboard 0 2 WITHSCORES # 取全部成员,按分数升序 ZRANGE leaderboard 0 -1 WITHSCORESWITHSCORES 参数会把分数一起返回,方便后续直接渲染成榜单项,非常常用。
ZRANGEBYSCORE
按分数区间取成员,这个命令是"延时队列"和"滑动窗口"场景的核心:
# 取 50 到 100 分之间的成员 ZRANGEBYSCORE leaderboard 50 100 WITHSCORES # 取 0 分到正无穷,也就是当前分数大于等于 0 的所有成员 ZRANGEBYSCORE leaderboard 0 +inf # 取负无穷到当前时间戳,用来拉取到期任务 ZRANGEBYSCORE delay_queue -inf 1700000000分数区间的边界默认是闭区间,如果想去掉某个端点,用左括号(,比如ZRANGEBYSCORE leaderboard (50 100表示不包括 50 分本身。
ZRANK / ZREVRANK
查成员的排名。ZRANK 返回升序排名,ZREVRANK 返回降序排名,名次从 0 开始:
ZRANK leaderboard "player_2" ZREVRANK leaderboard "player_2"ZSCORE
直接查某个成员的分数:
ZSCORE leaderboard "player_2"ZCARD / ZCOUNT
ZCARD 返回成员总数,ZCOUNT 返回分数区间内的成员数量:
ZCARD leaderboard ZCOUNT leaderboard 60 100这两个命令在统计榜单总人数、统计某个分数段成绩分布时特别省事。
2.3 集合运算类命令:ZUNIONSTORE / ZINTERSTORE
Zset 也支持类似 Set 的并集和交集运算,而且计算结果会存成一个新的 Zset:
# 把两个排行榜合并,权重分别是 1 和 0.5 ZUNIONSTORE total_rank 2 week_rank month_rank WEIGHTS 1 0.5 AGGREGATE SUM # 取两个集合共同存在的成员,用于筛选同时满足多个条件的用户 ZINTERSTORE common_user 2 group_a group_b权重参数 WEIGHTS 的含义是:取并集/交集时,对某个集合里所有成员的 score 先乘以权重再合并。AGGREGATE 默认是 SUM(分数相加),还可以指定 MIN 或者 MAX——这就非常适合做"多维度取最小/最大值"这类统计需求,比如多个规则打分里取最高分。
友情提醒:ZUNIONSTORE 和 ZINTERSTORE 的结果集大小取决于集合中元素个数,如果集合很大(几十万以上),建议在业务低峰期执行,避免阻塞 Redis 单线程处理其他请求。
2.4 命令时间复杂度速查
上面这些命令的时间复杂度差异非常大,我把高频命令整理成一张表,写代码前先看一眼:
| 命令 | 时间复杂度 | 说明 |
|---|---|---|
| ZADD | O(log N) | N 是集合大小,加一个元素要二分查找插入位置 |
| ZSCORE | O(1) | 直接查哈希表 |
| ZRANK / ZREVRANK | O(log N) | 跳表层序遍历过程中统计排名 |
| ZRANGE / ZREVRANGE | O(log N + M) | M 是返回的元素个数,分页取少量很划算 |
| ZRANGEBYSCORE | O(log N + M) | 类似 ZRANGE |
| ZCOUNT | O(log N) | 直接拿区间的起止位置计算 |
| ZINCRBY | O(log N) | 修改 score 后要移动元素位置 |
| ZREM | O(log N) | 删除后调整跳表 |
| ZUNIONSTORE / ZINTERSTORE | O(N) + O(M * log N) | 涉及多集合遍历,量大会拖累性能 |
核心判断标准:只要涉及按分数区间或排名区间操作,Zset 都能在 O(log N) 级别搞定,这也是它吊打一堆"取出来再排序"方案的根本原因。
3. 底层原理:skiplist 和 dict 的组合拳
3.1 为什么不能简单用一个有序数组
Zset 要求同时支持两种高效操作:一是"按分数快速定位区间",二是"按 member 快速查分数"。如果只用有序数组,按分数二分查找是没问题,但插入和删除元素时要移动大量数据,O(N) 的代价在数据量稍大的场景下根本扛不住。
如果只用哈希表,查询分数是 O(1) 没问题,但"按分数排序输出"就彻底没戏了,哈希表的顺序是随机的,没法维护。官方最终给出的答案是:Zset 底层同时维护了两个结构——一个哈希表(dict)存 member 到 score 的映射,一个跳表(skiplist)存按 score 排序的有序结构。
3.2 跳表是怎么工作的
跳表本质上是一个"多层级的有序链表"。
普通链表的劣势在于查找必须从头到尾逐个遍历。跳表的做法是在原始有序链表之上,抽取一部分节点作为"索引层",索引层再往上抽一层,形成多层结构。查找时从最高层开始,每次尽量往右跳,跳过头了就往下层走,这样能快速缩小范围。
我举个生活化的例子:想象一本新华字典,如果你只有一张按拼音排列的词条表,查"张"这个字得从第一页翻到大概两三百页,很慢。但现在你手里有几张"页码索引卡",第一张卡只记录"每一百页的第一个字是什么",第二张卡只记录"每五十页的第一个字",你就能先在索引卡上快速定位到"张"大致在哪个区间,再下到详细页确认。跳表查找就是这种"先跳大步,再跳小步,最后落到目标"的思路。
具体到 Redis 的实现里,跳表节点会保存 member 和 score。当两个节点的 score 相同时,再按 member 的字典序排序,保证排序的稳定性。每一步查找的时间复杂度控制在 O(log N) 左右,插入和删除也只需要局部调整指针,代价同样是 O(log N)。
3.3 哈希表在这套组合里的角色
跳表负责"按分数排序",哈希表负责"按 member 精确找"。当你执行 ZSCORE 取某个成员的分数时,Redis 不会去跳表里线性搜,而是直接走哈希表 O(1) 出结果。当你要往跳表里插入一个 member 时,也会先经过哈希表确认这个 member 是否已存在,已存在的话就更新分数,不存在就插入。
这两套结构在代码里是"同生共死"的关系:插入时两边都写,删除时两边都删,修改分数时哈希表更新映射,跳表删除旧节点并重新插入新分数对应的位置。Redis 通过严格的事务性操作把两个结构绑在一起,保证任何一个时刻两个结构反映的数据状态完全一致。
3.4 内存优化:从 ziplist 到 skiplist 的转换
你可能会问:既然跳表和哈希表这么强,为什么不所有情况下都用?答案是内存。跳表节点有多个 forward 指针,哈希表也有额外的指针和元数据,数据量小的时候,这些开销纯属浪费。
Redis 给了两个可控参数:
zset-max-ziplist-entries:默认 128,Zset 元素数量超过这个值,从 ziplist 转为 skiplist。zset-max-ziplist-value:默认 64,如果某个 member 或者 score 的字符串长度超过这个值,也会触发转换。
ziplist 是一种紧凑的连续内存结构,新增数据时可能触发内存重新分配和复制,数据量小的时候效率尚可、内存占用极低,但到大几千上万之后性能就会明显下降。所以 Redis 在小数据量时优先用 ziplist 省内存,达到阈值后自动切到 skiplist 保证性能。这个转换是单向的,一旦从 ziplist 变成 skiplist,不会自动转回来。
如果你自己搭 Redis,数据量比较小的情况下可以把这两个参数改大一点,进一步省内存。但如果预估数据量会快速增长,建议保持默认值,省得后面触发转换时那一下卡顿影响到线上请求。
4. 记一次排行榜功能的上线全过程
4.1 需求背景与方案选型
我之前接了一个社区类 App 的需求:做一个"月度互动之星"排行榜,统计每个用户的获赞数、评论数、转发数,按总得分降序排列,并且要展示当前用户自己的排名。
这个需求第一反应是查 MySQL 的ORDER BY,但用户总量十几万,每天又有几十万次互动事件,每次都实时查库排序,库根本扛不住,而且上榜前 50 名还要给运营后台做配置加权。这时候我用 Redis Zset 来做实时的"分数聚合 + 排序",MySQL 只负责数据最终落库备份,承担的压力小很多。
4.2 数据结构设计
我设置了两个 Zset:
rank:user:month:202402:记录每个用户的当月总互动分数。rank:user:month:202402:temp:临时统计增量,每小时跑一次批处理,把增量合并进主榜单。
key 里带上202402这个月份后缀,是为了月底做榜单归档时非常方便:直接把整个 key 改名或删除,不用小心翼翼地逐条清理。
score 的计算公式做了加权处理:
总得分 = 获赞数 * 1 + 评论数 * 2 + 转发数 * 3因为 Zset 的 score 只能存一个数字,需要把三个维度的数据压缩成一个数值。具体做法是:每当收到一条互动消息,就更新用户在 temp Zset 里的分数,比如收到一条评论就给这个用户的 score 加 2,收到一条转发就加 3,等批处理时间到了,再通过 ZUNIONSTORE 把 temp 合并进主榜单。
4.3 写入和查询的关键代码
写入侧的逻辑大概长这样(我用 Java 的 RedisTemplate 演示核心部分):
public void addScore(String userId, int score) { String tempKey = "rank:user:month:202402:temp"; stringRedisTemplate.opsForZSet().incrementScore(tempKey, userId, score); }查询榜单前 50 名(从高分到低分):
public List<UserRankVO> top50() { String rankKey = "rank:user:month:202402"; Set<ZSetOperations.TypedTuple<String>> tuples = stringRedisTemplate.opsForZSet().reverseRangeWithScores(rankKey, 0, 49); // 遍历 tuples 组装返回结果 }查当前用户自己的排名和分数:
public UserRankVO selfRank(String userId) { String rankKey = "rank:user:month:202402"; Double score = stringRedisTemplate.opsForZSet().score(rankKey, userId); Long rank = stringRedisTemplate.opsForZSet().reverseRank(rankKey, userId); // 注意 reverseRank 返回从 0 开始的索引,展示的时候要 +1 }4.4 定时合并与过期策略
临时增量 data 合并进主榜,我用了一个 Spring Schedule 每 10 分钟执行一次:
public void mergeTempToMain() { String tempKey = "rank:user:month:202402:temp"; String mainKey = "rank:user:month:202402"; // 把 temp 集合里所有成员按分数累加到主集合里,权重 1 表示原样合并 stringRedisTemplate.opsForZSet().unionAndStore(mainKey, tempKey, mainKey); // 合并完成后清空临时集合 stringRedisTemplate.delete(tempKey); }这里有个很关键的细节:unionAndStore(mainKey, tempKey, mainKey)是允许目标 key 和源 key 相同的,结果集就是"主榜单里每个成员加上 temp 里同名的增量,没有在 temp 里出现的保持原分数",完美实现了增量合并。
月度结束归档时再写一个任务,把主榜单 key 改名成rank:user:month:202402:archive,然后删掉临时 key,进入下个周期的计算。
4.5 上线后的性能表现
上线后最高峰时期大概每秒有 2000 次左右 ZINCRBY 写入,P99 耗时稳定在 1ms 以内。榜单查询因为有 Redis 层的缓存 + Zset 的跳表结构,即使是 ZRANGEBYSCORE 拉取全量榜单,耗时也都在几毫秒级别。相比之前直接查 MySQL 动不动几百毫秒的排序查询,至少提升了两个数量级。
这一步方案演变其实很多团队都走过,核心是"写入时就把排序算好",而不是"查询时临时去算"。Zset 本质上是"以空间换时间"的典型代表:代价是内存里多存一份有序结构,换来的是 O(log N) 级别的高效读写和排名查询。
5. 实战场景延伸:延时队列、限流与分布式锁
排行榜是 Zset 最容易想到的场景,但 Zset 的价值远不止于此。如果你接手一个中大型分布式系统,在下面几个场景里用 Zset 能省掉一大半自研组件的功夫。
5.1 基于 Zset 的延时队列
延时队列的业务需求非常常见:订单超时未支付自动关闭、外卖超时未接单自动退款、定时任务延迟执行。传统做法是每个任务启动一个定时器,但任务量一大,线程和内存开销都不小。
Zset 的方案是:把任务的执行时间戳当作 score,任务内容作为 member:
ZADD delay_queue 1700000000 "task_order_1001" ZADD delay_queue 1700003600 "task_order_1002"消费者进程每隔一段时间执行一次 ZRANGEBYSCORE,把 score 小于当前时间的所有任务取出来处理:
// 伪代码 long now = System.currentTimeMillis() / 1000; Set<String> tasks = redis.opsForZSet().rangeByScore("delay_queue", 0, now); for (String task : tasks) { // 处理任务 process(task); // 处理成功后删除,防止重复消费 redis.opsForZSet().remove("delay_queue", task); }这里有一个并发下的坑:多个消费者同时扫描到同一批到期的任务,可能会重复处理。稳妥做法是"取出任务后先 ZREM 再执行,或者用 ZREMRANGEBYSCORE 直接把窗口拉出来再执行",这样每个任务只可能被一个消费者拿到。短时间内的重复扫描也不怕,因为任务已经不在集合里了。
5.2 滑动窗口限流
限流最常见的有两种思路:固定窗口计数和时间戳滑动窗口。固定窗口的边界处会有流量突刺问题,而滑动窗口能更平滑地控制速率。
Zset 实现滑动窗口限流的逻辑很直接:把每次请求的时间戳作为 member,score 也设为相同的时间戳:
public boolean isAllowed(String userId, int maxRequests, int windowSeconds) { String key = "rate:user:" + userId; long now = System.currentTimeMillis(); long windowStart = now - windowSeconds * 1000L; // 移除窗口外的记录 redis.opsForZSet().removeRangeByScore(key, 0, windowStart); // 统计当前窗口内的请求数 Long count = redis.opsForZSet().zCard(key); if (count != null && count >= maxRequests) { return false; } // 加入当前请求 redis.opsForZSet().add(key, String.valueOf(now), now); return true; }这里 member 用时间戳字符串,主要是为了保证同一毫秒内多个请求也能被记录(member 必须唯一)。因为每次限流判断都会先把过期窗口清理掉,所以这个 Zset 不会无限增长,一般超过窗口时间就会自动被 remove 掉。相比 Redisson 的 RateLimiter,用 Zset 自己实现的好处是线程数、客户端语言都不受限制,逻辑完全透明可控。
5.3 用 Zset 实现公平分布式锁
普通的 Redis 分布式锁(SETNX + 过期时间)在高并发场景下容易遇到"锁被其他线程误删"的坑,而且不保证公平性——后来者可能永远抢不到锁。Zset 可以通过把"请求排队编号"作为 score 来实现一把"公平锁":
public boolean tryFairLock(String lockKey, String requestId, int timeoutSeconds) { String queueKey = "fairlock:queue:" + lockKey; long requestTime = System.nanoTime(); // 用纳秒时间戳作为排队顺序 // 加入排队队列,member 是 requestId redis.opsForZSet().add(queueKey, requestId, requestTime); // 获取自己在队列中的排名 Long rank = redis.opsForZSet().rank(queueKey, requestId); if (rank == null) { return false; } // 自己排在第一位,说明可以拿到锁 if (rank == 0) { redis.opsForValue().set("fairlock:holder:" + lockKey, requestId, timeoutSeconds, TimeUnit.SECONDS); return true; } // 没排到第一位,等待一段时间后再来查 return false; }这种实现方式的好处是:多个线程不会同时疯抢锁,而是按照先来后到的顺序依次拿锁,非常公平。代价是需要额外的轮询和排队逻辑,适合对公平性要求高的业务场景,比如定期批量任务中的资源分配。如果只是普通互斥需求,SETNX 那把简单锁就够用了。
6. 常见问题与排坑记录
6.1 大数据量下的分页性能问题
很多新手用 Zset 做排行榜分页时,习惯直接把 offset 设得很大,比如第 100 万条数据之后的分页。Redis 的 ZRANGE 在取尾部数据时,需要从跳表头部一路往下走,相当于 O(offset),当 offset 很大时性能会急剧恶化。
我实测过一个 500 万成员的榜单,直接取第 400 万到 400 万零 5 名,耗时比取前 10 名慢了差不多两个数量级。优化思路有两个:
- 限制最大分页深度,只允许用户翻到前 1000 名,1000 名之后不提供分页,用"按分数区间拉取"替代。
- 如果必须支持深度分页,考虑用"上一次的最后一名分数作为游标",比如
ZRANGEBYSCORE rank (lastScore +inf LIMIT 0 10,用游标滑动的思路替代 offset 跳转,性能稳定在 O(log N + M)。
6.2 score 相同导致排名不稳定
排行榜必须处理"同分"的情况。如果直接用 ZRANK 查排名,Redis 在跳表内部会按 member 字典序排,在两个 member 分数相同的时候,谁的字典序小谁排在前面,这对业务来说是不可控的。
常见解法是"业务自己扩展排名规则":只把 Zset 的排名当作"并列排名"来用,相同分数显示同一个名次,比如前两名都是 100 分,就都显示第 1 名,第三名是 90 分就显示第 3 名。另一种做法是"改造 score":把时间因素也编码进 score 里,比如score = 总分数 + 1 - 时间戳的归一化值,这样分数高的一定排在前面,分数相同的按时间排序,但这个方案会让 score 失去直观性,改排行规则时要谨慎。
6.3 Zset 作为"缓存 + 持久化"的双层结构使用时怎么保证一致性
很多团队用 Zset 做热点排行榜,同时又要求数据不能丢。Redis 毕竟持久化策略有 RDB 和 AOF 两种,如果配置不当,重启后排行榜可能回滚到很久以前的状态。
我的经验是:对于排行榜这种对实时性要求高、但对个别数据丢失容忍度较高的场景,开启 AOF 的appendfsync everysec足够,最多丢 1 秒的数据,影响可接受。但如果 Zset 同时用来做延时队列,任务一旦丢失就会造成业务事故,建议开启appendfsync always,或者走"业务自己保障消息不丢"的机制——把任务写入数据库,Zset 只作为加速查询的索引,处理成功后再删。
6.4 大 key 和热 key 的治理
Zset 如果被塞得特别大(比如几百万个成员),同时又承载高频读写,就很容易成为 Redis 里的热点 key:单个 key 的读写压力都集中到一个分片节点上,这个节点会成为性能瓶颈。解决办法是"分片":把一个大 Zset 按业务维度拆成多个 key,比如按业务线分、按用户 ID 哈希分,读的时候并发查多个 key 再合并结果。
另外,如果你发现线上 Redis 的used_memory增长很快,排查完大 key 之后别忘了看这几个参数:zset-max-ziplist-entries和zset-max-ziplist-value是否合理。如果数据量不大却用了 skiplist,内存浪费可能超出预期,调整配置后能直接省出一大块内存。
6.5 Zset 里 member 的设计规范,隐蔽的坑
Zset 的 member 有一个容易忽略的约束:member 在整个集合中是唯一的。如果你用字符串拼接业务 ID,一定要自己保证拼接结果的唯一性。
我踩过一个真实案例:运营后台配置"全量用户排行榜",member 直接用用户 ID 拼日期,本意是每个人每天一条。但运营改了需求后要求"只看当月累计",我就改成每天写入时直接 ZINCRBY,结果某些老用户的历史记录因为 member 和当天一样被覆盖了,排行榜瞬间少了很多人。这个问题的根因是 member 设计时没有把"统计周期"考虑进去。避免方法很简单:member 里尽量带上"归属粒度"的唯一标识,比如month:202402:user:1001,这样后续扩展统计范围时,只需要改 key 前缀,不会污染已有数据。
7. 再给你一份 Zset 面试高频题清单
如果你正在准备面试,或者要带一个刚接触 Redis 的同事,这几个问题基本是绕不开的。我顺手把参考答案的核心也写一下,当作速查用。
Q1:Zset 底层用的是跳表,为什么不用红黑树?
红黑树在内存里也是 O(log N) 级别的平衡树,但实现复杂度比跳表高很多。跳表结构简单,容易通过调整概率因子来控制层数,范围查询(比如 ZRANGEBYSCORE)在跳表里只需要找到起始节点再沿链表往后遍历,非常自然;红黑树的区间查询则要中序遍历和回溯,实现复杂多了。跳表还天生利于并发——查找的时候不需要加锁,插入时只影响局部节点。对 Redis 这种追求简单和极致性能的场景,跳表明显更合适。
Q2:Zset 为什么还要搭配一个 dict?
跳表负责排序和区间操作,dict 负责 O(1) 的精确查找。两者结合,Zset 才能在"按范围查"和"按 member 查"两个维度都保持高效。如果没有 dict,ZSCORE 就退化成一个 O(log N) 的搜索;反过来,如果没有跳表,ZRANK 就无法实现。它们互相补齐,缺一不可。
Q3:Zset 的 ziplist 和 skiplist 的转换条件是什么?
ziplist 转换为 skiplist 的条件有两个,满足任一就会触发:一是元素数量超过zset-max-ziplist-entries(默认 128),二是新增元素的 member 长度或 score 长度超过zset-max-ziplist-value(默认 64)。转换过程会阻塞 Redis,大数据量下建议在业务低峰期让数据量缓慢增长,避免一次性大批量写入触发集中转换。
Q4:如何用 Zset 实现一个支持"分页 + 总页数"的排行榜?
总页数用 ZCARD 除以每页大小向上取整即可;分页则用 ZREVRANGE 加 offset 和 limit。如果数据量很大,可以用上一页最后一名分数做游标滑动,避免大 offset 导致性能劣化。
Q5:Zset 作为延时队列时,怎么避免任务重复消费?
核心是"先移除再执行"或者"利用 Redis 的单线程特性保证 ZREMRANGEBYSCORE 的原子性"。消费者把到期的任务一次性 ZREMRANGEBYSCORE 拉出来,再从结果集合里逐个执行。只要移除成功,其他消费者就不可能再次拿到这批任务。
8. 关于 Zset 的几个运维经验
文章最后这部分,我写点偏运维向的经验。很多人开发时把 Zset 用得飞起,真到线上出了问题就开始抓瞎,这里提供三个方向。
8.1 如何快速定位某个 Zset 是否是大 key
线上没必要全靠猜,Redis 自带的命令就能查:
# 查看当前库中所有 key 的大小分布 redis-cli --bigkeys--bigkeys扫描会把每一种数据类型的最大 key 和占比都列出来,跑一遍就能看出哪几个 Zset 吞内存。平时做容量预估,也可以用DEBUG OBJECT key查看编码方式和序列化长度。
8.2 持久化配置对 Zset 的影响
Zset 写操作特别频繁时,AOF 的 fsync 策略对 Redis 写吞吐影响很大。如果业务对数据丢失容忍度高,建议appendfsync everysec;如果数据绝不能丢,必须appendfsync always但性能会下降约一个量级。RDB 快照在 Zset 数据量特别大的场景,生成快照和恢复重载都比较耗时,需要重点监控rdb_bgsave_in_progress指标。
8.3 集群模式下 Zset 的 key 分布策略
Redis Cluster 分片是按 key 哈希的,如果某个 Zset 的 key 对应的是热点业务(比如全站总榜),所有读写都会打到同一个分片上,造成倾斜。我的建议是:能拆就拆,比如按业务线拆,不能拆的话再考虑把榜单做成多级聚合,先算子榜单再合并成总榜,这样既能提高并发度,也能减少单 key 压力。
Zset 是我个人认为 Redis 里"最简单又能最出效果"的数据结构——它不像 Hash 一样需要你精心设计字段结构,也不像 List 一样需要小心维护顺序。只要把 member 和 score 的模型想清楚,很多排序类需求都能一行命令解决。如果你之前一直写的都是ORDER BY然后内存排序,那我强烈建议下次遇到类似需求时,先停下来想想:这个东西放到 Zset 里是不是更优雅?