当前位置: 首页 > news >正文

【Redis从入门到精通】第23篇:ZSet对象——ziplist和skiplist的完美组合

上一篇【第22篇】Set对象——intset和hashtable的混搭艺术
下一篇【第24篇】Redis内存优化完全指南


如果Redis数据结构有"鄙视链",那ZSet(Sorted Set,有序集合)一定是站在顶端的那位。它是所有基础类型中最"重"的一个——底层结构复杂、能力强大、实现优雅。一个ZSet对象同时集成了ziplist(紧凑存储)、skiplist(高效范围查询)和hashtable(O(1)点查询)三种数据结构的优点,堪称Redis设计哲学的集大成者。

一、ZSet对象概览:一个类型,两种编码

ZSet和其他Redis对象一样,干着"见人下菜碟"的活。数据小的时候用ziplist,数据大了用skiplist。但和其他类型不同的是,skiplist编码下的ZSet内部还绑着一个hashtable,形成了一种"双核心"架构。

先来一张总览表:

特性ziplist编码skiplist编码
底层结构单个ziplistskiplist + hashtable(双结构)
member+score存储交替存储在ziplist中skiplist负责排序,hashtable负责点查询
排序方式物理顺序,按score升序跳表逻辑顺序,O(logN)
内存占用紧凑,省内存三个结构并存,内存开销大
ZRANK查询O(N),需遍历计数O(logN),跳表直接定位
ZSCORE查询O(N),需遍历O(1),hashtable直接定位

二、ziplist编码:挤得整整齐齐

当ZSet元素不多时,Redis会把所有member和score交替存放在一个ziplist中,而且按score升序排列

ziplist编码下的ZSet存储结构 ============================================================== ZSet: {(张三, 89), (李四, 92), (王五, 85), (赵六, 96)} ziplist实际存储(按score升序排列): +--------+--------+--------+--------+--------+--------+--------+--------+ | 王五 | 85 | 张三 | 89 | 李四 | 92 | 赵六 | 96 | +--------+--------+--------+--------+--------+--------+--------+--------+ member1 score1 member2 score2 member3 score3 member4 score4 score升序 → 85 < 89 < 92 < 96 关键规则: 1. member和score交替存储 2. 整体按score从小到大排序 3. score相同的,按member的字典序排序

这个排列方式决定了在ziplist编码下,ZRANGE(按排名查)非常低效——因为它需要从头遍历到目标的rank位置才能找到数据。但是在数据量小的情况下(默认128个以内),N这么小,遍历的开销几乎感觉不到。

来实操一下ziplist编码的ZSet:

# 创建小规模ZSet127.0.0.1:6379>ZADD game:rank89"张三"92"李四"85"王五"96"赵六"(integer)4# 查看编码127.0.0.1:6379>OBJECT ENCODING game:rank"ziplist"# 按排名查看——正序(低分到高分)127.0.0.1:6379>ZRANGE game:rank0-1WITHSCORES1)"\xe7\x8e\x8b\xe4\xba\x94"# 王五2)"85"3)"\xe5\xbc\xa0\xe4\xb8\x89"# 张三4)"89"5)"\xe6\x9d\x8e\xe5\x9b\x9b"# 李四6)"92"7)"\xe8\xb5\xb5\xe5\x85\xad"# 赵六8)"96"# 查看张三的排名127.0.0.1:6379>ZRANK game:rank"张三"(integer)1# 第1名(从0开始)

三、skiplist编码:双核驱动的强大引擎

当ZSet的数据量上去了,ziplist的遍历方式就力不从心了。这时Redis搬出skiplist编码的大杀器——一个同时拥有跳表和哈希表的"双核"架构。

为什么需要两个结构?

这是很多初学者困惑的地方:为什么skiplist不够用,还要加一个hashtable?

答案在于两者各有所长,互相补充:

skiplist编码下的ZSet内部结构 ============================================================== zset +-------------+ | dict (*) | ======> | zsl (*) | ======> +-------------+ | | v v hashtable skiplist (点查询O(1)) (范围查询O(logN)) +------------------+ +-------------------------------+ | dict (hashtable)| | skiplist (跳表,排序) | | | | | | key value | | L3: [头]----------------->[尾]| | "张三" 89 | | | | | "李四" 92 | | L2: [头]--->[85]----->[92]-->| | "王五" 85 | | | | | | | "赵六" 96 | | L1: [头]->[85]->[89]->[92]-->| | | | | | | | | +------------------+ | L0: [头]->[85]->[89]->[92]->[96]-->[尾] | 王五 张三 李四 赵六 +-------------------------------+ 工作分工: - 查分数 (ZSCORE): dict → O(1) - 查排名 (ZRANK): skiplist → O(logN) - 范围查询 (ZRANGEBYSCORE): skiplist → O(logN + M) - 新增元素 (ZADD): dict插入 + skiplist插入 → O(logN)

