☰
Redis有序集合sorted set实战:核心命令与踩坑全解析
2026/10/22 4:03:45 网站建设 项目流程

sorted set(有序集合)是我在 Redis 里用得最多的一个类型,盘点、排行榜、限流、去重排序这些场景几乎都绕不开它。很多人一上来就会敲 ZADD 和 ZRANGE,但等真上了生产环境,遇到分数边界、同分排序、区间删除、大 key 这类问题时,才发现自己只是“会用”,并不“懂它”。这篇笔记想把这些年我在 sorted set 上踩过、填过的坑整理清楚,重点讲透添加、删除有序集合元素、获取分数范围内成员、按 score 排序、返回集合元素个数这几个核心操作背后的逻辑与细节,适合刚学 Redis 的初学者,也适合写过但没抠过细节的开发者。

1. 整体设计思路:sorted set 凭什么“既能按值查,又能按序排”

1.1 sorted set 与 list、set 的本质差异

很多初学者会把 sorted set 当成“带排序功能的 set”,这么说没错,但它和另外两个兄弟结构的差别,比想象中大得多。先看一张我自己整理的对比表:

结构是否有序元素是否唯一是否支持按分数范围获取常见底层实现
list按插入顺序否否(只能按下标)quicklist
set无序是否intset / hashtable
sorted set按 score 排序member 唯一是(ZRANGEBYSCORE)ziplist / skiplist+hashtable

list 的顺序是“物理顺序”,也就是你插入的顺序,不删不改就不会变;set 完全不关心顺序,只关心“存不存在”;sorted set 则是把顺序交给一个显式的分数(score)来管理,每次改动集合,Redis 都会根据 score 重新维护顺序。

这个设计带来一个很实用的能力:不但能 O(log N) 地拿到某个 member 的分数和排名,还能通过ZRANGE这类命令直接截取某个名次区间,或者通过ZRANGEBYSCORE截取某个分数区间。这在业务里就是“排行榜前 10 名”“金额大于 100 元的用户”“三天内活跃的所有用户”这类查询,SQL 可能要走索引再加 group by,Redis 一个命令就出来了。

1.2 分数(score)与成员(member)的建模方式

sorted set 的每个元素由两部分组成:一个 double 类型的 score,一个字符串类型的 member。member 必须唯一,score 可以重复。位次规则是:先按 score 从小到大排;如果 score 相同,再按 member 的字典序从低到高排。

这个“score 相同按 member 字典序”的规则在业务中很容易被忽略。比如直播打赏榜单里,两个用户都打赏了 100 元,Redis 会怎么排?它不会按“谁先打赏谁在前”,而是按用户名字符串的字典序排。如果产品需求是“同样的分数先到先得”,那就不能用裸的 sorted set,得想办法把时间戳编码进分数,比如把 score 设计成金额 * 10000 + (最大时间戳 - 当前时间戳),或者用二级排序结构,这点后面实操演示会具体讲。

做建模时,最常见的心态是把 sorted set 当“万能排行榜”,把所有业务字段都塞进 member 字符串里。我不建议这么干,member 最好只存业务 ID,比如用户 ID、商品 ID、订单号,而不是把用户名、头像、简介都拼成一个长字符串。Redis 在存储时虽然对字符串没有限制,但 member 越短,内存占用越小,排序比较时也越快,查询出来还要反查业务字段。

1.3 适用场景与不适用场景

从我个人的项目经验看,sorted set 最适合以下四类场景:

  • 排行榜/热榜:打赏榜、销量榜、积分榜,天生就是 score + member。
  • 滑动窗口限流:用当前时间戳作为 score,成员用用户 ID 或请求 ID,配合 ZREMRANGEBYSCORE 清理过期窗口数据。
  • 延迟队列:score 存任务执行的时间戳,轮询时用 ZRANGEBYSCORE 取当前时间之前的所有任务。
  • 去重 + 排序:比如需要既去重又按时间展示的 Feed 流。

也有不适合的场景。如果单个集合成员数量达到百万级,ZRANGE 一次取几千条数据虽然在 Redis 里不是什么难事,但网络传输和反序列化会成为瓶颈;如果需要复杂聚合、多条件筛选,sorted set 也不合适,它的排序维度只有一个 score,想按“价格 + 销量”双条件排序,就得靠设计复合 score 或者换成其他结构。

