排行榜、热搜榜、积分榜、延时任务、滑动窗口限流,这些需求单拎出来都不复杂,但只要落到存储层,十个里有八个最后会归到同一个结构上——Redis 的ZSet(有序集合)。它同时干了四件事:去重、计分、排序、区间查询。你不用在应用层再维护一套排序逻辑,也不用把全量数据捞出来在内存里sort一遍。这篇文章聊的就是Redis ZSet 命令这一套东西:每个命令的参数到底怎么组合、开闭区间怎么写、为什么ZRANGEBYLEX有时候返回空、ZADD的GT和NX为什么不能一起用、排行榜同分怎么排、延时队列怎么保证不丢任务。内容偏实战,适合已经能敲SET/GET但被 ZSet 一堆参数劝退的同学,也适合天天用ZRANGE但没细看过底层编码的老手对照着查漏补缺。命令以 Redis 6.2 之后的语法为准,涉及 7.x 新增的会单独标出来。
1. 排行榜类需求为什么最后都落在 ZSet 上
1.1 从“去重加排序”这两个朴素需求说起
先别急着看命令。想清楚一件事:一个典型的榜单需要满足哪些约束?第一,同一个对象只能出现一次,用户 A 不能因为投了两次票就在榜上占两个位置;第二,分数会变,今天 100 分明天 120 分,改完分位置要跟着动;第三,要能快速取前 N,首页只展示前 50,不能每次都全量排序;第四,要能查某个对象的名次,“你当前排第 137 名”这种话得说得出来;第五,偶尔还要按分数段查,比如“60 分到 80 分之间的所有用户”。
把这五条约束摆出来,选型基本就没悬念了。用 List 存,去重得自己扫一遍,改分要先LREM再LPUSH,位置全乱;用 Hash 存,HGET拿分很快,但要排名就得把整个 Hash 拉到应用层排序,数据量一上去直接内存爆炸;用 Set,去重没问题,可惜没有顺序,SMEMBERS出来的顺序是不可预期的;直接怼 MySQL,ORDER BY score DESC LIMIT 50加个联合索引确实能跑,但榜单这种东西是读多写多、要求毫秒级响应的,数据库连接池经不起这么造。
ZSet 的模型刚好对上:每个元素是member + score的二元组,member 唯一,score 用来排序。写入ZADD是O(log N),按排名取区间是O(log N + M),查单个成员名次是O(log N),取成员分数是O(1)。一套结构把上面五条约束全覆盖了,这就是它在榜单场景里几乎没有替代品的原因。
1.2 ZSet 在 Redis 数据结构家族里的位置
把 Redis 的几大结构横向摆一下,能更清楚 ZSet 的定位。String 是万能的字节容器,能干计数也能干缓存,但它本身不带集合语义;List 是有序的,可顺序来自插入位置,元素可以重复,按索引随机访问的代价随长度线性上升;Hash 适合存对象字段,字段无序;Set 无序但去重;Bitmap 和 HyperLogLog 是省内存的专用结构,一个管布尔位一个管基数估算;Stream 是消息流,带消费组。
ZSet 是这里面**唯一同时具备“唯一性”和“可排序性”**的。而且它的排序维度不是插入顺序,是你可以随便改的 score,这一点非常关键——意味着“热度”“权重”“优先级”“到期时间戳”这些业务概念都能直接映射成 score。顺带说一句,Redis 的 GEO 系列命令(GEOADD、GEOSEARCH)底层就是把经纬度编码成 geohash 塞进 score,存的是一个 ZSet,所以你对 GEO 数据的很多理解可以直接迁移过来。
1.3 底层编码:小对象走压缩,大对象才上跳表
ZSet 有两种内部编码,这个知识点直接影响你的内存账单。元素数量少、成员短的时候,它用的是紧凑的连续内存结构——Redis 7.0 之前是ziplist,7.0 起换成了listpack,本质都是把 member 和 score 挨着排在一块连续内存里,省掉了指针开销。触发切换的阈值由两个配置控制:
# Redis 7.x 的默认值 zset-max-listpack-entries 128 zset-max-listpack-value 64只要元素个数超过 128,或者任意一个 member 的长度超过 64 字节,整个 ZSet 就会转换成skiplist + dict的组合结构。注意是“整个”,不是“超出的那部分”。skiplist 负责维护按 score 排序的多层链表,支撑范围查询和排名;dict 是 member 到 score 的哈希表,支撑ZSCORE的O(1)查找。两者共享同一份 member 和 score 对象,不会重复存储。
为什么是跳表而不是红黑树或者 B 树?核心原因有三个。范围查询天然友好,跳表底层就是一条有序链表,找到起点之后顺着指针往后走就行,平衡树做范围遍历得靠中序后继,代码复杂得多;实现简单,插入删除只动局部指针,不需要旋转和再平衡;并发和内存布局上更好控制,节点可以按层分配。代价是跳表的常数因子比平衡树略大,指针多、内存占用高一些,但对 Redis 这种“范围查询远比纯查找多”的场景,这笔账划得来。
注意:编码转换是单向的。一个 ZSet 从 listpack 变成 skiplist 之后,就算你把元素删到只剩 3 个,它也不会退回去。线上常见的坑是:某天一个 member 传了个超长 JSON 进来(超过 64 字节),整个 ZSet 直接从 listpack 升级成 skiplist,内存占用翻好几倍,而且永远回不去。member 命名一定要克制。
2. 写入侧:ZADD 的修饰符比你以为的多
2.1 基础语法与批量写入
ZADD是 ZSet 的入口命令,最朴素的形态是三个参数:
ZADD leaderboard 100 user:1 ZADD leaderboard 88 user:2 95 user:3 120 user:4第二条是批量写法,一次传多组score member。这一点在实际项目里很重要:批量写入比循环单条写快得多,因为省掉了每条命令的网络往返和命令解析开销。一个 1000 人的榜单,循环调用 1000 次ZADD和一次传 1000 组参数,耗时差距可能在一个数量级以上。
语法完整形式是:
ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member ...]括号里的都是可选的修饰符,位置固定、顺序固定,写在 key 之后、第一组 score 之前。下面逐个拆开。
2.2 NX、XX、GT、LT、CH、INCR 到底怎么组合
NX的意思是“只在成员不存在时添加”,已存在就跳过、不更新分数。典型用途是“报名”,先到先得,重复提交不覆盖。XX反过来,“只在成员存在时更新”,不存在就不加。典型用途是“只给已在榜的用户改分”,防止脏数据凭空插入新成员。
GT表示“只有新分数大于当前分数才更新”,LT表示“只有新分数小于当前分数才更新”。这两个修饰符有个容易被误解的地方:它们只约束“更新”行为,不阻止新成员的插入。也就是说ZADD key GT 10 newmember在 newmember 不存在时照样会插进去,因为插入场景下没有“当前分数”可供比较。想连插入一起拦住,得配合XX:ZADD key XX GT 10 member。
组合规则有几条硬限制,违反了 Redis 会直接报错:NX和XX互斥;GT和LT互斥;NX和GT/LT也互斥,因为 NX 根本不更新,比较大小没有意义。CH和INCR是另外两个维度,可以叠在上面任意合法组合上。
CH改的是返回值含义。不加 CH 时,ZADD返回的是新增元素的个数,被更新的老成员不计入。加了 CH 之后,返回发生变化的元素总数,新增和分数变化都算。做“本次更新影响了多少条数据”这种统计,必须带 CH。
INCR把命令变成自增语义,语法退化成一个成员:ZADD key INCR 5 member,返回的是自增后的新分数。如果配了NX而成员已存在,返回 nil;配了XX而成员不存在,也返回 nil。它和ZINCRBY功能重合,区别只在于能叠加GT/LT做“只增不减”之类的条件控制。
| 修饰符 | 作用 | 与谁互斥 | 典型场景 |
|---|---|---|---|
| NX | 仅新增,不更新 | XX、GT、LT | 报名、去重入榜 |
| XX | 仅更新,不新增 | NX | 只改已存在成员的分数 |
| GT | 分数变大才更新 | LT、NX | 只保留历史最高分 |
| LT | 分数变小才更新 | GT、NX | 维护最小堆式的优先队列 |
| CH | 返回值计入变更数 | 无 | 统计实际影响条数 |
| INCR | 自增模式,单成员 | 无(不能批量) | 带条件的计分 |
2.3 返回值含义的实战陷阱
ZADD的返回值有三种形态,不搞清楚很容易写出错误逻辑。不带 CH 不带 INCR 时,返回新增个数,可能是 0——这时候不代表命令没生效,可能只是分数被更新了而已。带 CH 时返回新增加变更的总数。带 INCR 时返回新分数(字符串形式的 double),或者在条件不满足时返回 nil。
踩过的坑是这样的:某次做“给用户加分”的逻辑,用ZADD key GT 5 member做幂等处理,然后拿返回值判断“是否加分成功”,结果发现老用户分数涨了但返回值是 0,白名单逻辑全错。正确做法要么加CH,要么干脆用ZINCRBY并检查返回的新分数。返回值这件事,一定要在动手前想清楚“我要判断的到底是写入成功还是数值变化”。
3. 读取侧:按排名、按分值、按字典序三条路
3.1 按排名取:ZRANGE 与 ZREVRANGE
ZRANGE key start stop按排名区间取元素,索引从 0 开始,支持负数(-1 表示最后一个)。分数从小到大排,所以ZRANGE key 0 9取的是分数最低的 10 个,ZRANGE key -10 -1取的是分数最高的 10 个。想要“分数从高到低的排行榜”,用ZREVRANGE key 0 9更直观。
配WITHSCORES可以把分数一起带回来,返回结果是member1, score1, member2, score2...的扁平数组,不是嵌套结构,客户端解析的时候要按两个一组切。这一点经常有人搞错,拿到数组后直接当成员列表用,结果一半是分数。
ZRANK key member返回成员的升序排名,ZREVRANK返回降序排名。名次的含义是“前面有多少个元素”,所以从 0 开始计数,翻译成“第几名”的时候记得加 1。成员不存在时返回 nil,别把它当成 0 处理。Redis 7.2 起这两个命令支持WITHSCORE参数,一次拿到名次和分数,省掉一次ZSCORE往返。
注意:
ZRANGE key 0 -1会返回整个集合。几万个元素的 ZSet 上执行这条命令,返回的数据量加上序列化开销,足以让 Redis 单线程卡住几十毫秒,影响所有其他请求。大集合分页,或者换ZSCAN。
3.2 按分值取:ZRANGEBYSCORE 的开闭区间写法
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]按分数范围取,这才是榜单里“取 60 到 80 分之间的人”的正解。区间端点默认是闭区间,想开区间就在数字前面加一个左括号:
ZRANGEBYSCORE board 60 80 # 60 <= score <= 80 ZRANGEBYSCORE board (60 80 # 60 < score <= 80 ZRANGEBYSCORE board (60 (80 # 60 < score < 80 ZRANGEBYSCORE board -inf +inf # 全部 ZRANGEBYSCORE board 60 +inf LIMIT 0 10 # 60 分以上,前 10 个-inf和+inf是两个特殊值,写起来比-1.7976931348623157e308舒服得多。LIMIT offset count的分页语义和 SQL 类似,但要注意它的复杂度是O(log N + offset + count)——offset 越大越慢,因为 Redis 得顺着链表真的往后走那么多步。深度分页(offset 上万)在 ZSet 上同样是性能陷阱,别指望它像数据库索引那样优雅。
ZREVRANGEBYSCORE key max min是降序版本,参数顺序反过来了,先写大值再写小值。这个反直觉的设计是历史遗留,见过太多人在这一步写反参数导致返回空数组。
3.3 按字典序取:ZRANGEBYLEX 的隐藏前提
ZRANGEBYLEX key min max [LIMIT offset count]按member 的字典序取区间,端点的写法规则是:[表示包含,(表示不包含,-是负无穷,+是正无穷。比如取以a到c开头的所有成员:
ZRANGEBYLEX myzset [a [c ZRANGEBYLEX myzset - + ZRANGEBYLEX myzset [member100 (member200这条命令有个必须满足的前提:集合里所有元素的score 必须完全相同,否则返回结果未定义。原因很简单,跳表是先按 score 排序、score 相同时才按 member 字典序排序的,如果 score 乱七八糟,那 member 在链表上根本就不是连续字典序排列的,二分查找自然失效。实践中用它的典型场景就是做“自动补全”或者“按名字前缀查”,此时所有成员的 score 统一设成 0。
还有一个细节:字典序比较走的是memcmp 字节序,不是人类直觉。数字字符串"10"排在"9"前面,因为'1' < '9';大写字母排在小写前面。要做数字补全,得自己补零对齐。ZREVRANGEBYLEX key max min是降序版本,同样参数顺序反转。
3.4 6.2 之后统一的 ZRANGE
从 Redis 6.2 开始,ZRANGE被扩展成了一个“万能取数命令”,把上面三条路合并了:
ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] [WITHSCORES]不写BYSCORE/BYLEX就是原来的排名语义;加BYSCORE变成ZRANGEBYSCORE的等价物;加BYLEX变成ZRANGEBYLEX的等价物;加REV就是降序。这里有两个必须记住的坑:
第一,加了REV之后,开始和结束参数的位置要反过来。ZRANGE key (80 (60 BYSCORE REV才是“80 到 60 倒序”,写成(60 (80会返回空。
第二,LIMIT只能和BYSCORE或BYLEX一起用,在排名语义下用 LIMIT 会直接报语法错误。想做排名分页,老老实实用ZRANGE key offset offset+count-1。
新项目建议统一用ZRANGE的新语法,命令集更收敛、读代码时不用在三个相似命令之间切换。老项目里大量ZRANGEBYSCORE也没必要急着改,功能完全等价。
4. 改分、删除与计数:那些容易忽略的命令
4.1 ZINCRBY:计分场景的主力
ZINCRBY key increment member对成员的分数做增量更新,返回新分数。成员不存在时按 0 起算,所以ZINCRBY board 10 newuser会让 newuser 直接以 10 分入榜。这个行为在“投票加一”的场景里正好合适,省掉“先判断存在再 ZADD”的两步操作。
但这里有个精度问题必须提前说清楚:score 是 IEEE 754 双精度浮点数,不是定点小数。ZINCRBY key 0.1 member连续执行 10 次,结果不是 1.0,而是0.9999999999999999。这不是 Redis 的缺陷,是浮点数的固有特性。涉及金额、积分这类要求精确的场景,一律用整数存储——把“元”换成“分”,把“1.5 倍权重”换成“乘以 100 后的整数”。真需要小数展示,在应用层做除法。
4.2 ZREM 与三种区间批量删除
ZREM key member [member ...]按成员删,支持批量,返回实际删除的个数(不存在的成员不计入)。这个返回值可以用来做并发场景下的“抢占”判断——多个消费者同时ZREM同一个成员,只有返回 1 的那个才算抢到。
按区间批量删除有三条命令,用途各不相同:
| 命令 | 删除依据 | 典型用途 |
|---|---|---|
| ZREMRANGEBYRANK key start stop | 排名区间 | 排行榜只保留前 1000 名,后面的清掉 |
| ZREMRANGEBYSCORE key min max | 分数区间 | 延时队列清理已过期任务 |
| ZREMRANGEBYLEX key min max | 字典序区间 | 按前缀清理缓存索引 |
ZREMRANGEBYRANK key 0 -1001这种写法就是“删掉排名第 0 到倒数第 1001 个”,等价于只保留最后 1000 名,做榜单截断非常方便。注意区间删除的复杂度是O(log N + M),M 是被删元素个数,一次删几十万个同样会阻塞,得分批来。
4.3 ZCOUNT、ZCARD、ZSCORE、ZMSCORE
ZCARD key返回集合大小,O(1),可以放心高频调用。ZCOUNT key min max统计分数落在区间内的元素个数,同样支持(开区间和-inf/+inf,复杂度O(log N),因为它只需要在跳表上定位两个端点。做“60 到 80 分有多少人”这种统计,用ZCOUNT比取出来再len()高效得多。
ZSCORE key member取单个成员的分数,O(1),走的是内部的 dict。返回的是字符串形式的分数,解析成数字时注意别丢精度。Redis 6.2 新增的ZMSCORE key member [member ...]支持一次查多个,返回数组,不存在的成员对应位置是 nil。批量查分数用ZMSCORE能省掉大量往返,尤其是做列表页渲染时,一页 20 个用户,一次调用搞定。
4.4 ZRANDMEMBER 与 ZSCAN
ZRANDMEMBER key [count [WITHSCORES]]随机取成员。不传 count 返回单个成员;传正数 count 返回不重复的 count 个(实际不足则返回全部);传负数 count 返回可能有重复的|count|个。抽奖场景直接用它,比ZRANGE出来再随机选靠谱。
ZSCAN key cursor [MATCH pattern] [COUNT count]是渐进式遍历,和SCAN家族一个套路。它保证遍历期间存在的元素一定会被返回,但不保证不重复,也不保证返回顺序。大 ZSet 做全量处理必须用它,别用ZRANGE 0 -1。游标从 0 开始,返回的新游标为 0 时表示遍历结束:
ZSCAN myzset 0 COUNT 100 # 返回:1) "新游标" 2) 1) "member1" 2) "score1" ...5. 多集合运算与弹出型命令
5.1 ZUNIONSTORE、ZINTERSTORE、ZDIFFSTORE
榜单合并是常见需求:日榜、周榜要做总榜,多个维度的分数要加权汇总。ZUNIONSTORE就是干这个的:
ZUNIONSTORE dest 2 board:day board:week WEIGHTS 1 0.5 AGGREGATE SUMdest是结果存放的键,2是参与运算的集合个数,后面跟键名列表。三个可选参数里,WEIGHTS给每个集合配权重(和键的顺序一一对应),AGGREGATE决定同分成员怎么合并,取值SUM(默认)、MIN、MAX。注意AGGREGATE约束的是分数合并规则,不是“取哪个集合的分数”,别搞混。
ZINTERSTORE语法完全一致,语义是取交集,只有同时出现在所有输入集合里的成员才保留。ZDIFFSTORE是 6.2 加的差集,语法略有不同,不支持 WEIGHTS 和 AGGREGATE,因为差集的语义就是“在第一个集合里但不在后面任何一个集合里”,没有合并分数的需求。
还有一组不落盘的版本:ZUNION、ZINTER、ZDIFF,同样是 6.2 加的,直接返回结果不写目标键。临时算一次就用它们,别为了看一眼结果专门建个键再删掉。7.0 还加了ZINTERCARD,只返回交集元素的个数,不算分数,做“有多少人同时满足多个条件”的统计时内存开销极小。
注意:
ZUNIONSTORE/ZINTERSTORE的目标键如果已存在,会被直接覆盖,不管原来存的是什么类型。生产环境务必用带业务前缀的键名,别手滑传了个user:1上去。
5.2 ZPOPMIN、BZPOPMIN 与 ZMPOP
ZPOPMIN key [count]弹出分数最小的成员,ZPOPMAX弹最大的。这是 5.0 加的,返回member, score的扁平结构。做“优先处理分值最低的任务”这类调度,它比“先ZRANGE 0 0再ZREM”省一次往返,而且是原子操作。
BZPOPMIN key [key ...] timeout是阻塞版,在所有给定键上都空的时候挂起等待,直到有数据或者超时。注意它的返回格式和ZPOPMIN不一样,是三元组key, member, score,因为涉及多个键,得告诉你是哪个键出的数据。timeout单位是秒,写 0 表示永久阻塞。
7.0 加的ZMPOP numkeys key [key ...] MIN|MAX [COUNT count]是更现代的多键批量弹出,语义上替代了BZPOPMIN的非阻塞用法,配合BZMPOP一起用。新项目优先考虑这一组。
6. 三个能直接抄作业的落地场景
6.1 实时排行榜,含同分并列的处理
基本形态很直接:
# 更新分数 ZADD rank:day 2500 user:1001 # 取前 10 ZREVRANGE rank:day 0 9 WITHSCORES # 查我的名次 ZREVRANK rank:day user:1001问题出在同分上。ZSet 在 score 相同时,按 member 的字典序升序排列。这意味着两个 2500 分的用户,谁的 member 字符串字典序小谁排前面,跟“谁先达到这个分数”毫无关系,业务方往往不接受这个结果。
标准解法是把时间维度编进 score。因为 score 是 double,整数部分能精确到 2^53,约 9e15,完全够用。常见做法是分数放大 10^10 倍,低位存一个随时间递减的量:
score = 实际分数 * 10^10 + (10^10 - 当前时间戳秒)先达到的分数时间戳小,后面的补偿量大,整体分数就高,排前面。取出来展示时,对 10^10 取整就还原成真实分数。要提醒的是,不要试图塞超过 15 位有效数字的信息,double 的精度极限在 15 到 17 位十进制之间,17 位就已经开始丢精度了。
多天榜单可以用ZUNIONSTORE按天加权合并,或者干脆每个周期一个键,过期时间设成周期的 1.5 倍,让 Redis 自己回收。
6.2 延时队列:定时任务的轻量实现
思路是把“到期执行的时间戳”当 score,业务 id 当 member:
ZADD delay:queue 1735689600 task:8888消费端轮询取出到期任务:
ZRANGEBYSCORE delay:queue -inf 1735689600 LIMIT 0 10但“取”和“删”之间不是原子的,多个消费者会取到同一批任务。要么用 Lua 脚本把ZRANGEBYSCORE和ZREM打包成一个原子操作,要么用ZPOPMIN配合分数判断——ZPOPMIN弹出来发现分数还没到,得再ZADD塞回去,这种“弹出再放回”的写法在高并发下容易乱序,不推荐。
推荐用 Lua,逻辑清晰:
-- KEYS[1] 队列键,ARGV[1] 当前时间戳,ARGV[2] 批量大小 local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2]) if #tasks == 0 then return {} end redis.call('ZREM', KEYS[1], unpack(tasks)) return tasks这个方案的局限得说明白:没有 ACK 机制。任务从 ZSet 里删掉就没了,消费者崩在中间就丢任务。对可靠性要求高的场景,要么在删之前先写一份“处理中”的备份集合,要么老老实实上 Stream 的消费组。延时队列适合的是“丢一两条也能接受”的定时提醒、缓存刷新这类任务。
6.3 滑动窗口限流
思路是每个请求往 ZSet 里插一条记录,score 和 member 都用时间戳(member 加个随机后缀保唯一),然后清掉窗口外的,数一下窗口内的数量:
-- KEYS[1] 限流键,ARGV[1] 当前毫秒时间戳,ARGV[2] 窗口大小(ms),ARGV[3] 阈值 redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2]) local count = redis.call('ZCARD', KEYS[1]) if count < tonumber(ARGV[3]) then redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1] .. ':' .. math.random(1000000)) redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end return 0PEXPIRE那行很关键,不然冷门用户的键会一直留着占内存。这个方案比固定窗口的计数器精确,不会出现“窗口边界瞬间放行两倍流量”的问题,代价是每个请求都要写一条记录,内存和 CPU 开销都更高。阈值不高(比如每秒几十次)的场景可以用,动辄每秒上万次的接口还是推荐令牌桶配合普通计数器。
7. 线上踩坑与排查实录
7.1 常见问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
ZRANGEBYLEX返回空或结果不对 | 集合内 score 不一致 | 先确认所有成员 score 相同 |
ZADD返回值是 0 但分数变了 | 没加CH,返回值只统计新增 | 加CH或改用ZINCRBY看新分数 |
ZADD ... NX GT报语法错误 | NX 与 GT/LT 互斥 | 去掉 NX,或改用XX GT |
| 分数累加出现 0.9999... | double 浮点精度 | 改用整数存储,放大倍数 |
| 某天内存突然涨了几倍 | member 超 64 字节触发编码转换 | 检查 member 命名,缩短或改用 id |
| 接口偶发超时,慢查询里有 ZRANGE | 大集合上ZRANGE key 0 -1 | 分页取或用ZSCAN遍历 |
DEL一个大 ZSet 卡住 | 同步删除阻塞主线程 | 改UNLINK或分批ZREMRANGEBYRANK |
ZREVRANGEBYSCORE返回空 | max/min 参数写反 | 记住降序版是先大后小 |
7.2 大 key 与内存估算
一个百万级成员的 ZSet,走 skiplist 编码后,每个节点除了 member 和 score 两个对象本身,还要维护平均 1.33 层的前向指针和一个后向指针。粗算下来单节点额外开销在 60 到 100 字节之间,加上 member 字符串本身和 Redis 的对象头,百万成员轻松吃到上百 MB。这还没算 dict 那侧的开销。
所以几个原则得记住。member 尽量短,用数字 id 而不是 UUID,用短前缀而不是完整路径。控制单个集合规模,按天、按小时分片,别让一个键无限膨胀。删除分批做,ZREMRANGEBYRANK key 0 999循环执行,每批之间 sleep 几毫秒,别一条命令删一百万。过期时间一定要设,哪怕是“永久榜单”,也设个 7 天让冷数据自然淘汰。
MEMORY USAGE key能查单个键的实际内存占用,redis-cli --bigkeys能扫出各类型里最大的键。线上巡检这两个工具用起来,比出事之后再救火强得多。
7.3 score 精度与业务 id 的拼接技巧
最后聊一个反复被问到的问题:能不能把多个维度拼进一个 score?能,但有边界。double 的尾数 52 位,整数精确范围到 2^53 ≈ 9.007e15。假设你想把“主分数”和“时间戳”拼一起,主分数占 4 位十进制、时间戳占 10 位十进制,加起来 14 位,还在安全范围内。但如果主分数本身就要 8 位,再加 10 位时间戳就是 18 位,超出精度,低位开始随机抖动,排序结果就会莫名其妙。
判断方法很简单:把所有可能的 score 值列出来,看最大值有没有超过 9e15。超了就换方案——要么用两个 ZSet 分别存主分数和次级排序键,要么把次级维度直接编进 member 字符串,靠字典序来兜底。后者虽然不够优雅,但至少行为是确定的、可预测的。
我个人的习惯是:score 只放一个语义清晰的数值,需要复合排序的时候,先想办法把业务规则简化,实在简化不了再考虑编码。因为编码进 score 的方案,一旦业务方改了排序规则,迁移成本极高,而且中间那串“放大 10 的 N 次方”的魔数,半年后自己回看都得愣一下。