一句话总结这个设计哲学:hashtable负责"这个人的分数是多少",skiplist负责"分数在80-90之间的都有谁"

用C代码看看这个结构体:

// Redis源码中的zset结构体typedefstructzset{dict*dict;// 字典,key=member,value=scorezskiplist*zsl;// 跳表,按score排序}zset;

实际操作演示:

# 创建大规模ZSet(自动用skiplist编码)127.0.0.1:6379>EVAL" for i=1,200 do redis.call('ZADD', 'big:zset', i, 'member'..i) end "0127.0.0.1:6379>OBJECT ENCODING big:zset"skiplist"# ZSCORE——走hashtable,O(1)127.0.0.1:6379>ZSCORE big:zset member150"150"# ZRANK——走skiplist,O(logN)127.0.0.1:6379>ZRANK big:zset member150(integer)149# ZRANGEBYSCORE——走skiplist范围查询,O(logN+M)127.0.0.1:6379>ZRANGEBYSCORE big:zset4050WITHSCORES1)"member40"2)"40"3)"member41"4)"41"...

踩坑提示:skiplist编码下,hashtable和skiplist中的数据是冗余重复的——同一个member在两个结构中各存了一份。这也解释了为什么ZSet是Redis中最吃内存的数据类型——你存入一个member+score,实际在内存中存了两份(dict存一份,skiplist存一份)。

四、编码转换:什么时候放大招

ZSet的编码转换规则和Hash类似,由两个配置参数控制:

配置参数含义默认值
zset-max-ziplist-entriesziplist最大元素数量128
zset-max-ziplist-value单个member最大长度64(字节)

需要注意的是,这里判断的是member的长度,不是score。score永远是数字(double类型),和value长度无关。

# 演示编码转换127.0.0.1:6379>CONFIG SET zset-max-ziplist-entries3OK# 3个元素——ziplist127.0.0.1:6379>ZADD test:zset10"a"20"b"30"c"(integer)3127.0.0.1:6379>OBJECT ENCODING test:zset"ziplist"# 第4个——触发转换!127.0.0.1:6379>ZADD test:zset40"d"(integer)1127.0.0.1:6379>OBJECT ENCODING test:zset"skiplist"

同样,如果member长度超过了64字节,即使元素很少,也会触发转换:

127.0.0.1:6379>CONFIG SET zset-max-ziplist-entries512OK127.0.0.1:6379>CONFIG SET zset-max-ziplist-value8OK127.0.0.1:6379>ZADD test:zset210"short"(integer)1127.0.0.1:6379>OBJECT ENCODING test:zset2"ziplist"# member长度超过8——触发转换127.0.0.1:6379>ZADD test:zset220"this_is_a_long_member_name"(integer)1127.0.0.1:6379>OBJECT ENCODING test:zset2"skiplist"

踩坑提示:ZSet的编码转换成本比Hash更高——因为它不仅要构建skiplist,还要同时构建hashtable。大数据量下的转换会瞬间吃满CPU,如果是在生产高峰期触发,真的会让人血压飙升。

五、核心命令的两种编码实现对比

同样的命令,在不同编码下走的是完全不同的代码路径:

命令ziplist实现skiplist实现
ZADD遍历找到插入位置,可能需要移动后续元素 O(N)跳表插入+字典插入 O(logN)
ZSCORE遍历找到member,读取下一个entry的score O(N)从dict中直接获取 O(1)
ZRANK从头遍历到找到member,计步数 O(N)跳表查找,span累加 O(logN)
ZRANGE从指定位置起取M个 O(N)跳表定位起点,然后沿L0层取M个 O(logN+M)
ZRANGEBYSCORE找到第一个≥min的元素,遍历到>max O(N)跳表定位min,沿L0取到max O(logN+M)
ZREM遍历找到member,删除并整理 O(N)dict删除+跳表删除 O(logN)
ZCOUNT遍历计数 O(N)跳表定位后计算 span 差值 O(logN)

可以看到,ziplist在大多数写操作上都吃亏。这就是为什么大数据量下一定要让ZSet用skiplist编码。

六、排行榜实战:ZSet的"主战场"

排行榜是ZSet最经典的应用场景。下面我们来搭建一个完整的游戏得分排行榜:

