如果你在业务里见过那种“要排名、要排序、还要打分实时更新”的需求——比如直播榜单、商品热度榜、后台的每日签到积分榜——那你一定绕不开 Redis 的 zset。我最早接触这个数据类型时还把它当成“带了分数的 set”,后来被线上一个排行榜需求反复折磨,才真正把它从头啃到尾。这篇文章是学习笔记的第六篇,专门拆解 zset 有序集合:底层是什么、命令怎么用、场景怎么做、坑在哪里,最后再说说它和其他数据类型怎么选。无论是刚入门 Redis 的开发者,还是已经在用 zset 但只停留在 ZADD/ZRANGE 层面的人,都应该能从这里拿走一些能直接用的东西。
1. 为什么非要用 zset:排行榜需求是怎么逼出来的
1.1 用 List 和 Set 手搓排行榜的痛苦
先思考一个很普通的业务:做直播间热度榜,规则是“观众送 1 个礼物,主播热度 +1,榜单按热度倒序”。
面对这个需求,最容易想到的方案是 List。每次有人送礼物,就 LPUSH 一条数据,最后一次性取出来。但问题马上出现了——List 只保证插入顺序,不保证热度顺序。你要倒序展示,就得把整个列表捞到客户端排序;热度变化时,还得在列表里找到那条记录并移除,再重新插入到正确位置。一个峰值几千人同时在线的直播间,这一套操作能把服务器拖垮。就算你用 SORT 命令,也只是“排序一次”,热门数据一变就又要重排。
也有人想到 Set。Set 帮你去重,可以避免同一个用户刷条目,但它是无序的,只能做到“知道谁来过”,没法知道谁是第一名,更没法快速返回 Top N。你仍然要把全量数据取出来在内存里排序。
这两个方案之所以别扭,本质上是因为它们把“数据存储”和“排序逻辑”混在了一起。排序不是不归数据库管,而是普通的线性结构管不了动态排序。你需要的是一个能“让数据自己保持有序列状态”的结构。
1.2 zset 给出的答案:member + score,排序交给数据库
zset(Sorted Set,有序集合)的核心抽象就两个东西:成员(member)和分数(score)。
- member 是具体的元素,唯一,天然去重;
- score 是一个 double 类型的数值,决定 member 在集合里的位置;
- 集合内部永远按照 score 升序维护,你插入一条数据、修改一条数据的 score,它都会自动跑到正确位置。
回到直播热度榜:把用户 ID 当 member,把礼物数量当 score。有人送礼,一条 ZINCRBY 就把热度加上去;要看榜首,一条 ZREVRANGE 0 0 就拿到了。整个过程不需要你手动排序、不需要先把数据拉到内存,Redis 在写入时就已经维护好了顺序。
1.3 先建立一个直观认知
我给第一次接触 zset 的朋友打个比方:它就是一张始终按分数排好序的成绩单。老师在黑板上写分数,写的人不需要重新誊写整张表,只需要把新分数插到它该在的位置。而这张成绩单既能按分数段裁出一截来(比如 90~100 分的人),又能告诉你某个学生排第几。这就是 zset 最朴素的使用模型,后面所有命令都是在这个模型上做增删改查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一张图看懂 zset 的底层结构:跳表与哈希表如何配合
2.1 两种编码方式,小集合与大数据量各有对策
每次我说“zset 底层是跳表”,都有读者接一句“那大集合是不是很吃内存”。这个问题问对了一半。实际上 Redis 会根据元素数量和单个元素的大小,给 zset 选择不同的编码方式。
- 元素少、member 短时,内部采用紧凑编码。Redis 7.0 之前叫 ziplist,7.0 之后换成了 listpack。这个阶段 zset 的元素就挨个挤在一段连续内存里,省内存效果非常好。
- 元素数量超过阈值后,才会转换为 skiplist(跳表)+ dict(哈希表)的组合结构。threshold 对应配置项
zset-max-listpack-entries和zset-max-listpack-value,默认一个 128,一个 64。也就是说,集合里元素超过 128 个,或者某个 member 的字符串长度超过 64 字节,就会升级成跳表结构。
想看当前 zset 用的什么编码,用 OBJECT ENCODING 命令:
bash复制127.0.0.1:6379> OBJECT ENCODING zset_key
"listpack"
# 数据量变大后
127.0.0.1:6379> OBJECT ENCODING zset_key
"skiplist"
很多脚本和监控平台会定期用这个命令扫描大 key,确认编码转换是否发生了。
2.2 跳表(skiplist)原理:拿空间换时间,还换得特别优雅
跳表可以理解成“带快速通道的链表”。普通链表查一个元素只能从头一个个往后找,O(N)。跳表在原始链表上叠了多层索引,每一层跳过一部分节点。
拿地铁举例:普通链表像每一站都停的慢车,你要从起点到第 30 站得坐 30 站;跳表则是把车站分了好几层快线,第一层 2 站一停、第二层 4 站一停、再往上 8 站一停。查询时先坐快线,到目标附近再换慢线精确停靠。这样查找的时间复杂度可以降到 O(log N),插入和删除也都是 O(log N)。
Redis 跳表实现里,每个节点不一定有同样多层索引。插入节点时按概率(Redis 使用 25%)随机生成层数,平均大概 1.33 层,最大层数限制是 32 层。这种随机化设计让跳表不需要像平衡树那样做复杂的旋转和再平衡。查询时从最高层开始,从左往右、从上往下跳跃,一路逼近目标 score。
2.3 为什么是跳表,而不是红黑树或 B+ 树
这是个经典问题。理论上平衡树(红黑树)的复杂度也是 O(log N),但 Redis 最终选了跳表,原因有三个:
- 实现简单,不容易写出 bug。红黑树的插入删除有旋转、变色一套复杂规则,跳表只要处理指针的层数替换。
- 范围查询(区间遍历)非常友好。红黑树做范围查询需要中序遍历,复杂度高且实现繁琐;跳表则是沿着最底层链表的指针一路扫过去,天然适合
ZRANGEBYSCORE这种“甩给你一段区间”的操作。 - 内存开销可控。跳表每一层都是一个前向指针,虽然整体比普通链表占内存,但换来的是对数级别的查询,性价比很高。
注意,zset 并不是只用跳表。跳表按 score 排好序,但如果你想通过 member 找它的分数,也就是执行 ZSCORE,跳表需要 O(log N)。Redis 在跳表之外还挂了一张哈希表(dict),key 是 member,value 是指向跳表节点的指针。这样 ZSCORE 就变成 O(1),ZRANK 这类定位也能在 O(log N) 内完成。两张结构通过 member 串联起来,一个管“按分数找成员”,一个管“按成员找分数”,互不冲突。
2.4 同分元素怎么排序
还有一个容易忽略的细节:当 score 相同时,zset 内部按 member 的字典顺序排列。比如:
bash复制127.0.0.1:6379> ZADD same:score 100 b 100 a 100 c
127.0.0.1:6379> ZRANGE same:score 0 -1
1) "a"
2) "b"
3) "c"
这对实际业务很重要。排行榜经常出现同分,如果你希望同分情况下按某个规则排序,不能指望 zset 的默认顺序,而要在设计 score 阶段就考虑组合分数。具体技巧后面实战部分再说。
3. 高频命令实操手册:从 ZADD 到 ZRANGE 的完整链路
3.1 写操作:加成员、增量更新、删除
zset 最常用的写命令就是 ZADD 和 ZINCRBY。ZADD 一次性可以加多个 member,也可以顺便更新已存在成员的 score:
bash复制# 添加或更新三个成员
ZADD live_rank:room_1001 100 user_a 98 user_b 120 user_c
# 给 user_c 热度再加 30
ZINCRBY live_rank:room_1001 30 user_c
ZINCRBY 是原子操作,高并发场景下特别关键。同一时间一千个人给同一个用户送礼,每个请求执行一次 ZINCRBY,Redis 会串行保证累加结果准确,不需要你加锁。
删除操作有几个维度:
ZREM key member [member...]:按 member 删,最常用;ZREMRANGEBYSCORE key min max:按分数区间删,比如清理 0~旧时间戳 的过期任务;ZREMRANGEBYRANK key start stop:按排名区间删,比如只保留榜单前 100,把 100 名之后的全移除。
排行榜“只保留 Top N”是个高频需求,ZREMRANGEBYRANK 一条命令就能解决,不用先算后删。
3.2 读操作:查分数、查排名、查区间
读命令分三组:
- 单点查询:
ZSCORE key member查某个成员的分数,O(1);ZCARD key查成员总数;ZCOUNT key min max查分数在区间内的成员数量。 - 排名查询:
ZRANK key member返回成员在升序里的排名,ZREVRANK是降序排名。注意排名从 0 开始,显示给用户时要加 1。 - 区间查询:
ZRANGE key start stop [WITHSCORES],按下标区间取;ZREVRANGE是倒序取;ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]按分数区间取;ZRANGEBYLEX key min max [LIMIT]按字典序区间取。
Redis 6.2 之后更推荐用统一的 ZRANGE 替代上面几个命令,它增加了 BYSCORE、BYLEX、REV 和 LIMIT 子参数。比如“按分数区间反向取值”可以直接写成:
bash复制ZRANGE live_rank:room_1001 100 200 BYSCORE REV WITHSCORES
这条命令等价于老式的 ZREVRANGEBYSCORE,而且组合能力更强。新项目直接用新写法,碰到旧代码再迁移也不麻烦,语义完全一致。
3.3 集合运算:并集、交集、差集
zset 也提供了集合运算,处理“总榜 = 各直播间榜合并”这类需求:
ZUNIONSTORE dest numkeys key [key ...] [WEIGHTS w] [AGGREGATE SUM|MIN|MAX]ZINTERSTORE dest numkeys key [key ...]ZDIFFSTORE dest numkeys key [key ...]
简单说,做全站热榜时可以把几十个直播间的 zset 合并到一个总榜里,权重可以调,比如大主播房间的分数乘 1.5,小主播房间乘 1.0。聚合方式默认 SUM,也就是分数相加。
一个容易踩的点:ZUNIONSTORE 默认目标 key 的成员分数是“源 key 分数相加”,如果两个榜单里有同一个成员,分数会累加。这通常是你想要的,但如果想做“取最大”或者“取最新”,要显式指定 AGGREGATE MAX 或者 MIN。
3.4 阻塞弹出命令:BZPOPMIN / BZPOPMAX
Redis 5.0 引入了 BZPOPMIN / BZPOPMAX,这俩命令让 zset 也能当阻塞队列用。消费端阻塞等待 zset 里最小的元素出现,有了就弹出,可以在延迟队列场景减少轮询的 CPU 浪费。后面延迟队列实战会用到它。
4. 业务场景落地:排行榜、延迟队列与更多玩法
4.1 经典排行榜:一步到位的榜单系统
排行榜是最标准的 zset 场景。我做过一个主播积分榜,逻辑非常顺:
- 主播每次收到礼物,执行
ZINCRBY rank 5 anchor_id; - 需要看 Top 10,执行
ZREVRANGE rank 0 9 WITHSCORES; - 需要看某个主播排名,执行
ZREVRANK rank anchor_id; - 榜单要清空重来,DEL key 或者换个 key 名。
这个方案最大的优势是读写复杂度都是 O(log N),无论榜单里是 1000 人还是 100 万人,取 Top 10 的响应时间几乎不变。
需要注意:ZREVRANK 返回的名次是 0-based。很多前端直接展示 0 就会闹笑话,记得加 1。
4.2 延迟队列:用 score 存执行时间的不完美方案
zset 实现延迟队列是经典方案,核心思路惊人地简单:
- member 存任务 ID;
- score 存任务计划执行的时间戳;
- 有一个轮询任务不断查
ZRANGEBYSCORE key 0 now LIMIT 0 10,查出来就执行,执行完ZREM掉。
生产端:
python复制# 60 秒后执行
r.zadd("delay_queue", {"task:1001": time.time() + 60})
消费端:
python复制while True:
now = time.time()
# 取当前时间之前到期的任务,最多 10 个
jobs = r.zrangebyscore("delay_queue", 0, now, start=0, num=10)
for job in jobs:
# 只有 ZREM 返回 1 的进程才真正执行,避免多实例重复消费
if r.zrem("delay_queue", job):
execute(job)
time.sleep(0.1)
这里 ZREM 很关键。多个消费者同时取到同一个任务时,只有 ZREM 成功的那个人才执行,天然起到“抢单”作用。如果希望更主动、减少轮询,可以换 BZPOPMIN 阻塞弹出,但它没有“按到期时间取”的能力,更适合“到期时间已到就立即弹出的场景”。
延迟队列的边界你要清楚:zset 方案没有消息确认机制,任务执行失败后如果已经 ZREM,就会丢失。生产环境建议加一个“执行中集合”和重试队列,任务取出后放另一张 zset 或 hash 里记录状态,失败再投回来。延迟队列写得足够稳,但别把这套方案当成 RabbitMQ 那种带 ACK 的消息队列来用。
4.3 同分排序:把时间戳塞进 score
排行榜同分是避不开的。比如一个游戏排行榜,都是 100 万分,系统希望“先达到 100 万分的人排前面”。zset 同分时按字典序,肯定不符合要求。
解决办法是把主分数和时间信息“打包”进 score。由于 score 是 double,你可以用整数部分放分数,小数部分放一个随分数增加而变化的序列。思路是反着来:先达到的人排前面,那么序列要越大越早达到。可以定义:
python复制# 用一个很大的数减去时间戳,让早期时间戳对应更大的序列
sequence = 1_000_000_000_000 - int(time.time() * 1000)
score = points * 1e12 + sequence
取分时用 int(score) 拿到真实分数,用 score - int(score) 拿到序列。score 是 double,能精确表示的整数范围有限(2^53 以内),所以要控制总位数。更精细的做法是拆成两段式 score,或者用 ZINCRBY 只加主分数,但同分排序精确就需要组合分数。这是 zset 实战里最需要设计的一环,千万别不假思索地直接用“分数 + 时间”拼一个会溢出的小数。
4.4 更多场景:计数排序与时间线
zset 的能力还可以迁移到很多地方:
- 热搜词排序:出现一次 ZINCRBY hot_word 1 word,取 Top 50 用 ZREVRANGE。相比用 Hash 计数,省了“先拿出全部数据排序”的步骤;
- 商品销量榜:下单成功就 ZINCRBY,榜单实时更新;
- 事件时间线:score 存毫秒时间戳,member 存事件 ID,可以按时间段捞事件;
- 推荐系统候选集:score 存相似度,取 Top K 给召回模块。
这些场景本质都一样:有分数、要排名、要 Top K、要按区间查询,那就用 zset。场景本身的业务语义先放一边,先识别它是不是“可量化分数的集合”。
5. zset 的坑,我真的踩过
5.1 浮点精度的暗坑
zset 的 score 是 double,能精确表示的整数有限。前几年我在一个积分系统里直接把用户余额当 score,余额偶尔出现“xx.9999999”之类的数字,查日志才发现是 double 精度问题。特别是用 ZINCRBY 反复累加小数时,浮点误差会不断累积。
对策分几种:
- 分数尽量设计为整数。像打赏、积分、步数,天然是整数,直接当 score 用就没事;
- 必须用小数时,把业务小数放大成整数存储,展示时再缩小,比如 “12.5 元”存 125,“元”层面用整数;
- 分数不能超过 2^53。超过了,或者你那个分数本身来自很大的 ID 字符串用于排序,就要提前评估。
5.2 大 key 与内存开销
zset 小集合时用 listpack,内存很省;一旦超过阈值转为跳表结构,每个节点就要额外维护多层前向指针和对应的哈希表项,内存开销会明显上升。我曾在一个百万级用户积分榜上,单个 key 就占了 200 多 MB。这不一定是错误,但大 key 会带来两个衍生问题:
- Redis 实例做持久化、主从同步时,大 key 会造成阻塞;
- 热 key 的集中读写会让单实例 CPU 上升。
排查大 key 用 redis-cli --bigkeys,它会扫描整个实例并提示哪些 key 最大、什么类型。线上如果发现 zset 特别大,可以考虑集群拆分,把榜单按业务维度拆成多个 key,或者降级为“Top 100 存 zset + 全量数据存 hash”的组合方案。
5.3 分页越翻越慢
ZRANGE 按下标取,第一页 0~9 很快,但如果你翻到第 10000 页,Redis 要从跳表头部走到 100000 那个位置,代价是 O(offset + limit)。别小看这个,有的榜单系统就是因为前端做了超长分页,把 CPU 打满的。
解决方案:
- 榜单场景一般只给 Top 100,后端做兜底,超出范围的请求直接返回固定结果;
- 如果要全部数据,用
ZRANGEBYSCORE+ LIMIT 按分数游标翻页,每次把上一次最后一条的分数作为下一次查询的下界; - 每次只取需要的数量,不要
0 -1全量拉。
5.4 不能对单个 member 设置过期
zset 只能对整个 key 设置 EXPIRE,不能对单个成员设置 TTL。这会让“临时榜单”“限时活动积分”很难处理:活动结束后,整个 key 删除是简单的;但活动进行中你要让某个过期成员自动掉榜,就麻烦了。
我的常规做法是:score 里带活动结束时间,取榜单时用 ZRANGEBYSCORE 过滤掉过期部分。另一个方案是给每个 member 挂一张单独的 hash 表存过期时间,后台定期用 ZREMRANGEBYSCORE 清理,后者更彻底。没有一劳永逸的办法,得根据你更新频率来取舍——更新频繁,就在查询时过滤;更新不频繁,就定期清理。
5.5 ZRANGEBYLEX 的适用边界
ZRANGEBYLEX 是按 member 字典序取元素,很多人以为可以通用,结果踩了两条硬规则:
- 它只适用于所有 member 的 score 相同的情况,否则结果无意义;
- 它做的是二进制字符串比较,不是自然语言排序。中文按拼音排?不存在的,码点顺序。
所以 ZRANGEBYLEX 最常见的用途是“score 全相同时的分页”,比如把一组 ID 放在同一 score 下做 ID 序列。别拿来当通用的词典排序工具。
5.6 并发环境下“检查再更新”要小心
ZINCRBY 本身是原子的,但很多人会写“先 ZSCORE 读一下,判断条件,再 ZADD 写”的代码。这段逻辑拆成两步之后就不是原子的了,并发时两个请求同时读旧值,然后都写新值,后写覆盖先写,数据就丢了。
解决办法是 Lua 脚本把“读-判断-写”打包成原子操作。比如限制积分不超过上限:
lua复制local score = redis.call('ZSCORE', KEYS[1], ARGV[1])
if score ~= false and score + tonumber(ARGV[2]) > tonumber(ARGV[3]) then
return 0
end
return redis.call('ZINCRBY', KEYS[1], ARGV[2], ARGV[1])
这样脚本整体执行,不受中间插入请求影响。
6. 选型决策:zset 和其他数据类型的边界在哪里
6.1 五大数据类型一句话对比
| 类型 | 元素是否有序 | 是否去重 | 能否按分数/字段排序 | 典型场景 |
|---|---|---|---|---|
| String | 只有单值 | 不适用 | 不适用 | 缓存、计数器、分布式锁 |
| List | 插入顺序 | 否 | 只能按插入顺序 | 消息队列、栈、时间线 |
| Hash | 否 | 字段唯一 | 否 | 对象/用户信息存储 |
| Set | 否 | 是 | 否 | 去重、交集并集计算 |
| Zset | 按 score 排序 | 是 | 是 | 排行榜、延迟队列、Top K |
单看这张表,zset 的优势很明显:它是五种类型里唯一同时具备“去重、有序、可排序、可区间查询”的类型。List 虽有序但不去重,Set 去重但无序,Hash 能存分数但排序得靠客户端。
6.2 什么时候果断用 zset
判断标准很简单:你的数据是不是“一批独立成员,每个成员带一个可比较的分值,且你需要随时知道排序结果”。满足这三点,zset 就是最优解。
反向判断也可以:如果你发现自己为了排名功能,正在 List 上做 SORT、在 Set 上做客户端排序、在 Hash 上拉全表到内存排序,那就该考虑换成 zset。我见过很多团队明明有 zset 却非要用 Hash + 定时任务刷排序结果,最后为了 5 分钟延迟的榜单维护了大一堆临时表,纯属自找麻烦。
6.3 什么时候不要用 zset
zset 也不是银弹。
- 只有去重需求,没有排序需求,用 Set。zset 会额外维护 score 和跳表结构,内存浪费;
- 只有先进先出队列需求,用 List。List 的 LPUSH + BRPOP 更简单,而且支持多个消费者;
- 频繁按成员取大量字段,用 Hash。zset 的 member 只是字符串,不能像 Hash 那样存一个“对象”;
- 要做可靠消息,带 ACK、消费组,用 Streams。zset 延迟队列只是模拟,不具备消息确认和重投机制。
zset 本质是“有序的细粒度计数器/排序器”,不是万能的存储结构。遇到“既有对象字段,又要排序”的需求,我会选择 Hash + zset 双写,或者 Hash 为主、zset 只存“ID + 排序分数”,避免把一个完整对象塞进 member。
6.4 和 Streams 的边界
很多人在延迟队列话题下问 Streams 和 zset 的区别。Streams 是 Redis 5.0 引入的专门消息队列结构,有消费者组、ACK、死信概念,适合需要“明确消费进度”的任务流。zset 方案适合“到期后谁抢到谁干、不关心消费轨迹”的轻量场景。我个人的经验是:任务量不大、失败重试策略简单,用 zset;任务量大、需要多消费者组协作和回溯,就用 Streams。巨头应用通常也不直接用 Redis 序列做最后兜底,但这是另一个话题了。
回过头看,zset 是一个把“排序”这个原本属于算法的动作内化到数据结构里的设计。理解了它,你再遇到排行榜、延迟队列、Top K 这类需求时,会下意识先想到“这个结构天然就是答案”。最后分享一个实际小技巧:上线前用 OBJECT ENCODING 看一眼自己的 zset 到底是 listpack 还是 skiplist,心里有数;排行榜取 Top N 时,永远记得先 LIMIT,别一口气把几万条数据拉回客户端。这两个习惯,能帮你省下不少半夜排查问题的头发。
