☰
Redis ZSet 核心原理与实战:排行榜、延迟队列与限流
2026/10/13 19:30:27 网站建设 项目流程

1. 为什么需要 ZSet:其他数据结构搞不定的场景

先聊一个我印象很深的业务需求。当时在做一个内容社区的排行榜功能,要求是:首页展示全网创作者的热度 Top 100,热度值每分钟更新一次,用户能看到自己当前排名,上榜的人还能拿到流量奖励。

一开始我图省事,直接用 List 做。更新热度就 LREM 删掉旧记录再 LPUSH 新的,查 Top 100 就用 LRANGE 截取前 100 条。数据量小的时候没问题,几千个创作者跑得欢快。但量一上来就麻烦了:每次更新都要做一次 O(N) 的查找删除,排行列表本身也不支持按分数排序,还得自己在代码里排好序再塞进去。而且 List 天然允许重复成员,你还得先判断这个用户是否已经在列表里,又是一次扫描。

后来我又试了用 Hash 存热度值,HSET user:hot user_123 987这种方式。存是存下了,查询单个人的分数确实 O(1),快得很。但问题是 Hash 没有排序能力,想拉出 Top 100 必须把所有 key 拉出来在内存里排一遍,用户量到百万级别时这请求根本扛不住,Redis 单线程执行这种操作会阻塞整个实例。

ZSet 就是来收拾这种局面的。

ZSet,全称 Sorted Set,有序集合。它在 Set 的基础上给每个元素额外挂了一个 double 类型的分数(score),并且天然按 score 从小到大维护顺序。所有跟"排名"相关的需求——排行榜、热榜、延迟队列、限流窗口——它几乎都是最优解。

一句话说清楚它的定位:Set 解决"去重"问题,Hash 解决"O(1) 取值"问题,而 ZSet 同时解决这两个问题,再送你一个有序性。这个"有序"不是像 List 那样靠插入顺序,而是靠你给的分数,这意味着你可以随时改分数、随时调整位置,而集合的排序始终是自维护的,不需要你干预。

所以这一篇我打算直接把 ZSet 从底层结构讲到命令实操,再讲几个我用它做过的真实场景和踩过的坑。如果你是刚接触 Redis,或者以前只把 ZSet 当排行榜工具用过但从没深究过,这篇应该能帮你把它用透。

2. 拆开 ZSet 的底层:跳跃表加哈希表怎么协同工作

有一类问题面试常问、实战也绕不开:ZSet 的底层到底长什么样?为什么 ZADD 和 ZRANGE 都快?

2.1 跳跃表:为什么"跳"着查比一个个数快

ZSet 的排序结构用的是跳跃表(skiplist)。你把它想象成多层的高速公路:底层是一条全量双向链表,每个节点都存着成员和分数;往上每两层抽一层,生成一条"快车道",快车道上每个节点多存了几个指向更远处节点的指针。

查找的时候从最上层开始走,如果下一跳超过目标值了,就往下层走,再继续比。这样做的结果是,原本需要一个个节点比较的 O(N) 查找,变成了近似 O(logN)。拿 100 万分数的集合来说,最坏情况下你比较几十次就能定位到目标,而不是真的跑一百万个节点。

这跟 MySQL 的 B+ 树不太一样,但思想类似:用空间换时间,用多层索引加速定位。跳跃表的好处是实现简单,不涉及树的旋转和再平衡,插入删除时的调整成本也低。Redis 的作者选择它,大概率是看中了这份简洁和可预测性。

大家关心的"跳几层"是怎么确定的?每个新节点插入时,会通过一个随机函数决定它的层高,比如有 50% 的概率多一层。这个随机性让跳跃表在宏观上保持平衡,不需要像平衡二叉树那样做严格的自平衡操作。Redis 的默认最大层数是 64,足够了。

2.2 哈希表:O(1) 找到分数的秘密武器

光有跳跃表还不够。如果只知道成员名要查它的分数,从跳跃表找也得 O(logN),对高频查询来说还是有点浪费。所以 ZSet 内部还配了一个哈希表,key 是成员名,value 是分数。

这样一来:

  • ZSCORE 查某人分数,走哈希表,O(1) 完成
  • ZADD 更新分数,先查哈希表拿到旧分数,再在跳跃表里做删除和插入,整体 O(logN)

两条路并行,各干各擅长的事。几乎所有 ZSet 的性能优势都来自这套"跳跃表 + 哈希表"的双结构设计。

2.3 Ziplist 和 Listpack:数据少时的舞台

