做后端的人大概率都遇到过这种需求:给用户按某个字段排序展示、做一个实时排行榜、实现一个延迟队列。刚入行的时候我第一反应都是"查出来以后 order by 一下不就行了",数据量小的时候确实没问题,一旦到了"每秒几万次更新、还要实时排名、还要支持按分数区间查询"这种量级,数据库的 order by 就成了性能黑洞。这时候 Redis 的 Sorted Sets(有序集合)就该登场了。
Sorted Sets 是 Redis 五种基础数据类型里最“聪明”的一个,它把 Set 的去重能力和一个可排序的 score(分数)揉在一起,所有成员天然按 score 从小到大排列,底层用跳跃表加哈希表两个结构共同支撑。你问它“分数在 80 到 90 之间的前 10 名是谁”,它毫秒级就能算出来,不需要全表扫描,不需要临时表。这篇文章就围绕 Sorted Sets 本身,把它所有常用命令一个一个拆开讲,讲清楚每个命令的语义、边界情况、时间复杂度和真实业务里你会踩的坑。适合刚学完 Redis 基础数据类型想深入理解的人,也适合要在项目里落地排行榜、延迟队列的开发者,面试前想系统过一遍 Sorted Sets 的人同样可以照着这篇顺一遍。
1. 先弄明白 Sorted Sets 到底解决了什么问题
1.1 一个排行榜需求逼出来的数据结构
在没有 Sorted Sets 之前,如果你想在 Redis 里维护一个“谁得分最高”的列表,你能用什么?
- 用 List:插入顺序固定,想按分数排序得自己取出来再排,数据多了根本排不动。
- 用 Hash:user -> score 存起来方便,但你想拿“分数大于 100 的所有人”时就傻了,只能 HSCAN 全量扫一遍再逐条判断。
- 用 Set:去重很爽,但 Set 本身无序,要排序还是得拉到客户端做。
这三个方案共同的痛点:排序操作在 Redis 之外完成,数据要全量传输,排序要消耗应用服务器 CPU,数据量一大全完蛋。Sorted Sets 的核心思路是把“排序”这件事直接压进 Redis 内部——每个成员带一个 double 类型的分数,Redis 负责在写入时就把数据维护成有序状态,查询的时候直接按顺序吐出来。这个设计看似简单,但它把“按分数排序”“按分数区间取数据”“算排名”这几个高频需求全部变成了 O(logN) 级别的操作,这才是它能扛住高并发实时排行的根本原因。
1.2 score + member:有序集合的“二元世界观”
Sorted Sets 里每个元素由两部分组成:member(成员)和 score(分数)。
- member 是唯一的,同一个 key 里不允许出现两个相同的 member,这一点和 Set 完全一致。
- score 是 double 类型(即 IEEE 754 的 64 位浮点数),可以重复,多个 member 对应同一个 score 是完全合法的。
- 所有 member 按照 score 从小到大排列,如果 score 相同,再按 member 的字典序(lexicographical order)排列。
这里有个新手最容易忽略的点:score 相同的情况下,“谁排在前面”由 member 字符串本身的字节顺序决定,而不是插入顺序。也就是说你先后插入了 "b" 和 "a",但它俩分数一样的话,永远是 "a" 排在 "b" 前面。这个规则在实现并列排名的功能时特别关键,后面实战部分我会再展开。
SDS 字符串作为 member,score 用 double 存储,这就决定了 Sorted Sets 的适用边界:它擅长存“实体 id + 数值指标”这种二元结构,比如 userId + 积分、订单号 + 过期时间戳、商品 id + 销量。如果你需要存的不是一个数值而是一段复杂对象,要么序列化后当 member,要么换别的数据结构,别硬塞。
2. 核心命令拆解:写入、查询、删除
2.1 ZADD:一个命令搞定插入和更新
ZADD 是 Sorted Sets 最基础的命令,语法如下:
ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member ...]最常用的基本形式就是ZADD key score member:
127.0.0.1:6379> ZADD leaderboard 100 "user:1" (integer) 1 127.0.0.1:6379> ZADD leaderboard 95 "user:2" 87 "user:3" (integer) 2返回的整数值表示“本次新增了多少个 member”。如果 member 已经存在,ZADD 就变成了更新 score,返回 0:
127.0.0.1:6379> ZADD leaderboard 120 "user:1" (integer) 0 # 因为 user:1 已存在,只是分数从 100 改成了 120这时候不少人会踩一个坑:到底更新没更新?答案是更新了,返回值只是单纯代表新增数量,想确认有没有发生更新需要加 CH 选项。CH 是 “changed” 的意思,加了这个参数后,返回的是“新增数量 + 被修改分数的 member 数量”:
127.0.0.1:6379> ZADD leaderboard CH 130 "user:1" (integer) 1 # CH 模式下才能看出 user:1 被改了还有几个选项值得单独说:
NX:只在 member 不存在时插入,等价于“新增语义”,已存在的 member 不会更新 score。XX:只在 member 已存在时更新 score,等价于“更新语义”,不存在的 member 不会被插入。GT/LT:只有新分数比当前分数更大 / 更小的时候才更新,Redis 6.2 之后才有,做“分数只升不降”这种逻辑特别方便。INCR:把 ZADD 变成原子自增,等价于 ZINCRBY,后面专门讲。
建议:凡是写“排行榜”这类高频更新场景,优先想清楚你要的是“插入”还是“更新”还是“有条件更新”,然后选对应的选项组合,避免每回先 ZSCORE 判断再 ZADD 的两步操作,那不仅多一次网络往返,还引入了原子性问题。
2.2 ZRANGE 与 ZREVRANGE:正着拿、反着拿
查询是 Sorted Sets 使用频率最高的操作。ZRANGE 按分数从小到大的顺序返回指定排名区间(注意是排名区间,不是分数区间)的元素:
127.0.0.1:6379> ZRANGE leaderboard 0 -1 1) "user:3" 2) "user:2" 3) "user:1"0 -1表示从第 0 个一直取到最后一个。ZRANGE leaderboard 0 1就是取前两名。ZREVRANGE 则是反过来的顺序,从分数高到低:
127.0.0.1:6379> ZREVRANGE leaderboard 0 -1 WITHSCORES 1) "user:1" 2) "130" 3) "user:2" 4) "95"加了WITHSCORES会把 score 一起返回,注意返回格式是 member-score 交替出现的扁平列表,解析的时候要按两个一组处理。Redis 6.2 开始 ZRANGE 强化了很多,可以直接按分数区间查、按字典序查,甚至支持 REV 反向,所以如果你用的是 6.2 以上版本,ZREVRANGE 这种老命令可以慢慢被新语法替代:
127.0.0.1:6379> ZRANGE leaderboard 0 1 REV WITHSCORES 1) "user:1" 2) "130" 3) "user:2" 4) "95"新版本的 ZRANGE 相当于把 ZREVRANGE、ZRANGEBYSCORE、ZRANGEBYLEX 的功能全收敛到一个命令里了,参数变多但语义更统一。老项目里见到 ZREVRANGE 也不要慌,功能是等价的,只是维护成本高一些。
2.3 ZSCORE、ZCARD:查分数和查数量
这两个属于“看一眼”类命令,简单但天天用:
127.0.0.1:6379> ZSCORE leaderboard "user:1" "130" 127.0.0.1:6379> ZCARD leaderboard (integer) 3ZSCORE 返回 member 的分数,member 不存在时返回 nil。ZCARD 返回这个有序集合的元素总个数,等价于 Set 的 SCARD。还有一个 Redis 6.2 才加入的 ZMSCORE,可以一次查多个 member 的分数:
127.0.0.1:6379> ZMSCORE leaderboard "user:1" "user:2" "user:100" 1) "130" 2) "95" 3) (nil)一次 RTT 拿到多个分数,批量场景里能省不少网络开销。做后台管理页的时候,我一般会用 ZMSCORE 把一批 userId 的分数批量捞出来,然后拼成一个表格,不用循环调用 ZSCORE。
3. 按分数区间和字典序查询的进阶玩法
3.1 ZRANGEBYSCORE:闭区间、开区间与无穷大
排行榜业务里最核心的查询是“给我分数在某个区间内的人”。ZRANGEBYSCORE 就是干这个的:
127.0.0.1:6379> ZRANGEBYSCORE leaderboard 90 130 1) "user:2" 2) "user:1"这个命令的区间符号有几个细节特别容易错:
( 表示开区间,不含边界值。比如ZRANGEBYSCORE leaderboard (90 130` 会排除分数正好是 90 的成员。-inf和+inf分别表示负无穷和正无穷,你要查“分数大于等于 90 的所有人”就是ZRANGEBYSCORE leaderboard 90 +inf。- 分数不带括号时是闭区间,含边界。
我见过不止一个同事写ZRANGEBYSCORE key 90 130,结果发现 90 分和 130 分的人都被包含进来,这才想起闭区间默认是包含的。如果业务上不想含边界,一定记得加(:
127.0.0.1:6379> ZRANGEBYSCORE leaderboard (90 (130还有一点,老版本里 ZRANGEBYSCORE 不支持 LIMIT 之外的倒序限制,要拿区间内分数最高的人得配合 ZREVRANGEBYSCORE:
127.0.0.1:6379> ZREVRANGEBYSCORE leaderboard 130 90 LIMIT 0 10注意 ZREVRANGEBYSCORE 的参数顺序是“从大到小”,所以写的是130 90,不是90 130,这个顺序写反了会直接拿不到数据。Redis 6.2 之后用ZRANGE key min max BYSCORE REV LIMIT 0 10也能达到同样效果,语义更清晰。
3.2 ZRANGEBYLEX:按字典序干活
当所有成员的 score 都相同的时候,Sorted Sets 实际上退化成按 member 字典序排列的集合,这时候 ZRANGEBYLEX 就有用了。它按 member 的字典序范围查询,语法里用-表示负无穷,+表示正无穷:
127.0.0.1:6379> ZADD autocomplete 0 "apple" 0 "app" 0 "apricot" 0 "banana" 127.0.0.1:6379> ZRANGEBYLEX autocomplete [app (b 1) "app" 2) "apple" 3) "apricot"[表示闭区间,(表示开区间。这个命令最经典的应用是做输入框自动补全:把候选词全部塞进同一个 key,score 统一为 0,然后根据用户输入前缀去 ZRANGEBYLEX 取范围。比如用户输入了 “app”,你就查[app到[app\xff(用\xff表示前缀的结束边界),就能把所有以 app 开头的词都捞出来:
127.0.0.1:6379> ZRANGEBYLEX autocomplete [app [app\xff 1) "app" 2) "apple"这套方案比关系库的 LIKE 快得多,比 Redis 自身的 KEYS 也安全得多,不会阻塞。但注意它要求所有分数完全一致,如果分数不同排序就按 score 走了,字典序查询会得到意想不到的结果。
3.3 ZCOUNT:区间计数,不取数据只取数
有些场景你不需要数据本身,只需要“这个区间有多少人”。ZCOUNT 就是干这个的:
127.0.0.1:6379> ZCOUNT leaderboard 90 +inf (integer) 2这个命令返回分数在指定闭区间内的成员数量,计算过程和 ZRANGEBYSCORE 一样走跳跃表索引,复杂度是 O(logN),不是全量扫描。做各种“高于某个阈值的用户数量”统计时特别好用,比如实时监控在线用户中“活跃度超过 80 的用户有多少”,一条命令搞定。
配套的还有 ZLEXCOUNT,统计字典序区间内有多少成员,用法和 ZRANGEBYLEX 一样,也是要求 score 都相同。
4. 删改、自增与排名计算
4.1 删除操作:ZREM 与批量删除
删除单个或多个 member 用 ZREM:
127.0.0.1:6379> ZREM leaderboard "user:2" (integer) 1 127.0.0.1:6379> ZREM leaderboard "user:1" "user:3" (integer) 2返回删除成功的数量。如果要按排名区间批量删,用 ZREMRANGEBYRANK:
127.0.0.1:6379> ZREMRANGEBYRANK leaderboard 0 9这段代码会把排名第 0 到第 9 的成员全部删掉,也就是“删除分数最低的前 10 个”。类似的还有 ZREMRANGEBYSCORE(按分数区间删)和 ZREMRANGEBYLEX(按字典序区间删)。做排行榜定期清理尾部数据时,ZREMRANGEBYRANK 是最常用的:只保留 Top N,其他全清掉,一行命令完成,不需要遍历删除。
4.2 ZINCRBY:排行榜更新的核心
排行榜最典型的操作不是“直接改分数”,而是“每次行为发生时给分数加一定数值”。比如用户完成一个任务积分 +10,使用 ZADD 就得先 ZSCORE 读出旧值,再加完写回去,两步操作之间有并发窗口,数据会丢。ZINCRBY 把“读-改-写”合并成一步原子操作:
127.0.0.1:6379> ZINCRBY leaderboard 10 "user:1" "140"返回的是更新后的新分数。这个命令的时间复杂度是 O(logN),Redis 内部会直接定位到 member 然后更新分数并调整跳跃表位置,不需要额外加锁。所有“积分累加”“播放量增加”“投票数加一”这类需求,都应该用 ZINCRBY,不要自己先读再写。
4.3 ZRANK 与 ZREVRANK:排名怎么算
有了分数,自然要问“某个人排第几”。ZRANK 返回 member 按分数从小到大排的排名(从 0 开始),ZREVRANK 返回从大到小排的排名:
127.0.0.1:6379> ZRANK leaderboard "user:2" (integer) 1 127.0.0.1:6379> ZREVRANK leaderboard "user:2" (integer) 2排名从 0 开始这个点很多人会忘,前端展示的时候记得 +1。如果 member 不存在,返回 nil。ZREVRANK 0 就是第一名,这在排行榜场景里是天然契合的:ZREVRANK的返回值直接就是“第几名减一”。
这里要提醒一个“并列排名”的坑。很多业务的排行榜要求“相同分数并列排名”,但 ZRANK 给的是物理排名,它按“分数 + 字典序”排完再算位置。如果三个用户分数都是 100,ZRANK 会给出 0、1、2 三个不同的排名,而不是并列第 1。要实现并列排名,常规做法是分数相同的人按某种规则二次比较,或者干脆用 ZSCORE 拿到分数后在应用层做分段处理。这个没有现成命令能一步到位,必须自己设计,设计时别把 ZRANK 当成并列排名直接用。
5. 集合运算与弹出操作
5.1 ZUNIONSTORE 与 ZINTERSTORE:多集合合并
ZUNIONSTORE 可以把多个 Sorted Sets 按分数合并成一个新的 Sorted Sets,每个 key 还可以设置权重:
127.0.0.1:6379> ZADD week1 100 "user:1" 50 "user:2" 127.0.0.1:6379> ZADD week2 80 "user:1" 90 "user:2" 127.0.0.1:6379> ZUNIONSTORE total 2 week1 week2 (integer) 2 127.0.0.1:6379> ZRANGE total 0 -1 WITHSCORES 1) "user:2" 2) "140" 3) "user:1" 4) "180"ZUNIONSTORE dest 2 week1 week2里的2表示后面有两个 key。默认是简单的分数相加,也可以用 WEIGHTS 给不同 key 的分数乘系数,用 AGGREGATE 指定 SUM、MIN、MAX 三种聚合方式:
127.0.0.1:6379> ZUNIONSTORE total 2 week1 week2 WEIGHTS 1 2 AGGREGATE SUM这段代码表示 week1 的分数乘以 1,week2 的分数乘以 2,再加在一起。ZINTERSTORE 则是取交集,只有同时出现在多个集合里的 member 才会进入结果集,分数按 AGGREGATE 指定的方式聚合。这两个命令特别适合做“多期排行榜汇总”或“多重条件筛选后的综合分”。
要注意的是,ZUNIONSTORE 的时间复杂度是 O(N + M) 加结果的排序开销,如果参与运算的集合很大,这段操作会阻塞 Redis 主线程。Redis 6.2 之后有了不带存储的 ZUNION / ZINTER,它们直接返回结果不写入新 key:
127.0.0.1:6379> ZUNION 2 week1 week2 WITHSCORES不带 STORE 的命令适合结果集要马上被客户端消费的场景,省掉了写 key 再删 key 的额外操作。但同样地,大集合上的运算还是要谨慎评估耗时。Redis 7.0 还补了 ZDIFFSTORE 和 ZDIFF,可以做差集运算。
5.2 ZPOPMAX / ZPOPMIN 与阻塞版本
ZPOPMAX 弹出分数最高的几个元素,ZPOPMIN 弹出分数最低的,弹出即从集合中移除:
127.0.0.1:6379> ZPOPMAX leaderboard 2 1) "user:1" 2) "140" 3) "user:2" 4) "95"一个典型用法是“消费队列”:把任务按优先级分数存入 Sorted Sets,worker 用 ZPOPMAX 取最高优先级的任务处理,处理完再从集合里消失。BZPOPMAX 和 BZPOPMIN 是阻塞版本,如果集合里没有元素,就阻塞等待直到超时或有新元素:
127.0.0.1:6379> BZPOPMAX leaderboard 30这个命令的使用方式和 BLPOP 完全一致,30是阻塞超时秒数,0 表示永远阻塞。用 Sorted Sets 做延迟队列时,BZPOPMIN 是从队列头部取“到期时间最早”的任务,天然匹配。小心不要让多个消费者同时阻塞在同一个 key 上,Redis 是单线程的,阻塞的连接会占用连接资源,连接池要留出余量。
6. 底层原理:为什么是跳跃表
6.1 哈希表 + 跳跃表的双引擎结构
Sorted Sets 的性能优势不是凭空来的,它内部同时维护了两个结构。
- 哈希表负责按 member 精确查找:
ZSCORE、ZADD判断 member 是否存在,走的就是哈希表,O(1) 复杂度。 - 跳跃表负责按顺序维护元素:
ZRANGE、ZRANK、ZRANGEBYSCORE这类跟顺序有关的操作,走的是跳跃表,平均 O(logN) 复杂度。
这两个结构指向同一批元素,通过指针连接,所以内存上有重叠但不重复存储 member 和 score。当集合里的元素数量很少时(默认小于 128 个且每个元素字节数小于 64),Redis 会使用压缩列表(ziplist)编码来省内存,超过阈值才转换成真正的跳跃表 + 哈希表结构。这个细节在 Redis 7.0 之后被 listpack 取代了,但思路一样:小数据用紧凑编码,大数据用高效结构。
6.2 为什么选跳表而不是红黑树
能维护有序序列的数据结构不少,红黑树、B+ 树、跳表各有拥趸,Redis 选择跳跃表有现实考量。
- 实现简单:跳表的插入、删除、查找逻辑就是“从高层往下走,每层找合适位置”,代码量比红黑树小一个量级,而且不容易写错。
- 范围查询友好:跳表本质上是有序链表加上多级索引,按范围遍历时只要找到起点然后顺着链表往右走就行。红黑树想遍历一个范围要先中序遍历,代码复杂得多。
- 并发调节容易:跳表的层数是随机生成的,不需要像红黑树那样在插入后做复杂的旋转和变色调整。Redis 是单线程模型,虽然不太需要关注并发,但随机化带来的实现简洁性依然有优势。
B+ 树更适合磁盘持久化的大规模索引,但 Redis 的数据整体在内存里,内存随机访问本身就很快,跳表的额外指针开销换来的是极简的实现和维护成本,这是非常务实的取舍。
6.3 时间复杂度速查表
很多面试和日常排障都涉及复杂度判断,我把 Sorted Sets 常用命令的时间复杂度整理成一张表:
| 命令 | 时间复杂度 | 说明 |
|---|---|---|
| ZADD | O(logN) | N 为集合元素数量 |
| ZSCORE | O(1) | 走哈希表 |
| ZCARD | O(1) | 内部维护了长度计数 |
| ZRANGE / ZREVRANGE | O(logN + M) | M 为返回的元素数量 |
| ZRANGEBYSCORE | O(logN + M) | |
| ZRANK / ZREVRANK | O(logN) | |
| ZINCRBY | O(logN) | |
| ZREM | O(logN) | 删除单个 member |
| ZREMRANGEBYSCORE | O(logN + M) | |
| ZUNIONSTORE | O(N + M) + 排序开销 | N、M 为参与集合的大小 |
| ZPOPMAX / ZPOPMIN | O(logN) |
这张表最大的启示:单元素操作基本都是 O(logN),这是 Sorted Sets 能支撑千万级排行榜的根本原因,但批量操作(尤其 ZUNIONSTORE)的耗时可能和集合规模线性相关,用之前必须评估数据量。
7. 实战案例:排行榜与延迟队列
7.1 实时排行榜完整方案
排行榜是最经典的 Sorted Sets 应用。假设我们的业务是“用户签到积分榜”,需求是:
- 每次签到积分 +10
- 实时展示 Top 100
- 查看任意用户当前排名
写逻辑用 ZINCRBY:
127.0.0.1:6379> ZINCRBY daily_score:20240601 10 "user:123"用日期作为 key 后缀,天然支持“每日榜”“周榜”(周榜可以用 ZUNIONSTORE 把 7 天的 key 合并)。查 Top 100 用 ZREVRANGE:
127.0.0.1:6379> ZREVRANGE daily_score:20240601 0 99 WITHSCORES查用户排名用 ZREVRANK 再 +1:
127.0.0.1:6379> ZREVRANK daily_score:20240601 "user:123" (integer) 15整个方案没有任何应用层排序,全部由 Redis 内部完成。设计时要注意 key 的过期策略:每日榜如果只保留近 30 天,就给 key 设置 30 天 TTL,Redis 会自动清理过期 key,不用写定时任务。
7.2 延迟队列:用时间戳当 score
延迟队列的需求很常见:订单超时未支付要关闭、消息发送失败要重试、定时任务要按点触发。用 Sorted Sets 做延迟队列的思路很巧妙——score 存“任务应该被执行的时间戳”,member 存任务内容或任务 ID。
- 生产者:
ZADD delay_queue <current_timestamp + delay_seconds> <task_id> - 消费者:用
ZRANGEBYSCORE delay_queue 0 <current_timestamp> LIMIT 0 100取出所有已到期的任务,逐条处理,处理成功后用 ZREM 移除。 - 如果没到期,可以
BZPOPMIN循环等待,或者轮询时算一下最近一个任务的到期时间,选择合适 sleep 时长。
伪代码思路:
while True: now = int(time.time() * 1000) tasks = redis.zrangebyscore("delay_queue", 0, now, start=0, num=100) for task in tasks: if process(task): redis.zrem("delay_queue", task) time.sleep(1)这个队列方案最大的优势是实现简单、支持按时间精准排序,而且天然支持多消费者并发抢任务(因为 ZREM 是原子的,每个任务只会被一个消费者删除)。劣势是没有原生的 ACK 机制,任务处理到一半消费者挂了,任务就丢了。所以这种方案更适合“允许少量丢失”或“配合数据库记录状态可对账”的场景。如果你是第一次实现延迟队列,从这套方案入手绝对比直接上 RocketMQ 要轻得多。
8. 踩坑实录与排查技巧
8.1 分数用浮点数的精度坑
score 是 double 类型,这不是 Redis 的缺陷,是所有浮点数的通病。典型场景:你的业务分数精确到小数点后两位,比如 99.99,存进去再读出来可能变成不能精确表示的二进制浮点数。更危险的是多个浮点数累加时误差会逐步放大。规避方案很简单:
- 分数用“分为单位”的整数存储,99.99 元就存 9999,展示时再除以 100。
- 如果业务分数本身就是整数,直接用整数,别套一层浮点运算。
我实际见过线上系统因为分数是99.99 + 0.01这种计算导致排行榜出现 100.0000000001 这种诡异数字,页面直接把榜单样式干崩了。处理方案是把分数全部换算成整数分值入库,从此再无精度问题。
8.2 相同 score 时不要依赖插入顺序
前面提过,score 相同时按 member 字典序排。这意味着如果你拿“时间顺序”作为先后依据(比如同时刻多人得分),得分相同的人越晚插入反而可能排得越靠前或靠后,完全取决于 member 的字符串内容。这个坑在实现“按时间排序的榜单”时特别容易踩。
规避办法:需要“分数相同时按时间先后排”的业务,把时间因素编码进分数里。常见手法是使用“大整数 + 时间戳”的组合分数。比如主分数是积分,次分数是时间逆序值,可以把分数存成积分 * 10^12 + (MAX_TIMESTAMP - timestamp)这种组合数,既保证主分数优先,又保证同分时时间早的排前面。这个数列可能超出 double 的精确表示范围,所以实际工程里一般用整数拼接或者拆成多个 Sorted Sets 做二次排序,需要你结合数据量权衡复杂度。
8.3 大 key 与阻塞操作
Sorted Sets 的单 key 可以容纳几百万元素,性能依然不差,但有几个操作要警惕:
ZRANGE key 0 -1不带 LIMIT 的全量查询,一次返回几百万条,网络传输直接打满,客户端内存也可能扛不住,一定要分页。ZREMRANGEBYRANK key 0 -1全量清空虽然很快,但结果是一次性删除巨量元素,会造成主线程耗时和内存回收压力。大 key 删除建议用 UNLINK 替换的异步释放机制,或者分批删除。ZUNIONSTORE合并超大集合时要估算耗时,会阻塞主线程,阻塞期间所有其他命令都会排队。
排查方法很简单。用DEBUG OBJECT key看编码方式和元素个数,用MEMORY USAGE key看内存占用,用SLOWLOG GET看历史慢命令。如果某个 key 的元素规模超过百万,最好在监控里针对这个 key 单独加告警,别等它拖垮整个 Redis 实例。
8.4 内存优化:压缩编码与命名字典
Sorted Sets 占用的内存比想象中大,因为跳跃表每个节点都要维护多层指针。优化手段有几个:
- 小集合会自动用紧凑编码(ziplist / listpack),存几百个元素时内存优势巨大,尽量不要让有序集合无意义地塞入大量小元素。
- member 尽量用短字符串,比如用户 ID 用数字字符串而不是长 UUID。member 每多一个字节,排序和哈希都要多占一份内存。
- 多个业务共用一个 Sorted Sets 还是拆成多个 key,要根据读写模式权衡。同一个 key 能承载的写入并发远大于多个 key,但单个超大 key 又会有阻塞风险。一般来说,按业务维度拆分 key,并让单个 key 的元素控制在几十万以内,是工程上比较稳的平衡点。
9. 我的个人经验总结
做了几年 Redis 相关的东西,Sorted Sets 是我在项目里用得最频繁的“重型武器”,功能密度远超其他四种基本类型。根据我个人的体会,有几个经验值得单独分享给你。
第一个是“能用整数就别用浮点”。所有跟金钱、分数、排名相关的字段,在设计阶段就定成整数存储,这是成本最低但收益最大的决定,能帮你省掉后面一整类精度 bug。
第二个是“优先用 6.2 之后的命令风格”。ZRANGE 统一了反向、按分数、按字典序的查询语法,新代码里写 ZRANGE 比混杂 ZREVRANGE、ZRANGEBYSCORE、ZRANGEBYLEX 三套老命令可读性强得多,排查问题时心智负担也小很多。
第三个是“排行榜这类需求,一定要想清楚并列规则再动手”。ZRANK 只是物理排名,业务上的“并列排名”需要自己在分数设计上做文章。在需求评审阶段就确认好这个规则,能避免上线后又返工。
最后一个技巧是“延迟队列别一上来就引入消息中间件”。如果你的延迟任务量在百万级以内、对消息可靠性要求不是极端苛刻,一个 Sorted Sets 加一个轮询进程就够了,开发和运维成本低好几个量级。等业务量真上去了,再去迁移 RocketMQ 或专门的消息队列,那时候你对“到底需要消息队列的哪些能力”才有清晰的认知。
照着这篇把命令敲一遍、把场景想一遍,再回你正在做的项目里去审视哪些地方能用到 Sorted Sets,这个数据类型的价值你才算真正吃透了。