Redis ZSet深度解析:从跳跃表原理到排行榜实战
前段时间接了一个社区积分商城的项目需求方提了一句话让我印象特别深按积分从高到低展示用户积分一样的话注册时间早的排前面。刚听到这句话时我第一反应是SQL一把梭ORDER BY score DESC, create_time ASC LIMIT 20不就完事了但问题在于用户量几十万排行页又是高频接口每次全表排序根本扛不住。后来我把排行榜数据全部挪到了 Redis 的 ZSet 里用ZREVRANGE直接取 TopN个人名次用ZREVRANK一次查询搞定接口从平均 400ms 直接降到个位数毫秒。这篇就来把 ZSet有序集合从使用、原理到实战、踩坑一次讲透。1. ZSet到底解决了什么问题为什么不能只用List或Set1.1 List是有序的但它的有序不是你要的那种有序很多刚接触 Redis 的同学容易把 List 和 ZSet 搞混因为乍一看两个都能排队。List 的有序是指插入顺序你用LPUSH往左边塞用RPUSH往右边塞取出来的顺序永远是你塞进去的顺序。想实现排行榜只能先把所有元素LRANGE拉到客户端在应用内存里排序再取前 N 个。数据量小的时候没问题一旦元素达到几万、几十万每次查询都要全量传输和排序Redis 变成了一个纯存储中间件性能优势全丢了。List 还有一个硬伤它不去重。同一个用户因为操作频繁可能插入多条记录你得自己在业务层做去重。这意味着每次写入前还要额外查一次是否已存在逻辑绕来绕去并发下还容易出重复数据。1.2 Set能去重但去重之后就裸奔了Set 把去重这个问题解决了成员唯一性由 Redis 保证。可它没有分数概念也就没有天然的排序依据。假如你用 Set 存用户ID想按积分排序你依然得在外部维护一张积分表然后去查每个用户的积分再排序复杂度直接 O(N²)。有人会说我用 Hash 存积分member 是用户IDfield 是积分再遍历排序行不行行但本质还是全量数据拉到客户端排序和 List 方案没有区别。而且 Hash 在数据量大时遍历的HGETALL本身就是一个阻塞风险。1.3 ZSet把集合和排序揉在了一起ZSet 的每个成员member都绑定了一个score双精度浮点数。Redis 内部以 score 为排序键将成员按分数从小到大组织起来。这带来的直接效果是去重member 唯一天然去重自动排序每次写入时Redis 就顺手把顺序维护好了查询时无需额外排序范围查询按分数区间取成员按排名区间取成员复杂度都很低从使用者的角度理解ZSet 就是Set Sorted的合体。它既能像 Set 一样保证成员唯一又能像有序链表一样按分数快速定位。你不需要自己维护任何索引结构一个 key 就搞定了。1.4 什么时候应该选ZSet我总结了四个典型场景只要你符合其中之一就可以优先考虑 ZSet排行榜积分榜、热榜、商品销量榜几乎就是为 ZSet 量身定做的延迟队列score 存的是任务执行的时间戳消费者按时间取任务滑动窗口限流score 存请求时间戳配合ZREMRANGEBYSCORE清理窗口外的数据带权重的关系映射比如好友亲密度排序、商品热度排序本质都是成员 权重2. 底层实现拆解压缩列表和跳跃表在背后怎么一起干活2.1 数据量小的时候ZSet其实是一块连续内存很多文章讲 ZSet 就直接说底层是跳跃表这句话只对了一半。Redis 的优化思路从来都是用最少的内存办最多的事所以 ZSet 在元素数量少、成员体积小的情况下用的是一种叫ziplist压缩列表的紧凑存储结构。你可以把 ziplist 想象成一块连续的内存区域里塞了一串小格子每个格子里依次存放member和score两个两个地排下去。因为是连续内存CPU 缓存命中率极高而且不需要额外的指针开销整体内存占用非常低。代价是插入和删除需要移动后面的元素如果数据量大复制成本会迅速失控。ZSet 使用 ziplist 的条件有两个必须同时满足元素数量小于zset-max-ziplist-entries默认128每个 member 的长度小于zset-max-ziplist-value默认64字节2.2 数据量上去之后跳跃表才是主角一旦元素数量超过 128 个或者有 member 长度超过 64 字节Redis 就会把 ZSet 转换为skiplist hash table的组合结构。注意这里是或任一条件触发就会转换。skiplist跳跃表负责按 score 排序和范围查询dict哈希表负责按 member 精确查找对应的 score。两个结构配合实现了 ZSet 各种操作的低复杂度操作时间复杂度靠什么实现ZSCOREO(1)dict 直接拿 member 的 scoreZADDO(logN)跳表插入新节点ZRANKO(logN)跳表按分数定位成员ZRANGEO(logN M)跳表定位起点再沿链表遍历 M 个ZREMRANGEBYSCOREO(logN M)跳表按分数区间删除为什么加一个 dict因为跳表按 member 查找是很慢的必须从头遍历。而我们的业务场景里经常要通过 member 查分数、改分数dict 就承担了这个精确查找的职责。这就是空间换时间的经典选择。2.3 编码转换是单向的别指望它转回去编码转换的方向是ziplist - skiplist不可逆。哪怕你后面把元素删到只剩几个它也不会自动转回 ziplist。这一点在生产环境特别重要如果你预判某个 ZSet 将来会超过 128 个元素那就不要指望先用几天小数据后面自动优化。一个真实的教训我以前维护过一个活动排行 key活动前期数据量小ziplist 编码时内存占用也就几个 KB。活动爆量后转换成了 skiplist内存直接翻了几倍。这本身不奇怪但如果你在 Redis 内存紧张的环境下运行这种突然膨胀有可能触发内存淘汰导致其他 key 被意外逐出。所以给重要 ZSet key 预留内存水位是必要的。2.4 Redis 7.0 之后ziplist 换成了 listpackRedis 7.0 做了一次底层升级ZSet 的小数据编码从 ziplist 换成了listpack紧凑列表。listpack 在内存布局上跟 ziplist 类似但解决了 ziplist 的级联更新问题——ziplist 在插入元素时如果某个节点变大后面所有节点的长度字段都要连锁更新最坏情况是 O(N²) 的复杂度。listpack 通过更巧妙的设计限制了这种级联更新的传播范围。对应的配置项也改名了zset-max-listpack-entries、zset-max-listpack-value默认值和之前保持一致128 / 64。如果你还在用 Redis 6.x面试或者架构方案里被问到可以和面试官提一嘴这个版本差异很容易加分。3. 核心命令完全拆解每次操作背后发生了什么3.1 ZADD写入时它到底在做什么ZADD key score member [score member ...]这条命令是最常用的写入入口。它的核心逻辑是member 不存在直接在跳表里插入一个新节点member 已存在更新它的 score并调整它在跳表中的位置一个容易被忽略的细节ZADD的返回值默认是新插入的成员数量。如果你用ZADD更新一个已存在成员的分数返回值是 0。这在批量写入的场景里很容易让你误判结果。想要拿到实际变更数可以加CH参数ZADD ranking 100 zhangsan ZADD ranking 200 zhangsan (integer) 0 ZADD ranking 200 zhangsan CH (integer) 1还有XX仅更新已存在、NX仅插入新成员、GT/LT仅当新分数大于/小于旧分数时才更新这几个参数具体场景很实用。比如防刷积分时你希望积分只增不减可以直接ZADD ranking GT。ZINCRBY是另一个写入派系的命令ZINCRBY key increment member。它的效果是对已有成员做原子加分不存在则先初始化再从 0 加。排行榜实时积分变更我强烈建议用 ZINCRBY 而不是先 ZSCORE 再 ZADD。因为 ZINCRBY 是一个原子操作在高并发下不会出现读旧值-加值-写回的并发竞争问题。3.2 排行榜查询标配ZREVRANGE 与 ZRANGEZSet 默认按分数从小到大排列所以ZRANGE key start stop [WITHSCORES]从第 start 名取到第 stop 名升序ZREVRANGE key start stop [WITHSCORES]从第 start 名取到第 stop 名降序排行榜场景通常要分数越高排越前所以用的是ZREVRANGE。注意 start 和 stop 都是从 0 开始的下标这个下标不是分数而是排名位置。ZREVRANGE ranking 0 9就是取第 1 名到第 10 名。你还可以给 stop 传-1表示一直取到末尾。加WITHSCORES参数后返回结果会变成 member1 score1 member2 score2 这样成对的数据解析时注意按两个一组处理。3.3 Java代码示例完整实现TopN查询我用 Spring Boot Lettuce 写过一段非常典型的查询代码贴出来给你参考// 取排行榜前10名 ListString results redisTemplate.opsForZSet() .reverseRangeWithScores(ranking:20240101, 0, 9); for (ZSetOperations.TypedTupleString tuple : results) { String member tuple.getValue(); // 用户ID Double score tuple.getScore(); // 积分 System.out.println(用户 member 积分 score); } // 获取用户当前排名 Long rank redisTemplate.opsForZSet().reverseRank(ranking:20240101, u12345); // rank从0开始展示时需要1 System.out.println(当前排名 (rank 1));这段代码看着简单但有两个点容易踩坑reverseRangeWithScores返回的集合是有序的但如果你用HashMap去接就会丢掉顺序必须用List或LinkedHashMapreverseRank返回 null 表示成员不在集合里代码里要做空指针处理3.4 拿到第几名ZRANK与ZREVRANK的索引陷阱ZRANK key member返回的是该成员在升序排列中的索引ZREVRANK则是在降序排列中的索引。两者的共同陷阱是返回的索引从 0 开始。比如分数最高的用户ZREVRANK返回 0。如果你直接在页面上展示第 0 名那肯定不对业务上需要 1才是我们习惯的第 1 名。另外Redis 6.2 之后ZRANK支持了WITHSCORE参数可以一次性拿到排名和分数少一次网络往返ZRANK ranking u12345 WITHSCORE 1) (integer) 125 2) 3563.5 分数查询ZSCORE是O(1)的别再用HGET替代了ZSCORE key member直接返回成员的分数。因为 ZSet 内部有 dict 做精确索引这个操作的时间复杂度是O(1)非常快。同时返回的是浮点数在命令行里看到带.0后缀比如356.0是正常的。批量要取多个成员的分数用ZMSCORE key member [member ...]一条命令搞定避免在循环里反复发请求。这个命令在 Redis 6.2 版本才引入如果你还在用老版本只能自己用 Pipeline 或者循环调用 ZSCORE。3.6 删除操作的三种姿势ZREM key member [member ...]按成员删除最常用的方式ZREMRANGEBYRANK key start stop按排名区间删除比如清掉排名 100 名之后的所有数据ZREMRANGEBYSCORE key min max按分数区间删除延迟队列消费后的清理就靠它延迟队列用ZREMRANGEBYSCORE key -inf now可以一次性把当前时间之前所有的任务删除-inf表示负无穷这个写法很实用。4. 面试高频为什么ZSet用跳表不用红黑树4.1 跳表和红黑树在复杂度上其实打了个平手这是 Redis 面试题里出现频率极高的一道题。首先需要明确跳表skiplist和红黑树在插入、删除、查找单个元素上时间复杂度都是O(logN)理论上并没有显著差距。那为什么 Redis 作者偏偏选了跳表关键在范围查询range query上。ZSet 最核心的卖点就是按分数区间取成员比如取排名前 100 的用户。跳表因为底层是有序链表做范围查询时只需要先找到起点然后沿链表指针往后遍历 N 个节点就行非常自然。红黑树虽然也能做范围查询但需要用到中序遍历还要维护前驱后继指针实现复杂度明显更高。4.2 作者的取舍工程上的简单性胜过理论上的最优Redis 的作者 antirez 在源码注释里表达过类似观点跳表的实现足够简单、容易调试而且内存占用可以灵活控制每个节点的层数是随机生成的期望层数低内存开销可控红黑树虽然极限性能可能更优但代码实现和调试难度不是一个量级尤其是在需要频繁插入删除的场景下一旦出现 bug排查成本会非常高。我做技术选型时也会遵循同样的逻辑在没有数量级差距的情况下优先选实现简单、清晰、出问题容易排查的方案。跳表就是一个理论不差工程更好的选择。4.3 那为什么不选B树B树更常用于磁盘数据库比如 MySQL 的 InnoDB它的设计目标是减少磁盘 IO通过一个节点存储多个 key 来降低树的高度。但 Redis 的数据在内存里磁盘 IO 不是瓶颈也就没必要付出 B 树节点分裂合并这些额外的维护成本。这个对比在面试时主动提到会比只说跳表简单显得更有层次。5. 实战用ZSet从零搭一个完整的排行榜服务5.1 需求拆解先把规则定清楚以积分排行榜为例完整需求一般是这样的用户每日积分动态变化页面展示总榜 Top 20支持分页查看更多名次展示当前用户的个人排名积分相同时注册时间早的用户排前面这里最棘手的就是积分相同按注册时间排这个规则。ZSet 的排序规则是先按 score 排score 相同再按 member 的字典序排。也就是说如果你直接把用户ID当 member那积分相同时u10000 会排在 u9999 前面因为字典序 1 9。这显然不符合业务预期。5.2 合成分数把多个排序维度揉进一个score常规解法是合成分数。把积分放大到整数部分把注册时间或者某个业务相关的小数塞进小数部分。比如积分最大不超过 1 亿8 位数字那可以用合成分数 积分 * 1e8 (基准值 - 注册时间戳)这里减去注册时间戳是为了让注册越早的分数越大。如果你简单地把注册时间戳塞进去效果会是注册越晚的排越前面方向正好反了。为了处理这个我用一个足够大的基准值比如9999999999减去注册时间戳。还要注意一个硬性边界double 类型只能精确表示 2^53 以内的整数。如果积分 * 1e8 太大超过 2^53约 900 万亿就会出现精度丢失两个不同分数可能被解析成同一个值。所以合成分数方案一定要预估好业务上限留足余量。积分通常不会超过百万级乘以 1e8 后是 1e14仍在安全范围内。5.3 更新积分的原子操作积分变化时不建议先查再写直接用ZINCRBY原子自增// 用户获得100积分 Double currentScore redisTemplate.opsForZSet() .incrementScore(ranking:20240101, u12345, 100);incrementScore返回的是更新后的最新分数。这一步要注意如果你的合成分数方案已经应用到排行榜的 ZSet 上那这里加分也应该加在合成分数上而不是原始积分上。实际项目里我会同时维护两个 keyranking:raw:20240101存原始积分key 里的 value 是真实积分方便后续业务取用ranking:20240101存合成分数专门服务榜单查询两个 key 用同一个 member更新时先用ZINCRBY更新原始积分再重新计算合成分数后用ZADD更新榜单 key。这里会有短暂的不一致窗口但榜单场景完全可以接受。如果你要求强一致可以用 Lua 脚本把两步操作原子化代码也不复杂。5.4 分页查询和我的排名Top 20 直接ZREVRANGE ranking 0 19 WITHSCORES。分页只需计算好 start 和 stopint page 2; // 第2页 int pageSize 20; // 每页20条 long start (page - 1L) * pageSize; long end start pageSize - 1; ListString topUsers redisTemplate.opsForZSet() .reverseRangeWithScores(ranking:20240101, start, end);我的排名用ZREVRANKLong myRank redisTemplate.opsForZSet() .reverseRank(ranking:20240101, u12345); if (myRank ! null) { // 从0开始的索引加1就是展示用的排名 return myRank 1; }这里还要注意一个性能细节如果 ZSet 非常大几十万成员ZREVRANK依然很快因为内部结构保证了这个操作 O(logN)。但如果你是从榜外查一个不存在的 memberZREVRANK返回 null这个判断逻辑也要写在代码里别直接做 1 运算。5.5 key过期策略别让你的排行榜变成僵尸key积分排行榜大多是按天/按周维度滚动所以 key 的过期时间一定要设好EXPIRE ranking:20240101 86400 * 30这样 30 天后这个 key 自动消失避免每天一个排行榜 key 堆积在内存里。如果你要保留历史榜单做数据分析那就另开一套归档逻辑把 Redis 里的数据定期导出到冷存储。6. ZSet的另类用法与生产踩坑6.1 延迟队列只用两个命令就能实现用 ZSet 做延迟队列的核心思路是member 存任务IDscore 存任务执行的时间戳。消费者轮询时取出所有 score 小于等于当前时间戳的任务执行ZRANGEBYSCORE task_queue -inf now LIMIT 0 10执行完成后再ZREM删掉任务。为什么不直接用 List 做延迟队列因为 List 无法按时间范围取出到期的任务你必须自己遍历并判断每条任务是否到期效率很低。ZSet 的ZRANGEBYSCORE可以直接按时间范围筛选一次 query 就拿到所有到期任务。这个方案有几个要特别注意的点过期任务积压消费者挂了或者任务执行时间较长会导致 ZSet 里堆积大量未处理任务。要有一套监控机制定期检查队列长度是否超过阈值重复消费多个消费者同时ZRANGEBYSCORE会各自取到同一批任务任务处理前没有状态标记可能导致重复执行。最简单的办法是先ZREM再执行如果你需要保证至少执行一次就要引入额外的执行状态表原子性问题ZRANGEBYSCORE和ZREM不是原子的。用 Lua 脚本把这两步合并可以避免多个消费者取到同一个任务后竞争6.2 滑动窗口限流ZSet 做限流的正确姿势用 ZSet 实现滑动窗口限流的逻辑不复杂ZREMRANGEBYSCORE user:req:u12345 -inf (now - 60s) ZCARD user:req:u12345先用ZREMRANGEBYSCORE删除窗口之外的所有请求记录也就是 60 秒以前的然后ZCARD统计窗口内请求数。如果超过阈值比如 100就拒绝请求否则ZADD user:req:u12345 now now记录一次请求。这个方案比固定窗口限流例如利用INCREXPIRE更精确因为它能处理第 59 秒到第 61 秒跨越两个窗口这种边界情况。代价是每个请求都要把整个窗口内的请求记录扫一遍当然有ZREMRANGEBYSCORE的帮助老数据会被清理内存开销相对固定窗口大一些适合对限流精度要求比较高的接口。6.3 踩坑一double精度问题是真的坑这是 ZSet 最经典的一个坑。score 是 C 语言的 double 类型如果你在业务里用元为单位存储金额小数点后超过 2 位很容易出现这种诡异现象zadd account 0.1 u1 zadd account 0.2 u2 zadd account 0.3 u3 zscore account u1 0.10000000000000001解决方案有几种整数存储把金额乘以 100 或 10000 转成整数这是最推荐的做法字符串存储分数计算时自己在业务层处理但这样 ZSet 就失去了自动排序的意义不要用分数做相等判断如果需要精确比较用 ZSCORE 取回后在业务层做精度处理6.4 踩坑二大key问题是排行榜的隐形杀手排行榜这类的 ZSet key如果用户量非常大一个 key 可能包含几十万甚至上百万个 element。在这个 key 上做ZRANGE取全部数据或者ZREVRANGEBYSCORE取一个很大的区间Redis 是单线程模型这个命令执行期间所有其他请求都会被阻塞。生产环境里我建议对大型 ZSet 做以下防护设置合理的分页大小禁止一次性取出全量数据对超大 key 做监控ZSet 总字节数超过阈值就告警如果排行榜必须全量查询考虑按业务维度拆成多个小 key比如按用户ID哈希分片6.5 踩坑三member字段别设计得过长ZSet 的 member 是直接以字符串形式存储的如果 member 是一个很长的字符串比如完整的 JSON 或者超长ID拼串内存占用会非常大。更严重的是ZSet 底层跳表在比较 member 时是按字节比较的member 越长比较的开销越大。所以member 尽量短用纯数字ID或者短哈希值别图省事把整个对象塞进 member 里。6.6 踩坑四多个维度的排序ZSet一次只能干一件事一个 ZSet 只能按一个 score 排序。如果业务需求是先按热度排再按时间排或者既能按销量排又能按价格排一个 ZSet 是搞不定的。这时候要么维护多个 key要么引入其他方案。我以前做过一个商品列表页需要按销量/价格/上架时间三个维度排序最终方案是建了三个 ZSet key每个 key 的 member 是商品IDscore 分别是销量、价格、上架时间戳。写入时三个 key 同步更新查询时按对应维度查对应 key。代价是写入操作从一次变成三次但查询效率非常高。如果你的业务对写入性能极度敏感就要权衡这个方案的可行性。7. 性能评估与优化建议从命令复杂度到内存规划7.1 命令复杂度速查表命令时间复杂度备注ZADDO(M logN)M 是新添加的成员数量ZSCOREO(1)依赖 dict 索引ZRANK / ZREVRANKO(logN)定位成员排名ZRANGE / ZREVRANGEO(logN M)M 是返回的成员数量ZRANGEBYSCOREO(logN M)按分数区间查询ZREMO(M logN)M 是删除的成员数量ZCARDO(1)直接取长度ZINCRBYO(logN)更新分数并位移设计方案时尽量把核心操作控制在 O(logN) 量级。避免ZRANGE key 0 -1这种全量取出不只是网络传输问题更是为了避免 Redis 主线程长时间执行导致其他命令排队。7.2 内存规划一个ZSet到底占多大内存这个不好一概而论但可以给一个粗略估算思路。skiplist 编码下每个节点除了保存 member 和 score还需要保存前向/后向指针以及层高信息随机 1~32 层。member 越短、元素越少ZSet 里 dict 的占比就越明显。整体上一个 100 万元素的 ZSetkey 里面成员是短ID的话内存大约在 100MB 到 200MB 这个量级。我在实际项目里用过一个更简单的方法直接用MEMORY USAGE key命令查看一个 ZSet 当前占用多少字节。这是最准确的评估方式建议上线前用真实数据估算一轮。MEMORY USAGE ranking:20240101 (integer) 157382407.3 淘汰策略与持久化对ZSet的影响Redis 的内存淘汰策略比如allkeys-lru、volatile-lru对 ZSet 同样生效。如果一个 ZSet key 被淘汰了整个排行榜数据就全没了这个代价在业务上可能是致命的。所以关键榜单个 key 千万别设置过期时间或者设置很长的过期时间同时建议给这些 key 单独命名前缀在监控系统里加白名单。持久化方面RDB 和 AOF 都会完整保存 ZSet 数据。如果你的排行榜很大比如 50 万成员每次 RDB 快照都全量写入一次次数多了可能会触发 fork 阶段的内存抖动。AOF 重写时也会消耗额外内存建议在低峰期执行手动重写或者把自动触发阈值调高。7.4 多实例部署时的注意事项在 Redis Cluster 集群模式里ZSet 的 key 会根据 hash slot 分布在不同的节点上。如果你的 ZSet 操作要跨节点比如对一个 key 做ZINTERSTORE或ZUNIONSTORE就有问题了——ZUNIONSTORE只能在所有参与 key 都位于同一个 slot 时才能工作。这要求设计时用{tag}哈希标签把相关 key 落在同一个 slot比如{hot:ranking}20240101。ZUNIONSTORE result 2 {hot:ranking}20240101 {hot:click}20240101没有加哈希标签的话这条命令会直接报CROSSSLOT错误。这个坑我在集群迁移时踩过一次排查了半小时才反应过来。8. 命令使用中几个容易混淆的细节8.1 ZRANGEBYSCORE 与 ZRANGE 的界限差异ZRANGE key start stop的 start/stop 是排名下标而ZRANGEBYSCORE key min max的 min/max 是分数值。两者经常被搞混尤其当你的排行榜是按分数区间去查时很容易写成ZRANGE key 100 200这会把它理解为取第 100 名到第 200 名而不是取分数 100 到 200 的成员。正确写法是ZRANGEBYSCORE key 100 200分数区间还支持开闭区间(100表示不包含 100200表示包含 200-inf表示负无穷inf表示正无穷。8.2 Redis 6.2 之后的新语法改变了使用习惯Redis 6.2 为ZRANGE增加了一组新选项让它直接替代了ZRANGEBYSCORE、ZREVRANGEBYSCORE、ZRANGEBYLEX这几个老命令ZRANGE key 100 200 BYSCORE ZRANGE key 200 100 BYSCORE REV ZRANGE key 0 -1 BYLEX新语法统一了范围查询的入口代码维护起来更清晰。对你来说如果 Redis 版本支持建议新项目直接使用新语法但如果项目里还有大量老命令代码也不必急着改老命令在 Redis 6.2 里依然兼容。8.3 LEX操作字典序排名的使用前提ZRANGEBYLEX是按 member 的字典序做范围查询它有一个硬性前提所有成员的 score 必须相同。因为当分数都一致时ZSet 的排序就会退回字典序此时按字典序做范围查询才有意义。我见过有人拿ZRANGEBYLEX做模糊搜索最后因为成员分数不一致结果完全错乱。日常开发中用得不多但知道这个限制能避免踩坑。8.4 ZINTERSTORE 与 ZUNIONSTORE多集合运算的权重问题ZINTERSTORE交集和ZUNIONSTORE并集可以对多个 ZSet 做集合运算生成一个新的 ZSet。常见场景是综合热度 0.4 * 点击分 0.6 * 评论分的加权排行。这个功能很强但要注意结果 key 是全新生成的不会自动更新业务侧要维护同步逻辑参与运算的 key 数量越多、体积越大CPU 开销越高集群模式下所有 key 必须在同一个 hash slotZUNIONSTORE hot:total 2 hot:click hot:comment WEIGHTS 0.4 0.6关于ZSet我的几条实操体会做完这个排行榜项目又回过头把 ZSet 的很多细节研究了了一遍我最想跟你分享的几条体会是第一所有排行榜需求先别急着写 SQL。只要数据量超过万这个量级Redis ZSet 基本就是最优解省掉 SQL 排序省掉索引维护一条命令解决。第二ZSet 坑都不在命令上而在业务建模上。score 精度、member 长度、key 拆分、编码转换这些才是真正容易出问题的地方。你把业务数据怎么映射成 score、怎么设计 member想清楚了后面写代码就顺了。第三别迷信任何底层结构要尊重版本变化。Redis 6.2 改命令语法、7.0 换 listpack这些变化都是性能和易用性上的迭代。多读官方 changelog多在你的目标版本上做验证比背别人的总结可靠得多。第四如果你准备面试ZSet 一定要把跳表为什么优于红黑树/ B 树讲透如果你做开发ZSet 一定要把合成分数怎么处理多个排序维度想明白。这两个问题基本就是 ZSet 考察的全部重点了。