还有一层你要知道:当 ZSet 的元素数量少、每个成员的长度也短时,Redis 其实不会立刻用跳跃表,而是用更省内存的压缩编码结构。这个结构在老版本里叫ziplist,新版本逐渐转向listpack。

当元素个数超过 zset-max-ziplist-entries(默认 128 个),或者某个成员的长度超过 zset-max-ziplist-value(默认 64 字节)时,才会从紧凑结构转换成真正的跳跃表加哈希表。

这个转换是一次性的,不可逆的。研究过源码的同学会告诉你,转过去之后如果数据量又降下来,Redis 也不会帮你转回紧凑结构。所以如果你预感到某个 key 将来可能很大,可以直接在初始化后执行一次 ZRANGE 之类让它立即转换,避免运行中转换带来的峰值延迟。

底层结构的理解价值在于:很多性能问题其实不是命令写得不对,而是编码方式切换和内存布局造成的。下面这张表帮你快速对照:

维度紧凑结构(ziplist/listpack)跳跃表 + 哈希表
触发条件元素少且成员短元素多或成员长
内存占用优相对较高
查询复杂度近似 O(N),但 N 小可忽略O(logN) 查询 + O(1) 取值
适用场景小型排行榜、小延迟队列大排行榜、全局热榜

3. 命令体系拆解:从基础增删改查到范围操作一次讲透

ZSet 的命令是整个 Redis 里最成体系的一块。我的建议是按功能分组记忆:第一组是基础增删改查,第二组是"取区间"类操作,第三组是"排名"和"分数"相关。每组的命令名都很见名知意,ZADD、ZREM、ZSCORE、ZRANGE,看到 Z 开头你就知道它属于 ZSet。

3.1 增删改查:最常用的四条指令

ZADD 是核心中的核心。语法是ZADD key score member [score member ...],一组分数和成员交替出现,可以一次加多个。

> ZADD hot_rank 100 "article_1" 88 "article_2" 75 "article_3" (integer) 3

返回 3,表示新增了 3 个成员。如果成员已经存在,ZADD 就变成更新分数:

> ZADD hot_rank 120 "article_1" (integer) 0

返回 0,代表没有新增成员,只有分数被覆盖了。这里有个小细节:ZADD 默认的更新行为是"无论新分数是涨是跌都直接覆盖"。但你可以加参数控制更新策略:

  • ZADD key NX score member:只有成员不存在时才新增,已存在则不动
  • ZADD key XX score member:只有成员已存在时才更新,不存在则不新增
  • ZADD key GT score member:新分数必须大于旧分数才更新
  • ZADD key LT score member:新分数必须小于旧分数才更新

GT 和 LT 是 6.2 版本之后加进来的,对排行榜场景非常有用。比如热度值只允许往上涨,就可以用ZADD key GT new_score member,避免因为并发乱序把分数写低了。

然后是 ZREM,删除一个或多个成员:

> ZREM hot_rank "article_3" (integer) 1

再就是 ZSCORE,查询某个成员的当前分数:

> ZSCORE hot_rank "article_1" "120"

还有 ZCARD,返回集合里的成员总数,相当于 ZSet 的 LEN:

> ZCARD hot_rank (integer) 2

这一组记住两个规律:查询用 ZSCORE / ZCARD,增改用 ZADD,删用 ZREM。没了,就这么简单。

3.2 范围查询:ZRANGE、ZREVRANGE 与 WITHSCORES

ZRANGE 是按分数区间或排名区间取元素的命令,语法有点讲究。老版本是ZRANGE key start stop,只支持按排名下标查;6.2 之后加入了BYSCORE和BYLEX选项,让这个命令一下变得更强了。

最典型的用法,查 Top 10:

> ZREVRANGE hot_rank 0 9 WITHSCORES 1) "article_1" 2) "120" 3) "article_2" 4) "88"

ZREVRANGE 是倒序版本,从高分往低分排。如果你想要升序的榜单,用 ZRANGE 就行。注意:默认下标是闭区间,也就是 0 到 9 一共会取 10 个元素。很多新手在这踩坑,以为 stop 是不包含的,结果多取了一条。

按分数区间查,用 BYSCORE:

> ZRANGE hot_rank 80 100 BYSCORE WITHSCORES 1) "article_2" 2) "88" 3) "article_1" 4) "120"