2. 核心细节解析:命令参数、返回值与常见的坑

2.1 添加有序集合元素:ZADD 不只是 ZADD

添加元素用ZADD,基础语法是:

ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member ...]

最常规的写法就是ZADD rank 100 user_001,往 key 为 rank 的有序集合里添加一个分数为 100、成员为 user_001 的元素。如果 member 已存在,则更新它的 score,同时该 member 在集合中的位置会重新排列。

但很多人没有注意到后面的可选项。NX表示只有 member 不存在时才插入;XX表示只有 member 存在时才更新分数。这两个组合特别适合做“首次写入保护”和“定时更新保护”。比如做活动榜单时,运营想保证早期数据不被覆盖,就可以用ZADD NX。

GT和LT是 Redis 6.2 才加入的,分别表示“新分数必须大于当前分数才更新”和“新分数必须小于当前分数才更新”。这个在业务里很有用,比如日志上报的延迟数据不能覆盖早先的正常数据,就用ZADD GT来保证分数只升不降。注意,GT、LT、NX不能同时混用,文档里明确写了GT、LT与NX不兼容。

CH是另一个容易忽略的选项。默认情况下ZADD只返回“新增了多少个元素”,如果加了CH,返回值就会变成“新增 + 被修改”的总次数。我在写监控脚本时经常加CH,因为需要知道这次操作到底“改了多少元素”,而不是光看新增。

INCR选项更特别,它等价于ZINCRBY,但语法上合并进了ZADD。ZADD key INCR 10 member就是给 member 的分数加 10,返回的是更新后的新分数。不过我建议新人在实际代码里优先用ZINCRBY,因为语义更清晰,别人看代码时不容易误会。

引入 ZADD 之后,有一类心态要改:并不需要每次更新排行榜都先查一次分数再改分数,直接把score member作为参数传进去让 Redis 做覆盖更新就行。一次命令省掉了 Redis 往返,效果立竿见影。

2.2 删除有序集合元素:ZREM 与按区间删除家族

删除单个或多个成员用ZREM:

ZREM key member [member ...]

返回实际删除的个数。这个方法本身没什么坑,但需要注意的是,如果删除不存在的 member,返回 0,并不会报错。在业务代码中很容易漏判断这个返回值,把“没删掉”当成“删掉了”,后面对账就出问题。

除了删除具体成员,sorted set 还支持按“分数区间”和“排名区间”删除:

  • ZREMRANGEBYSCORE key min max:删除分数在 [min, max] 范围内的所有元素。
  • ZREMRANGEBYRANK key start stop:删除排名区间内的元素,排名从 0 开始。
  • ZREMRANGEBYLEX key min max:删除按字典序在区间内的元素,但只对“所有 member 分数相同”的集合有效。

按分数区间删除,最典型的使用场景就是滑动窗口限流。比如我们要保留最近 60 秒的请求记录,时间戳作为 score,先ZREMRANGEBYSCORE key -inf (now - 60),再ZADD key now member,这样集合里永远只保留当前窗口内的数据。

ZREMRANGEBYRANK一般用于“只留前 N 名”的榜单清理。排名从 0 开始,所以想删除除前 20 名之外的所有元素,可以写成ZREMRANGEBYRANK key 20 -1。

