Redis ZSet 核心原理与实战:排行榜、延迟队列与限流

📅 发布时间:2026/10/13 19:30:27
Redis ZSet 核心原理与实战:排行榜、延迟队列与限流
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 与 WITHSCORESZRANGE 是按分数区间或排名区间取元素的命令语法有点讲究。老版本是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) 88ZREVRANGE 是倒序版本从高分往低分排。如果你想要升序的榜单用 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_1120之下所以排名第 1。如果你想要第几名记得在返回值上加 1否则用户看到第 0 名会困惑。ZREVRANK 是倒序排名适合排行榜直接展示 ZREVRANK hot_rank article_1 (integer) 0ZCOUNT 按分数区间数元素个数 ZCOUNT hot_rank 80 120 (integer) 2这个命令在统计分数落在某个区间的用户数时很好用比如抽奖活动里按积分数段统计参与人数。边界情况你要留意ZRANK 或 ZSCORE 查一个不存在的成员返回 nil你的客户端语言里要处理空值Go 的话是 redis.NilJava 的话是 null别当普通错误处理。3.4 进阶玩法并集、交集与增量更新业务里经常碰到跨多个榜单合并的场景比如按周榜 热榜综合得出总榜。这时候用 ZUNIONSTORE 和 ZINTERSTORE。 ZUNIONSTORE total_rank 2 week_rank hot_rank WEIGHTS 1 2 (integer) 3这条命令的意思是对 week_rank 和 hot_rank 做并集week_rank 的权重是 1hot_rank 的权重是 2结果存到 total_rank。如果同一个成员在两个集合都有分数合并时默认是分数相加。想取最大值AGGREGATE MAX想取最小值AGGREGATE MIN还有一个高频命令ZINCRBY给某个成员的分数增加一个值。排行榜热度更新就是它的主战场 ZINCRBY hot_rank 5 article_1 125ZINCRBY 的原子性让它非常适合并发加热度你不必担心多个请求同时更新导致丢失加分。底层就一个命令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 u1ZREM删除成员ZREM rank u1ZSCORE查单个成员分数ZSCORE rank u1ZCARD查成员总数ZCARD rankZINCRBY增加成员分数ZINCRBY rank 10 u1ZRANGE按下标升序取成员ZRANGE rank 0 9ZREVRANGE按下标降序取成员ZREVRANGE rank 0 9ZRANGEBYSCORE按分数区间取成员ZRANGE rank 80 100 BYSCOREZRANK / ZREVRANK查升序/降序排名ZREVRANK rank u1ZCOUNT统计分数区间内成员数ZCOUNT rank 80 100ZUNIONSTORE并集合并存储ZUNIONSTORE dest 2 a bZINTERSTORE交集合并存储ZINTERSTORE dest 2 a bZPOPMIN / ZPOPMAX弹出分数最小/最大成员ZPOPMAX rankBZPOPMIN / BZPOPMAX阻塞版弹出BZPOPMIN key timeoutZREMRANGEBYRANK按下标区间删除ZREMRANGEBYRANK rank 0 99ZREMRANGEBYSCORE按分数区间删除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建议你分开计分再合并。维护三个 ZSetread_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 // 如果超过阈值拒绝请求这里每个请求都是一个 memberscore 是请求到达的时间戳。member 为什么带随机后缀因为同一秒内可能有多个请求如果 member 是纯时间戳会互相覆盖计数就少了。加上随机后缀比如请求 ID保证唯一。这个方案的精度可以到毫秒级比固定窗口平滑得多。缺点也很明显每个请求都要在 ZSet 里存储一条记录高并发下内存占用不小。所以它更适合对某个用户、某个 IP 做精细化限流流量特别大的场景还是 LUA 脚本配合计数器更省内存。从实现层面说用 Redis 的 LUA 脚本把上面三步封装成原子操作可以避免并发下的统计偏差。具体写法不展开但核心思路就是保证清理-新增-统计这三步不被其他命令穿插。5. 生产环境避坑指南分数精度、成员不可变与五类常见问题工具用熟了还得知道它的脾气。下面这些坑我几乎都踩过一轮写出来给你省点时间。5.1 double 分数的精度陷阱ZSet 的 score 是 double。如果你往里塞的是大额整数比如 13286425123456789读出后很可能变成 1.3286425123456788e16精度就丢了。所以分数尽量控制在 2^53 以内JavaScript 安全整数范围否则直接用字符串分数也要小心排行榜如果需要精确到分、角、厘就把金额转成整数分再存宁可自己脑内乘 100也别把浮点金额直接塞进去我做过一个积分商城积分动辄上亿当时直接把 ZADD 当字符串写进去了结果 ZSCORE 读出来差了 1 分钱对账对了一下午。教训深刻。5.2 成员member本身就是不可变的ZSet 的成员一旦写入不能直接改名。你要改某个成员的名称只能 ZREM 掉旧的再 ZADD 新的。如果这个成员还关联了业务数据那要先在业务表里做映射关联不然改了名就找不回来了。另外成员在整个 key 内是唯一的重复 ZADD 相同成员只是更新分数不会产生两条记录。这条特性既是优点也是坑设计任务队列时如果想塞两个内容相同但属于不同业务的任务记得在成员里带上业务前缀。5.3 ZUNIONSTORE 在大 key 上的阻塞风险ZUNIONSTORE 在数据量很大时是 O(NM) 的复杂度别在高峰期对一个百万成员的 ZSet 做并集操作。Redis 是单线程这一跑可能阻塞几十毫秒甚至上百毫秒其他所有请求都会被卡住。规避方法合并结果写成异步任务或者在业务低峰期离线计算。记住一个原则凡是涉及全量遍历的命令ZRANGE 0 -1、ZUNIONSTORE、ZINTERSTORE在大 key 上都要三思。5.4 批量操作的效率与防误操作设计单个 ZADD 一次加几百个成员时执行效率远高于循环调用几百次。因为这样可以减少网络 RTTRedis 内部插入时也是集中分配内存。生产代码里尽量用批量 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看项目需要。如果你在实际使用中有踩到别的坑欢迎留言交流我这边踩过的那几个算是展示完了。