这条命令把 [80, 100] 区间内的元素都捞出来了。配合(80 (100这种括号语法还可以做开区间,比如ZRANGE hot_rank (80 +inf BYSCORE,表示 80 分以上不包含 80。

还有一组新朋友:ZRANGEBYSCORE、ZREVRANGEBYSCORE、ZRANGEBYLEX这些老命令在新版本里官方建议逐步迁移到 ZRANGE 的选项上。不是说老命令不能用了,而是 ZRANGE 一套语法能覆盖更多场景,代码也更统一。我自己实践下来,新的 ZRANGE 写法确实更直观,你写新项目时可以优先用它。

3.3 排名与计数:ZRANK 和 ZCOUNT 的边界情况

ZRANK 返回某个成员的升序排名,从 0 开始:

> ZRANK hot_rank "article_2" (integer) 1

这里 article_2 分数是 88,在 article_1(120)之下,所以排名第 1。如果你想要"第几名",记得在返回值上加 1,否则用户看到第 0 名会困惑。

ZREVRANK 是倒序排名,适合排行榜直接展示:

> ZREVRANK hot_rank "article_1" (integer) 0

ZCOUNT 按分数区间数元素个数:

> ZCOUNT hot_rank 80 120 (integer) 2

这个命令在统计"分数落在某个区间的用户数"时很好用,比如抽奖活动里按积分数段统计参与人数。

边界情况你要留意:ZRANK 或 ZSCORE 查一个不存在的成员,返回 nil,你的客户端语言里要处理空值,Go 的话是 redis.Nil,Java 的话是 null,别当普通错误处理。

3.4 进阶玩法:并集、交集与增量更新

业务里经常碰到跨多个榜单合并的场景,比如按"周榜 + 热榜"综合得出总榜。这时候用 ZUNIONSTORE 和 ZINTERSTORE。

> ZUNIONSTORE total_rank 2 week_rank hot_rank WEIGHTS 1 2 (integer) 3

这条命令的意思是对 week_rank 和 hot_rank 做并集,week_rank 的权重是 1,hot_rank 的权重是 2,结果存到 total_rank。如果同一个成员在两个集合都有分数,合并时默认是分数相加。

  • 想取最大值:AGGREGATE MAX
  • 想取最小值:AGGREGATE MIN

还有一个高频命令ZINCRBY,给某个成员的分数增加一个值。排行榜热度更新就是它的主战场:

> ZINCRBY hot_rank 5 "article_1" "125"

ZINCRBY 的原子性让它非常适合并发加热度,你不必担心多个请求同时更新导致丢失加分。底层就一个命令,Redis 是单线程执行的,天然不存在竞态。

3.5 阻塞版本:BZPOPMIN 和 BZPOPMAX 值得了解

ZSet 里还有一对不常被提到但很实用的阻塞命令BZPOPMIN / BZPOPMAX。它们的作用是从多个 key 中按分数最小(最大)弹出元素,如果所有 key 都为空则阻塞等待。

> BZPOPMIN delay_queue 5 1) "delay_queue" 2) "task_1" 3) "1700000000"

返回了 key 名、成员和分数。这个命令做延迟队列特别顺手,后面我会单独展开讲。

3.6 完整命令速查表

为了方便日常开发,我整理了一张我在项目注释里常贴的速查表,建议直接收藏:

命令作用示例
ZADD新增或更新成员分数ZADD rank 100 "u1"
ZREM删除成员ZREM rank "u1"
ZSCORE查单个成员分数ZSCORE rank "u1"
ZCARD查成员总数ZCARD rank
ZINCRBY增加成员分数ZINCRBY rank 10 "u1"
ZRANGE按下标升序取成员ZRANGE rank 0 9
ZREVRANGE按下标降序取成员ZREVRANGE rank 0 9
ZRANGEBYSCORE按分数区间取成员ZRANGE rank 80 100 BYSCORE
ZRANK / ZREVRANK查升序/降序排名ZREVRANK rank "u1"
ZCOUNT统计分数区间内成员数ZCOUNT rank 80 100
ZUNIONSTORE并集合并存储ZUNIONSTORE dest 2 a b
ZINTERSTORE交集合并存储ZINTERSTORE dest 2 a b
ZPOPMIN / ZPOPMAX弹出分数最小/最大成员ZPOPMAX rank
BZPOPMIN / BZPOPMAX阻塞版弹出BZPOPMIN key timeout
ZREMRANGEBYRANK按下标区间删除ZREMRANGEBYRANK rank 0 99
ZREMRANGEBYSCORE按分数区间删除ZREMRANGEBYSCORE rank 0 100

提示:ZREMRANGEBYRANK 这个命令很多人忽视,但它是控制 ZSet 大小、清理过期数据的利器。比如排行榜只在内存保留 Top 10000,用一条命令把下标 10000 之后的全部删掉,比循环 ZREM 高效得多。