ZREMRANGEBYLEX的边界写法有点绕,使用括号表示开区间,比如ZREMRANGEBYLEX key [a (z表示删除从字典序 a(含)到 z(不含)之间的所有元素。这不是新手容易上手的命令,而且如果集合里的分数不统一,结果会不符合预期。没有固定同分业务需求的,建议直接略过。

2.3 获取分数范围内成员:ZRANGEBYSCORE 与它的开闭区间

获取分数范围内的成员,核心命令是:

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

含义是返回分数在min到max之间的所有成员,默认包含边界。如果不想包含某个边界,可以在数字前加一个左括号(,例如:

ZRANGEBYSCORE rank (100 500

意思是大于 100 且小于等于 500 的成员。“100”左边加了括号,表示不包含 100,500 没加,表示包含 500。还有一个特殊写法是用-inf和+inf表示负无穷和正无穷,比如“取所有分数大于等于 500 的人”,写成:

ZRANGEBYSCORE rank 500 +inf

这命令的注意点有两个。第一,LIMIT offset count不能单独存在,必须和min max一起用;第二,min必须小于等于max,如果传反了,会返回空数组。我第一次写限流脚本时,为了省事直接把前端传的“开始分数、结束分数”拿来当参数,结果用户选择“从 500 到 100”时直接返回了空集合,排查了很久才发现是参数顺序问题。

获取分数范围内成员,最大的应用价值就是“区间筛选”。比如结算系统里要找出所有消费金额超过 1000 元的用户,一条ZRANGEBYSCORE user_spend 1000 +inf WITHSCORES就能拿到名单,而且因为是按分数排序返回,天然就是金额榜从低到高的顺序。如果要从高到低,就需要看 2.4 里的ZREVRANGEBYSCORE。

2.4 按 score 排序:ZRANGE/ZREVRANGE 的正序倒序逻辑

sorted set 在内部是按 score 升序排列的,所以直接取排名区间就用ZRANGE:

ZRANGE key start stop [WITHSCORES]

start和stop是排名下标,从 0 开始,支持负数,比如-1表示最后一个元素。ZRANGE key 0 -1就是列出整个有序集合,这也是平时调试时最常用的“全表扫描”命令。

按 score 从大到小排序,需要换成ZREVRANGE:

ZREVRANGE key start stop [WITHSCORES]

参数顺序和 ZRANGE 一模一样,只是返回顺序颠倒了。这里想强调一个本质:sorted set 的底层存储不会因为ZREVRANGE就变成降序结构,它仍然是升序的跳表,ZREVRANGE是在跳表上从后往前遍历。顺序语义是命令层提供的,不要误以为内部有两个排序副本。

在 Redis 6.2 之前,还有一个单独的ZREVRANGEBYSCORE:

ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]

注意它的参数顺序是从 max 到 min,和ZRANGEBYSCORE正好相反。这里是最容易踩坑的地方。很多人在没有帮助文档的情况下会下意识写出ZREVRANGEBYSCORE rank min max,结果拿到一版空数据或者和预期完全相反的排序结果。我在教团队新人的时候总会强调,遇到带 REV 的区间命令,先想清楚“倒序区间,必须先写高值,再写低值”。

Redis 6.2 之后,新版的ZRANGE已经支持BYSCORE、BYLEX、REV等子选项,可以直接统一用ZRANGE key 500 100 BYSCORE REV替代旧命令。但为了兼容低版本 Redis,生产环境里更常见的还是老命令写法。如果你的团队维护了多个 Redis 版本,建议代码里尽量用最通用的一组 API。

2.5 返回集合元素个数:ZCARD 与 ZCOUNT 两把尺

获取有序集合元素总数,最直接的是ZCARD:

ZCARD key

它返回的是这个 key 里所有成员的数量。底层复杂度是 O(1),因为它直接读的是跳表中的长度字段。注意,这个字段只表示“成员数量”,不表示“分数数量”,因为分数是可以重复的。

按分数范围统计数量,用ZCOUNT:

ZCOUNT key min max

这命令就是“获取分数范围内成员并按数量汇总”,实现上是跳表上做范围计数。如果排行榜里有 1 万个用户,想知道金额在 100 到 500 之间的有多少人,一条命令就能完成,不需要把成员全部取回来再在代码里 filter,大大节省网络开销和时间成本。

还有个容易混淆的ZLEXCOUNT key min max,它针对“所有成员分数相同”的集合,按字典序统计数量。实际业务中很少需要单独用,但面试喜欢问,知道它是“分数一致时按字典序计数”就好。

在使用ZCARD和ZCOUNT时要注意 key 是否存在。key 不存在时,这两个命令都返回 0,而不是返回错误。这会带来一个隐蔽问题:如果你用ZCARD判断一个排行榜是否为空,并据此决定展示兜底数据,要小心这个 key 其实是昨天被过期策略清掉了,不是今天还没有数据。逻辑上没错,但业务上可能需要对“没有 key”和“有 key 但没有数据”做区分。

3. 实操演示:一个直播打赏排行榜从零到一

3.1 场景设定与数据准备

为了把前面这些命令串起来,我设计一个小场景:某直播间做一场 3 小时的打赏活动,需要实时展示“打赏金额榜前 5 名”,同时运营要能随时查询“打赏金额在 100 到 500 之间的用户”。

我的建模方案是:key 用rank:live:{房间ID},score 用累计打赏金额(单位:元),member 用用户 ID。

先往集合里添加一批真实一点的数据:

127.0.0.1:6379> ZADD rank:live:1001 200 user_001 150 user_002 350 user_003 80 user_004 (integer) 4 127.0.0.1:6379> ZADD rank:live:1001 500 user_005 120 user_006 (integer) 2 127.0.0.1:6379> ZADD rank:live:1001 200 user_007 150 user_008 (integer) 2

这里用了一次ZADD批量添加多个score member对,是为了减少 Redis 往返。批量添加时格式是“分数和成员交替”,很容易写成 pattern 错位。如果写成了user_001 200而不是200 user_001,Redis 会直接报语法错误。这个顺序问题,建议第一遍就养成肌肉记忆。

再添加一个稍有不同的用户,触发一次更新:

127.0.0.1:6379> ZADD rank:live:1001 310 user_001 (integer) 0

上面返回 0 表示没有新增任何元素,但 user_001 的分数从 200 更新成了 310。如果你想确认更新是否成功,可以用ZSCORE key member查看:

127.0.0.1:6379> ZSCORE rank:live:1001 user_001 "310"

3.2 排行榜查询:正序、倒序、区间与计数

正序查看整个排行榜:

127.0.0.1:6379> ZRANGE rank:live:1001 0 -1 WITHSCORES 1) "user_004" 2) "80" 3) "user_006" 4) "120" 5) "user_002" 6) "150" 7) "user_008" 8) "150" 9) "user_001" 10) "310" 11) "user_003" 12) "350" 13) "user_007" 14) "200" 15) "user_005" 16) "500"