# 1. 初始化玩家数据ZADD game:leaderboard0"player:1001"0"player:1002"0"player:1003"ZADD game:leaderboard0"player:1004"0"player:1005"# 2. 玩家得分增加ZINCRBY game:leaderboard150"player:1001"ZINCRBY game:leaderboard200"player:1003"ZINCRBY game:leaderboard88"player:1002"ZINCRBY game:leaderboard300"player:1004"ZINCRBY game:leaderboard45"player:1005"# 3. 查看排行榜Top 3(降序——高分在前)127.0.0.1:6379>ZREVRANGE game:leaderboard02WITHSCORES1)"player:1004"2)"300"3)"player:1003"4)"200"5)"player:1001"6)"150"# 4. 查看某个玩家的排名和分数127.0.0.1:6379>ZSCORE game:leaderboard"player:1002""88"127.0.0.1:6379>ZREVRANK game:leaderboard"player:1002"(integer)3# 降序第3名# 5. 查看某个玩家附近的名次(前一名和后一名)127.0.0.1:6379>ZREVRANK game:leaderboard"player:1003"(integer)1# player:1003排第1# 查看他后面的人127.0.0.1:6379>ZREVRANGE game:leaderboard23WITHSCORES1)"player:1001"2)"150"3)"player:1002"4)"88"

用ASCII图把排行榜的结构画出来:

游戏得分排行榜(按score降序) ============================================ 排名 玩家ID 得分 #1 player:1004 300 ████████████████████████████ #2 player:1003 200 ██████████████████ #3 player:1001 150 ██████████████ #4 player:1002 88 ████████ #5 player:1005 45 ████ ZREVRANGE 0 2 WITHSCORES → 返回前3名 ZREVRANK player:1002 → 3 (降序排名,0-based) ZSCORE player:1002 → 88

ZINCRBY的妙用

在排行榜场景中,ZINCRBY是最重要的命令——它增量更新分数,不需要先读后写,保证了原子性:

# 玩家完成一局游戏获得新分数# ✅ 原子操作——不需要 GET → + → SET127.0.0.1:6379>ZINCRBY game:leaderboard50"player:1001""200"# ❌ 如果用String,需要这个步骤:# GET score:player:1001 → 150# 计算: 150 + 50 = 200# SET score:player:1001 200# 中间可能被其他请求插队,造成数据不一致

七、范围查询的边界值技巧

使用ZRANGEBYSCOREZREVRANGEBYSCORE时,括号的用法很容易搞混。下面用一张表说清楚:

# 基础语法回顾# ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]# 默认:闭区间[min, max]# 左开:(min 右开:(max# 列出score在80到95之间的(含80和95)127.0.0.1:6379>ZRANGEBYSCORE game:rank8095WITHSCORES# 列出score大于80且小于等于95的(不含80)127.0.0.1:6379>ZRANGEBYSCORE game:rank(8095WITHSCORES# 列出score大于等于80且小于95的(不含95)127.0.0.1:6379>ZRANGEBYSCORE game:rank80(95WITHSCORES# 列出所有score大于80的(用+inf表示正无穷)127.0.0.1:6379>ZRANGEBYSCORE game:rank(80+inf WITHSCORES# 列出所有score小于等于95的(用-inf表示负无穷)127.0.0.1:6379>ZRANGEBYSCORE game:rank-inf95WITHSCORES

边界符号速查表:

写法含义示例
min max闭区间 [min, max]80 100→ score∈[80,100]
(min maxmin开区间 (min, max](80 100→ score∈(80,100]
min (maxmax开区间 [min, max)80 (100→ score∈[80,100)
-inf +inf全区间返回所有元素
(min +inf大于min(0 +inf→ 所有正分

八、Redis 7.0+的新玩具:ZPOPMIN/ZPOPMAX

Redis 7.0带来了一些很有用的ZSet新命令,让队列场景的实现变得更优雅:

ZPOPMIN / ZPOPMAX:弹出最小/最大元素

# ZPOPMIN:弹出score最小的元素(和SPOP类似,但是按score顺序弹出)127.0.0.1:6379>ZADD tasks:queue1"task1"2"task2"3"task3"4"task4"(integer)4# 弹出分数最低的2个任务(优先级最高的)127.0.0.1:6379>ZPOPMIN tasks:queue21)"task1"2)"1"3)"task2"4)"2"# 剩下分数较高的任务127.0.0.1:6379>ZRANGE tasks:queue0-1WITHSCORES1)"task3"2)"3"3)"task4"4)"4"# ZPOPMAX:弹出分数最高的127.0.0.1:6379>ZPOPMAX tasks:queue11)"task4"2)"4"

BZPOPMIN / BZPOPMAX:阻塞式弹出

# 阻塞等待:如果ZSet为空,等待最多10秒# BZPOPMIN key timeout127.0.0.1:6379>BZPOPMIN tasks:queue101)"tasks:queue"2)"task3"3)"3"# 这个命令对任务队列场景特别友好!# 替代了以前用BRPOPLPUSH实现延迟队列的复杂操作

有了这些命令,用ZSet实现优先级队列变得特别简洁:

ZSet作为优先级队列 ============================================ 生产者 消费者 +------------------+ +------------------+ | ZADD queue 1 t1 | → | BZPOPMIN queue 0 | | ZADD queue 5 t2 | | 弹出 t1 (优先级1) | | ZADD queue 3 t3 | | BZPOPMIN queue 0 | +------------------+ | 弹出 t3 (优先级3) | +------------------+ ZSet内部: +------------------+ | t1(1) t3(3) t2(5)| +------------------+ 按score升序,低分优先处理

踩坑提示ZPOPMINZPOPMAX在Redis 5.0才加入,BZPOPMINBZPOPMAX也是5.0引入的。如果你的Redis版本太低,可以用ZRANGE + ZREM的组合(非原子)或者升级到新版本。


小结

ZSet是Redis中最复杂也最强大的数据结构,它用精巧的设计同时解决了三个问题:排序、范围查询和点查询。

核心要点回顾:

  1. 数据小的用ziplist——省内存,但查询需要遍历
  2. 数据大的用skiplist——本质是skiplist + hashtable双结构,排名查O(logN)、分数查O(1)
  3. skiplist和hashtable数据冗余——空间换时间,一个负责排序,一个负责点查
  4. 排行榜是ZSet的天然主场——ZADD/ZINCRBY/ZREVRANGE/ZRANK组合拳一气呵成
  5. 范围查询注意括号——(表示开区间,-inf/+inf表示无界
  6. Redis 7.0+新增ZPOPMIN/ZPOPMAX——让ZSet做优先级队列更加顺手

下一篇,我们将从"调优"的角度全面审视Redis的内存使用——OBJECT ENCODING怎么玩、内存碎片怎么治、大Key怎么找、8种淘汰策略怎么选……敬请期待《Redis内存优化完全指南》!


上一篇【第22篇】Set对象——intset和hashtable的混搭艺术
下一篇【第24篇】Redis内存优化完全指南


http://www.jsqmd.com/news/929050/

相关文章:

  • 从零设计电子徽章:EasyEDA实战与PCB制作全流程
  • 蓬江区26年最新奢侈品名包名表专业回收权威店铺推荐 - 莘州文化
  • Zotero-GPT:将AI智能文献分析融入学术工作流的实践指南
  • AMD Ryzen调试完全指南:免费开源SMUDebugTool终极教程
  • 基于可逆数据隐藏的WSNs多项目数据完整性认证方案
  • 基于Arduino与WS2812B的智能助眠氛围灯DIY全攻略
  • leetcode 2126. 摧毁小行星 中等
  • stsb-xlm-r-multilingual应用场景:智能客服、文档检索、内容推荐
  • Sora 2 vs Runway Gen-3 vs Pika 1.5:横向评测8K分辨率下运动连贯性、纹理保真度与时序一致性(附原始测试帧下载链接)
  • 从入门到精通:微软Lens模型完整安装与配置教程
  • 坡头区26年最新奢侈品名包名表专业回收权威店铺推荐 - 莘州文化
  • 2026淋雨试验箱品牌推荐:靠谱品牌筑牢防水测试合规防线 - 资讯速览
  • SY_AICC/gpt2-conversational-retrain模型参数调优指南:温度、top_p、top_k等超参数详解
  • 3分钟掌握Godot PCK文件解包:免费工具一键提取游戏资源
  • AI赋能小企业HR:从招聘到绩效的智能实践指南
  • AI Agent 12 项底层核心原理 + 应用方法
  • 【GitHub】Understand-Anything 深度技术分析:让代码库“开口说话“的交互式知识图谱
  • 终极微信聊天记录导出备份指南:永久保存你的珍贵回忆
  • 一个草根创业者的“最小可行性实践
  • Arduino智能感应骨架:超声波传感器与步进电机联动实现自动惊吓装置
  • 保姆级教程:在Ubuntu 20.04上搞定《视觉SLAM十四讲》第二版所有依赖库(Eigen、Pangolin、Ceres、g2o)
  • 三水区26年最新奢侈品名包名表专业回收权威店铺推荐 - 莘州文化
  • 基于ESP32与VS1053打造网络收音机:硬件连接、WiFi管理与深度睡眠实践
  • 基于Arduino的智能语音触发器:为老人定制Google Home物理呼叫方案
  • 从Kaggle竞赛到业务落地:用修正z-score提升你的数据清洗与特征工程效果
  • 智能数据提取与永久保存:WeChatMsg开源工具为个人数据管理提供自动化处理解决方案
  • 别再让高刷屏拖累你的游戏!Unity Android帧率适配全攻略:从Surface API到Display Mode
  • 魔兽争霸3终极优化指南:如何用WarcraftHelper解决现代系统兼容性问题
  • Qwen3.5-40B-Claude-4.6-Opus-Deckard-Heretic-Uncensored-Thinking完整社区贡献指南:如何参与这个无审查AI模型的开发与改进
  • Arduino音乐互动小屋:从传感器到执行器的嵌入式系统实战