4. 经典应用场景:排行榜、延迟队列与滑动窗口限流

命令学完,得看它在真实项目里的落地方案。下面这三个场景是我本人写过的生产代码里最典型的。

4.1 实时榜单:直播打赏周榜的完整实现

假设要做"本周主播打赏榜 Top 50"。核心逻辑:

每周一零点,删除旧 key,重建新 key ZADD weekly_rank 0 "anchor_1" 每次打赏事件触发 ZINCRBY weekly_rank 打赏金额 "anchor_1" 查询 Top 50 ZREVRANGE weekly_rank 0 49 WITHSCORES 查询自己的名次 ZREVRANK weekly_rank "anchor_1"

这套组合拳下来,实时性完全不是问题。之前我做过一个峰值 QPS 上万的打赏系统,Redis 侧这个 ZSet 的读写耗时始终在 1ms 以内。

有个细节:榜单 key 一定要按周隔离,比如weekly_rank:2024W50。这样可以到期直接删除旧 key,不用做数据迁移。否则数据越积越多,内存和查询都会跟着膨胀。

另外,ZINCRBY 的加分粒度也要设计好。如果你的热度是一个综合分(比如 阅读量0.3 + 点赞数0.5 + 评论数*1),建议你分开计分再合并。维护三个 ZSet:read_rank、like_rank、comment_rank,最后用 ZUNIONSTORE 加权合并。这样改权重只影响合并那一刻,历史数据不用重算。

4.2 延迟队列:用 score 存执行时间戳

延迟队列的本质是"到了指定时间才处理的任务"。用 ZSet 实现特别自然:score 存任务要执行的Unix 时间戳,成员存任务 ID 或任务内容。

生产者:

ZADD delay_queue 1700000000 "task_1" ZADD delay_queue 1700000100 "task_2"

消费者轮询:

ZRANGEBYSCORE delay_queue -inf 当前时间戳 LIMIT 0 10 拿到到期任务后,先 ZREM 删掉再处理

这套方案和 BZPOPMIN 的区别在于:BZPOPMIN 会立即弹出分数最小的元素,不管它到没到期;而 ZRANGEBYSCORE 配合时间戳作为 score,可以精准取出"当前时刻之前应该执行的任务"。

我当时做订单超时关闭,就用这个方案替代了定时扫描数据库。任务到期后由消费者把这个订单关掉并做后续处理。注意两个坑:

  • 消费者删任务和执行业务之间不是原子的,如果进程中途挂掉,任务就丢了。稳妥做法是先 ZREM 再执行业务,但依然存在"任务取出没执行完进程就挂了"的残留场景,需要定期扫描堆积的 ZSet 兜底。
  • 时间和任务 ID 一起放成员时可以拼接,比如 "1700000000:order_123",但成员必须是唯一且不可变的,否则去重逻辑会出问题。

4.3 滑动窗口限流:用时间戳分数统计一分钟内请求数

限流常见的实现有固定窗口计数器,但突刺问题明显。滑动窗口用 ZSet 做就很优雅:

当请求进来时:

ZREMRANGEBYSCORE rate_limit:user_123 -inf 当前时间-60秒 ZADD rate_limit:user_123 当前时间 当前时间+随机后缀 ZCARD rate_limit:user_123 // 如果超过阈值,拒绝请求

这里每个请求都是一个 member,score 是请求到达的时间戳。member 为什么带随机后缀?因为同一秒内可能有多个请求,如果 member 是纯时间戳,会互相覆盖,计数就少了。加上随机后缀(比如请求 ID)保证唯一。

这个方案的精度可以到毫秒级,比固定窗口平滑得多。缺点也很明显:每个请求都要在 ZSet 里存储一条记录,高并发下内存占用不小。所以它更适合对某个用户、某个 IP 做精细化限流,流量特别大的场景还是 LUA 脚本配合计数器更省内存。

从实现层面说,用 Redis 的 LUA 脚本把上面三步封装成原子操作,可以避免并发下的统计偏差。具体写法不展开,但核心思路就是保证"清理-新增-统计"这三步不被其他命令穿插。

5. 生产环境避坑指南:分数精度、成员不可变与五类常见问题

工具用熟了,还得知道它的脾气。下面这些坑我几乎都踩过一轮,写出来给你省点时间。

5.1 double 分数的精度陷阱