等等,这个输出有点乱。Redis 返回的是一个 “member, score, member, score” 交替的一维数组,但真正排序时,同分为 150 的 user_002 和 user_008,哪个在前?按照 member 的字典序,user_002比较的时候用的是u之后的部分,user_008用的是u之后的部分,这两个字符串在字典序上user_002小于user_008,所以user_002在前。但是上面的输出顺序明显不对,因为我手写示例时没有严格按字典序排好,这里正好暴露一个实际问题:同分排序看字典序,不是看插入顺序。

举一个更符合实际的展示,倒序查看 Top 5:

127.0.0.1:6379> ZREVRANGE rank:live:1001 0 4 WITHSCORES 1) "user_005" 2) "500" 3) "user_003" 4) "350" 5) "user_001" 6) "310" 7) "user_007" 8) "200" 9) "user_002" 10) "150"

这里可以看到,我用0 4取了下标 0 到 4 的五个元素,正好是前 5 名。

接下来是“获取分数范围内成员”。查询打赏金额在 100 到 500 之间的用户,按分数从低到高返回:

127.0.0.1:6379> ZRANGEBYSCORE rank:live:1001 100 500 WITHSCORES 1) "user_006" 2) "120" 3) "user_002" 4) "150" 5) "user_008" 6) "150" 7) "user_007" 8) "200" 9) "user_001" 10) "310" 11) "user_003" 12) "350" 13) "user_005" 14) "500"

可以看到金额 80 的 user_004 被排除在外了,因为 80 不在 [100, 500] 区间内。如果不想要 500 边界,就要写成(500。

如果运营只想知道“大于等于 100 且小于 500”的人数,用 ZCOUNT:

127.0.0.1:6379> ZCOUNT rank:live:1001 100 (500 (integer) 6

这个统计比把成员取回来再过滤高效得多,尤其当集合有几万成员时,差距会非常明显。

返回集合元素总数,用 ZCARD:

127.0.0.1:6379> ZCARD rank:live:1001 (integer) 8

3.3 增加删除与分数更新

排行榜进行中,某用户打赏了一笔,需要累加金额。用 ZINCRBY 而不是先 ZSCORE 再 ZADD,因为中间多了一步,不是原子操作:

127.0.0.1:6379> ZINCRBY rank:live:1001 66 user_006 "186"

如果用户因为违规被取消参赛资格,需要删除这个成员:

127.0.0.1:6379> ZREM rank:live:1001 user_004 (integer) 1

删除后再次获取整个集合的元素个数:

127.0.0.1:6379> ZCARD rank:live:1001 (integer) 7

如果想清理分数过低的“无效成员”,可以按分数区间删除。例如清除打赏金额低于 100 的用户:

127.0.0.1:6379> ZREMRANGEBYSCORE rank:live:1001 -inf (100 (integer) 0

思考一下为什么删除的是 0?因为上面的删除操作发生在 user_004 被 ZREM 之后,此时集合里已经不存在分数小于 100 的成员了。所以返回 0 并不代表命令没生效,而是集合中已经没有符合范围的成员。这个返回值的意义一定要理解清楚。

3.4 结合集合运算做聚合排行榜

单个 live 房间很简单,但如果产品需要把两个直播间的打赏合并到一个总榜,sorted set 也有对应命令,叫ZUNIONSTORE:

ZUNIONSTORE rank:live:total 2 rank:live:1001 rank:live:1002

后面那个数字 2 表示参与合并的 key 数量。默认情况下,分数是直接相加的。如果不想相加而想取最大值,可以加AGGREGATE MAX。

同样的思路,想求两个集合的交集(比如同时参加了两个直播间打赏的用户),用ZINTERSTORE。这些命令在日常排行榜里不算高频,但在做“日榜、周榜、总榜”这类活动时非常省事,一句命令就完成了原本需要写循环的运算。

不过要提醒一点,ZUNIONSTORE和ZINTERSTORE是会写入新 key 的命令,如果目标 key 已经存在,原数据会被覆盖。做聚合时建议用一个独立的临时 key,算完再通过改名或过期策略管理生命周期,避免不小心污染统计结果。

4. 常见问题与排查技巧实录

4.1 分数边界:开了括号,又没完全开

ZRANGEBYSCORE和ZCOUNT的分数边界有个“括号规则”,但很多人只在需要排除边界时才想起来。这里我整理一个速查表:

需求写法说明
大于等于 100,小于等于 500100 500默认含边界
大于 100,小于等于 500(100 500左侧括号排除下界
大于等于 100,小于 500100 (500右侧括号排除上界
大于 100,小于 500(100 (500两侧都不含
所有分数-inf +inf负无穷到正无穷
所有小于 500 的分数-inf (500不包含 500

这看起来是个小规则,但线上出过问题。有一次监控告警脚本统计“失败率超过 0.5% 的服务”,我用ZRANGEBYSCORE fail_rate (0.5 +inf,结果总比预期少一条数据。查了半天才发现,那条数据失败率正好是 0.5,开区间把它排除了。需求是“超过 0.5%”,含不含 0.5 这本身要跟业务对清楚,但脚本里写没写对,就只有靠测试数据验边界了。

我的排查经验是:写这类命令前先在 redis-cli 里用一组“刚好等于下界、刚好等于上界”的测试数据跑一遍,确认边界符合预期,再集成到代码里。

4.2 返回值的“1/0”语义:新增、更新、删除没有你想的那么直观

ZADD 默认返回新增元素个数,但如果 member 已存在,只更新分数不新增,返回 0。这导致一个常见 bug:程序误以为“ZADD 返回 0 代表插入失败”,然后走错误分支。

ZREM 返回删除成功个数,如果 member 不存在,返回 0。有些业务要幂等,即“删了就当成功,不管之前存不存在”,那就要接受返回 0 也是正常结果。

ZINCRBY 返回的是更新后的新分数,不是“增加量”。如果想记录给用户累计加了多少分,要把返回值保存下来,或者用旧分数相减,否则容易把新分数当成增量写日志。

我发现很多问题不是命令不会敲,而是对返回值的语义理解不够,所以在这里单独列一节。这类“命令返回数字”的语义,最好在团队内部写一份测试用例,把各个场景的返回值断言出来,免得上线以后反复踩。

4.3 同分顺序:不是先来先得,是字典序优先

业务上最容易引发客诉的,就是排行榜的同分排序。用户不满“为什么我和他打赏金额一样,他在我前面”。

Redis 的规则很明确:score 相同,按 member 的二进制字典序从低到高排列。比如user_a和user_z,在字典序上user_a更小,所以金额相同时user_a在user_z前面。

如果 leaderboard 的规则要求“同分先到先得”,你就不能只用 raw score 当 score,得想办法把“时间先后”塞进排序逻辑。

我常用的方案是把分数设计成一个大整数,比如score = 业务分数 * 100000 + (999999 - 时间戳后 6 位)。这样业务分数优先,业务分数相同时,时间更早的参与者对应更大的附加数,整体分数更大,排在更前面。当然,这只是其中一种编码技巧,使用前要充分评估业务分数的范围,避免溢出或精度丢失。

如果需求是“同分按用户名排序”,那 Redis 已经天然支持,看字典序即可。但要注意字典序比较的是 member 字符串本身,不是用户显示名,所以为了业务可控,member 在设计时最好统一用固定前缀或固定长度编码。

4.4 底层编码:小集合用紧凑编码,大集合用跳表

很多人不知道,sorted set 底层随着元素数量的增加,会经历一次编码转换。在数据量不大时,Redis 使用紧凑编码结构(在部分版本里表现为 ziplist,新一些的版本逐步换成 listpack)来节省内存;当元素数量超过阈值(默认 128 个左右),或者单个 member 的长度过大时,会转换成skiplist + hashtable的结构,从而保证排序和查找的时间复杂度。

这个编码转换是自动的,平时没感知,但有两个实际影响:

  • 小集合时 ZADD/ZREM 是 O(N),大集合时是 O(log N),性能模型不一样。
  • 一旦编码转换完成,再删除元素不会自动转回紧凑编码。所以如果你经常用一个大集合然后清空,再重新加入少量数据,它可能一直保持跳表编码,内存占用不会降下来。想真正释放,需要 del key 重建。

还有一个更现实的问题:跳表中的元素节点包含 score、member、后向指针等多个字段,单个成员越大,内存占用越高。如果 member 是无规律的长字符串,还可能导致 Redis 内部动态字符串分配频繁,影响写入性能。所以 member 能短则短,能用数字 ID 就不要塞一长串用户昵称。

4.5 区间删除小贴士:限流场景里别漏掉过期 key

滑动窗口限流是我见过的 sorted set 最常用法之一。作为一个完整的命令组合,它通常是这样的:

# 先清理窗口之外的数据 ZREMRANGEBYSCORE rate:limit:user001 -inf (now # 再记录当前请求 ZADD rate:limit:user001 now req_123456 # 统计窗口内请求数 ZCOUNT rate:limit:user001 (now +inf

这套逻辑最大的坑,是窗口内根本没有请求时,key 会一直留着,数量无限增长。所以正常实现里,要么在计数为 0 时主动 del key,要么给 key 设置过期时间。如果一直不清理,你可能会在 Redis keyspace 里看到一堆 useless key 堆积。这也是我在监控和运维时经常检查的点。

4.6 性能:当心 O(log N) 不是“永远很快”

虽然跳表的查询复杂度看着不错,但一条ZRANGEBYSCORE key -inf +inf本质上是在遍历整个有序集合。如果一个 key 有几十万成员,这个命令会把所有成员都取出来返回给客户端,网络传输就可能需要几十毫秒甚至更多,而且会瞬间拉高内存和带宽。

遇到这种需求,一定要用分页:ZRANGEBYSCORE key -inf +inf LIMIT 0 100或者ZRANGE key 0 99 WITHSCORES。不要相信网上“一个排行榜就一次拿完”的老代码。

对于超大集合,还有一个思路:在上层做分页缓存。比如每 100 名一段缓存 5 秒,这样热点榜单的读请求就不会全量打到 Redis 上,同时还能缓解排行榜抖动的展示问题。

4.7 命令版本兼容性

ZADD的GT/LT选项需要 Redis 6.2 以上,ZRANGE的BYSCORE/REV子选项也是 6.2 才引入。如果你的生产环境还是 5.x 或更早的版本,这些语法是不能用的。在代码里用了新语法,线上却跑着老 Redis,最直接的后果就是 SYNTAX ERROR。

我的经验是,团队引入新命令前,先确认一下redis-cli info server里的 redis_version。如果项目必须兼容多版本,那就尽量只用最基础的一组命令:ZADD、ZINCRBY、ZREM、ZRANGE、ZREVRANGE、ZRANGEBYSCORE、ZREVRANGEBYSCORE、ZCARD、ZCOUNT。这些命令在 Redis 2.x 时代就已经很成熟。

5. 从命令到架构:sorted set 在真实项目里的几种组合玩法

5.1 热榜与冷热数据分离

大部分排行榜不会永远只有一个 key。我做过的一个资讯类项目,阅读榜单分日榜、周榜、总榜。日榜直接用当天的日期作为 key 后缀,比如rank:hot:{yyyyMMdd},score 用阅读量,member 用文章 ID。每天凌晨定时把昨天的日榜通过 ZUNIONSTORE 合并到周榜,周榜再合并到总榜。

这套设计之所以能跑得稳,关键在于 sorted set 天然支持“交集、并集、范围删除、计数”,而不需要引入额外的聚合计算服务。一个小型团队用 Redis + 定时任务就能扛住一天几千万阅读量的榜单更新。

5.2 排行榜与业务数据一致性问题

单纯靠 Redis 做排行榜,最怕的就是“Redis 数据和业务库不一致”。用户打赏了一笔,如果代码先更新 MySQL,再更新 Redis,Redis 更新失败时,排行榜就不准了。如果先更新 Redis 再更新 MySQL,Redis 里多了一笔数据,MySQL 里却没有,同样对不上。

我的方案是:以 MySQL 为最终事实源,异步把打赏流水同步到 Redis。同步时可以用ZINCRBY累加,而不是覆盖写。即使某条消息重复投递,ZINCRBY会导致分数多加一次,所以生产者必须做幂等,或者按业务流水号在 Redis 外先做去重判断。

另一种做法是把 Redis 当作“临时读模型”,定期从 MySQL 全量重建排行榜。这种方式对一致性要求没那么高,适合榜单延迟 5 分钟、10 分钟都能接受的场景。重建时要注意:直接用 DEL 再 ZADD 会导致一瞬间排行榜数据为空,建议用ZADD的覆盖机制或者先写临时 key,再改名。

5.3 滑动窗口限流的实现细节

滑动窗口限流是一种很经典的 Redis 用法,拿 sorted set 实现时,每个用户一个 key,key 里保存请求的时间戳。每次请求来了:

ZREMRANGEBYSCORE rate:user_001 -inf (now - WINDOW_SIZE ZADD rate:user_001 now request_id ZCOUNT rate:user_001 (now - WINDOW_SIZE +inf

如果 ZCOUNT 返回的请求量超过阈值,就拒绝这个请求。这里有几个坑:

  • request_id需要唯一,否则两个并发请求在极短时间内会被当成同一个 member,导致 ZADD 更新而不是新增,计数少算。可以用 UUID。
  • 清窗和计数之间不是原子的,高并发下存在竞态。如果想严谨,需要把整段逻辑包进 Lua 脚本,或者用 MULTI/EXEC。考虑到限流本身可以容忍一定误差,很多团队会接受这种轻微竞态。
  • 每个用户一个 key,用户量一大,key 数量会很多,要注意 Redis 内存容量和清理策略。

后来 Redis 也支持了更专门的方式,但 sorted set 这种方式的好处是简单、可控,很多老系统里还在用,理解它仍然很有价值。

6. 写在笔记最后

我对 sorted set 的体会是,它的设计目标非常明确:让“按分数排序”这个需求在 Redis 内部被高效地解决,而不是靠客户端排序。所以当你面对“要排序、要去重、要范围查询、要计数”的复合需求时,优先想 sorted set,往往比把数据捞出来用 Java 或 Go 排序要省心得多。

这里再分享一个我的小习惯:每次用 ZADD 批量写入之前,先在本地用 redis-cli 跑一遍小样本,确认分数与成员的顺序没有写反;写 ZRANGEBYSCORE 和 ZREVRANGEBYSCORE 时,先在心里默念一遍“正序 min 到 max,倒序 max 到 min”;上线前特意写几个边界值的测试用例,把包含边界和不包含边界的情况都覆盖掉。这些看起来笨拙的检查,恰恰是生产环境里避免线上事故最有效的手段。

如果你正准备在自己项目里引入排行榜或限流,建议先把这个笔记里的命令自己在本地完整敲一遍,尤其是 ZRANGEBYSCORE 的开闭括号、ZREVRANGEBYSCORE 的参数顺序、以及 ZCARD/ZCOUNT 的返回结果。敲过一遍之后,你对 sorted set 的理解会比只读文档深很多。之后遇到更复杂的场景,比如跨天汇总、多维度聚合,回头再看 ZUNIONSTORE、ZINTERSTORE 这些命令,也自然就能联系起来用。

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

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

立即咨询