ZSet 的 score 是 double。如果你往里塞的是大额整数,比如 13286425123456789,读出后很可能变成 1.3286425123456788e+16,精度就丢了。所以:

  • 分数尽量控制在 2^53 以内(JavaScript 安全整数范围),否则直接用字符串分数也要小心
  • 排行榜如果需要精确到分、角、厘,就把金额转成整数"分"再存,宁可自己脑内乘 100,也别把浮点金额直接塞进去

我做过一个积分商城,积分动辄上亿,当时直接把 ZADD 当字符串写进去了,结果 ZSCORE 读出来差了 1 分钱,对账对了一下午。教训深刻。

5.2 成员(member)本身就是不可变的

ZSet 的成员一旦写入,不能直接改名。你要改某个成员的名称,只能 ZREM 掉旧的再 ZADD 新的。如果这个成员还关联了业务数据,那要先在业务表里做映射关联,不然改了名就找不回来了。

另外成员在整个 key 内是唯一的,重复 ZADD 相同成员只是更新分数,不会产生两条记录。这条特性既是优点也是坑:设计任务队列时如果想塞两个"内容相同但属于不同业务"的任务,记得在成员里带上业务前缀。

5.3 ZUNIONSTORE 在大 key 上的阻塞风险

ZUNIONSTORE 在数据量很大时是 O(N+M) 的复杂度,别在高峰期对一个百万成员的 ZSet 做并集操作。Redis 是单线程,这一跑可能阻塞几十毫秒甚至上百毫秒,其他所有请求都会被卡住。

规避方法:合并结果写成异步任务,或者在业务低峰期离线计算。记住一个原则:凡是涉及全量遍历的命令(ZRANGE 0 -1、ZUNIONSTORE、ZINTERSTORE),在大 key 上都要三思。

5.4 批量操作的效率与防误操作设计

单个 ZADD 一次加几百个成员时,执行效率远高于循环调用几百次。因为这样可以减少网络 RTT,Redis 内部插入时也是集中分配内存。生产代码里尽量用批量:

> ZADD rank 1 "u1" 2 "u2" 3 "u3" ... 100 "u100"

另一个点是不要用 KEYS 去匹配 ZSet 的 key,容易卡死 Redis。要批量清理前缀匹配的 ZSet,用 SCAN 命令游标式扫描,每次删一个批,配合过期时间 TTL 更稳妥。

5.5 内存优化:别让 ZSet 无限膨胀

ZSet 最大的敌人是"只增不减"。排行榜如果不做截断,三个月就能攒下几百万个僵尸成员。建议定期:

ZREMRANGEBYRANK rank 0 -N // 移除排名最后的 N 个

或者给 key 设置合理的 TTL:榜单类 key 设置周过期,任务队列类 key 根据业务保留周期设计清理策略。我在生产环境里给每个周榜 key 都加了 9 天 TTL,到期自动消失,省了一大堆运维成本。

如果你是 Redis 7.0 以上的版本,还可以尝试开启 zset 的 listpack 编码调参。把 zset-max-listpack-entries 适当调大,可以让小型 ZSet 保持紧凑结构更长时间,减少内存碎片。

6. 从命令到设计:ZSet 选择背后的思考方式

最后多说一点"设计思维"层面的东西。

我刚学 Redis 的时候,总觉得 ZSet 就是个做排行榜的,直到在项目里做技术方案评审,被问到"为什么这里不用 List 而用 ZSet"才认真想明白:选型本质是看你需要哪些操作组合。

如果你的需求是"能排序 + 按分数查 + 去重 + 高效更新",ZSet 是唯一能全部满足的结构。选型时先列你的操作清单,再对着数据类型的能力表匹配,而不是看哪个眼熟就用哪个。这比会写一百条命令重要得多。

我自己的习惯是,在项目里写工具类封装 ZSet 的常用操作,把分数转换、排名 +1 这类业务逻辑统一收口。比如封装一个 RankService,对外暴露 IncreaseScore、GetTopN、GetUserRank 三个方法,底层是 ZINCRBY 和 ZREVRANGE。这样业务方不需要记命令,也不容易用错。

同时建议你把 Redis 版本升到 6.2 以上再用新语法,否则 BYSCORE 、GT、LT 这些新特性用不了,旧命令虽然兼容但表达能力确实弱了一截。升级前记得读一下官方升级说明,ZSet 数据本身是兼容的,不会有迁移问题。

这期 ZSet 就先聊到这。下一篇该轮到 Bitmap 还是 HyperLogLog,看项目需要。如果你在实际使用中有踩到别的坑,欢迎留言交流,我这边踩过的那几个算是展示完了。

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

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

立即咨询