Redis底层结构全解析:SDS、跳表、listpack与编码转换实战
聊到 Redis,几乎每个后端开发都能脱口而出五种数据类型:String、List、Hash、Set、ZSet。但说实话,会用命令和真正理解底层结构,完全是两个层次。面试时一个“ZSet 底层为什么用跳表”就能筛掉一大片,线上排查时一个 big key 导致阻塞,不懂编码转换机制的人也常常一头雾水。这篇文章我打算把 Redis 数据类型底层结构这件事彻底讲透:从五种类型各自适合什么场景,到 SDS、quicklist、intset、skiplist 这些底层实现为什么这么设计,再到编码转换的参数触发机制和线上踩过的坑,一次性说清楚。不管你是准备面试、做架构选型,还是日常排查问题,这篇都值得收藏慢慢看。
1. 五种数据类型全景:先搞清楚它们各自擅长什么
很多新手学 Redis 的第一个误区,就是把数据类型当成简单的“存储容器”,存进去能取出来就算会用。其实 Redis 的每一种类型都针对特定场景做了大量优化,选错类型,性能和内存可能差一个数量级。所以在拆底层之前,我们先把五种类型的能力边界和适用场景理清楚。
1.1 五种类型的核心能力速查
我用一张表把这五种类型的关键信息汇总一下,方便你对照理解。
| 类型 | 底层核心结构(小数据量/大数据量) | 典型操作 | 最擅长的事 | 一句话类比 |
|---|---|---|---|---|
| String | int / embstr / raw(SDS) | SET、GET、INCR、SETNX | 缓存、计数器、分布式锁 | 一把带锁的钥匙串 |
| List | listpack / quicklist | LPUSH、RPUSH、LPOP、BRPOP | 消息队列、时间线、最新列表 | 一条双向通行的队伍 |
| Hash | listpack / hashtable | HSET、HGET、HINCRBY | 存储对象字段、购物车、配置项 | 一个可局部更新的档案袋 |
| Set | intset / listpack / hashtable | SADD、SINTER、SUNION、SPOP | 去重、标签系统、抽奖、好友关系 | 一个数学意义上的集合 |
| ZSet | listpack / skiplist + dict | ZADD、ZRANGEBYSCORE、ZRANK | 排行榜、延迟队列、限流滑动窗口 | 带分值排序的榜单 |
注意表中“小数据量/大数据量”的分法很关键。Redis 对数据量较小的对象会采用更紧凑的内存编码(listpack、intset),数据量大了之后才切换成更灵活的 hashtable、skiplist。这个机制后面第三章会重点讲,它是理解 Redis 内存优化的一把钥匙。
1.2 选型逻辑:什么场景选什么类型,别糊里糊涂
我见过不少同学把所有数据全塞进 String,用拼接 key 的方式模拟各种结构,最后 key 数量爆炸、内存飙升、维护困难。选错类型的代价往往不是立刻暴露的,而是集群扩容时才显现。
- String 适合标量数据和计数器:缓存用户 token、接口返回的 JSON 字符串、库存数字、点赞数。INCR/DECR 是原子操作,做计数器、限流器非常自然。
- List 适合先进先出或后进先出的数据流:LPUSH + BRPOP 组合就是经典的生产者消费者模型,比在数据库里维护一张队列表高效得多。注意不要把 List 当数组乱用,LINDEX 在大列表上是 O(N)。
- Hash 适合“对象整体缓存”:比如用户信息有几十个字段,如果用 String 就得序列化整个对象,改一个字段也要全量反序列化再写回;用 Hash 可以单独操作某个字段,内存布局也更紧凑。
- Set 适合集合运算和去重:SADD 天然去重;SINTER 可以做“共同关注”;SPOP 可以做随机抽奖,不用自己写随机算法。
- ZSet 适合一切带权重的排序场景:分数就是权重。排行榜、延迟任务(用时间戳当 score)、滑动窗口限流都能用 ZSet 优雅实现。
记住一个朴素的原则:先想清楚你的数据是“一个值”、“一组值”还是“一组带分数的值”,再选类型。不要为了省事全程 String 一把梭,后面数据规模上来再改数据结构,迁移成本远高于一开始就选对。
2. 底层结构逐个拆解:SDS、quicklist、跳表到底解决了什么问题
这部分是全篇的核心。Redis 之所以快,除了内存操作和 IO 多路复用,更关键的是它在数据结构上做了大量极致优化。每一种结构都是为了解决某个特定痛点而存在的,理解了痛点,你才算真正理解 Redis。
2.1 String 的底层是 SDS,Redis 为什么非要自己造一个字符串
String 在 Redis 内部的载体叫 SDS(Simple Dynamic String,简单动态字符串),而不是 C 语言原生字符串。C 字符串用 char[] 加 \0 结尾,看起来够用,但 Redis 这种高性能场景下有一堆硬伤。
首先是长度获取。C 字符串要数到 \0 才能知道长度,复杂度 O(N);SDS 在头部直接用 len 字段记录长度,查询是 O(1)。其次是缓冲区溢出问题。C 字符串拼接时如果忘了手动分配内存,可能把相邻内存写坏;SDS 在 API 里自动检查剩余空间并扩容,从机制上杜绝了溢出。
更关键的两点是空间预分配和惰性空间释放。空间预分配是指 SDS 扩容时会多分配一些内存:如果修改后的长度小于 1MB,就分配 2 * len 的容量;如果大于等于 1MB,就额外分配 1MB。这样连续做多次 append 操作时,不需要每次都触发系统调用去重新分配内存。惰性空间释放则是在缩短字符串时不立即回收多余内存,而是留着下次 append 用,同样是为了减少内存重分配频率。
最后还有二进制安全问题。C 字符串遇到 \0 就会截断,所以存不了图片、序列化后的对象这些含 \0 的二进制数据;SDS 用单独的 len 字段记录长度,字符串中间是什么都不影响,真正做到二进制安全。所以 Redis 里你甚至可以安全地把一个 Java 序列化对象塞进 String。
SDS 本身还有三种编码形态:int、embstr 和 raw。当存储的是整数且可以用 long 表示时,直接用 int 编码,8 字节搞定;当字符串长度不超过 44 字节时用 embstr,把 SDS 头结构和数据存在同一块连续内存里,减少一次内存分配;超过 44 字节则用 raw,头结构和数据分两块内存。这三种编码是透明的,你正常 SET/GET 就行,但 OBJECT ENCODING key 命令可以查看到的实际编码,排查问题时非常有用。
2.2 List 的底层是 quicklist,双向链表和紧凑数组的折中方案
List 的底层实现经历了好几代演进:最早的版本是双向链表,每个节点独立分配内存,优点是插入删除方便,缺点是内存碎片严重、每个节点还要额外存前后指针。后来引入了 ziplist(压缩列表),把一串数据存在连续内存里,内存紧凑但不支持在中间高效插入;再后来变成了 quicklist,也就是“双向链表 + 若干紧凑节点”的组合。
quicklist 的思路是:把数据切分成多个节点,每个节点内部是一个小型的紧凑列表(7.0 之前是 ziplist,7.0 之后是 listpack),节点之间用双向指针串起来。这样既有链表的灵活(可以从两端快速插入弹出),又有压缩列表的内存优势(单个节点内数据连续存放,减少内存碎片)。
这里有个关键参数 list-max-listpack-size,默认值是 128,控制每个节点最多装多少元素(7.0 前的 list-max-ziplist-size 默认是 8,单位是 KB)。设置太大会让单个节点变得又大又笨重,中间插入数据要移动大量元素;设置太小则链表节点太多,指针开销增加。实际使用中,如果你用 List 做消息队列,建议保持默认配置即可,别乱调。但有一点要注意:LINSERT 或 LINDEX 在 quicklist 上是 O(N) 操作,列表很大时慎用,容易阻塞主线程。
2.3 Hash 的底层:小对象用 listpack,大对象用 hashtable
Hash 类型如果没有特别设置,内部结构会根据元素数量和单个字段长度动态切换。数据量小、字段值也短时,用 listpack 把所有键值对紧凑地排在一起;数据量大了之后切换成真正的哈希表 hashtable。
listpack 的优势是内存极其紧凑,一个 field 和一个 value 挨着放,没有额外的指针开销,非常适合字段不多的小对象。但它的劣势是字段增多后,查找需要线性扫描,插入和删除也可能涉及数据搬移。所以当哈希表中的字段数超过 hash-max-listpack-entries(默认 128)或者某个 value 长度超过 hash-max-listpack-value(默认 64)时,Redis 就会自动把它转换成 hashtable。
hashtable 就是我们常说的哈希表,用数组加链表解决冲突,查找复杂度 O(1)。Redis 的 hashtable 还有一个重要特性叫渐进式 rehash:扩容和缩容不是一次性完成的,而是分批把数据从旧表搬到新表,避免一次性搬移大对象导致主线程卡顿。你可以在扩容期间同时读写新旧两表,这期间的 CPU 开销是平滑的。
实际开发里,Hash 最大的坑是 field 数量过大引发 big key 问题。比如把一个用户的几千个字段全塞进一个 Hash,HGETALL 一次性取出所有数据时,网络传输和内存序列化的开销都很大。正确的做法是控制单个 Hash 的大小,或者拆分成多个 key。
2.4 Set 的底层:都是整数时就上 intset
Set 的底层有小惊喜。如果集合中所有元素都是整数,且元素个数不超过 set-max-intset-entries(默认 512),Redis 会用 intset(整数集合)存储。intset 的本质是一个有序的整数数组,按从小到大排列,查找可以用二分法,内存占用比哈希表小得多。
intset 有一个特别有意思的设计:升级机制。假设当前集合存储的是 16 位整数数组,当插入一个 32 位整数时,整个数组会先升级到 32 位,再把新元素插进去。升级虽然要搬移所有元素,但一个集合一生通常只升级一次,所以整体性价比很高。不过要注意,intset 只支持升级、不支持降级,删掉大整数后不会自动降回小位数,这一点在内存规划时别忽略。
当集合不再满足“全整数”或“数量超限”条件时,intset 会切换为 hashtable 编码。另外 Redis 7.2 之后新增了一个变化:小规模的字符串集合也可以使用 listpack 编码,参数是 set-max-listpack-entries 和 set-max-listpack-value,进一步优化了小型字符串集合的内存占用。
Set 实战中最常被问到的是抽奖场景。用 SADD 把参与用户 ID 全部加入集合,SPOP 随机弹出中奖者,天然去重且无需自己写随机算法。但注意 SMEMBERS 在集合很大时会一次性返回所有元素,线上建议用 SSCAN 分批遍历。
2.5 ZSet 的底层:skiplist 和 dict 的组合拳
ZSet 是 Redis 五种类型里底层结构最复杂、面试也最爱考的一个。核心设计是 skiplist(跳表)+ dict(哈希表) 的组合:跳表负责按 score 排序和范围查询,dict 负责 O(1) 地按 member 查分数。
为什么跳表能胜任排序?你可以把跳表想象成一个多层的链表,最底层是包含所有元素的普通有序链表,每一层通过稀疏的指针跳过部分节点。查找时从最高层往下走,每层都大步跳过,逐渐缩小范围,平均查找复杂度是 O(log n)。Redis 的跳表实现里还加了一个 backward 指针支持倒序遍历,score 相同时按 member 的字典序排列,保证顺序唯一且可预测。
很多人会追问:为什么不用红黑树或 B+ 树?答案有三层。第一,跳表实现简单,几十行代码就能写清楚,调试和扩展都方便;平衡树的插入删除要处理各种旋转,工程实现和维护成本高得多。第二,跳表的范围查询非常自然,从某个 score 起点开始沿着链表往后走即可,区间操作不受层数影响;第三,内存占用可控,跳表的层数是随机生成的,Redis 里层数最大 32,每层指针只有 8 字节,平均下来每个节点约 1.33 个额外指针,比平衡树省不少内存。
当 ZSet 元素较少时(默认不超过 128 个且 member 长度不超过 64 字节),也会用 listpack 紧凑存储,这时成员是按 score 有序排列的,但插入需要搬移数据,所以只适用于小规模数据。数据量变大后,listpack 会转换成 skiplist + dict 的组合。这个转换时机也是面试常考点,后面章节单独讲。
明白了跳表的结构,排行榜的实现就水到渠成了:ZADD 写入分数,ZREVRANGE 取排行榜前 N 名,ZRANK 查某个用户的排名,都是 O(log n) 级别,比在数据库里 ORDER BY score LIMIT 高效太多。
3. 编码转换与内存优化实战:为什么数据量大了 Redis 反而变慢
理解了每种类型的两套结构之后,接下来要解决一个更实际的问题:什么时候从紧凑编码切换成标准结构?这个切换逻辑直接影响你的内存占用和查询性能。很多人没意识到,某些 key 在业务增长到阈值时,会默默发生一次全量数据搬移,如果这个 key 很大,这次搬移是会卡主线程的。
3.1 编码转换的触发条件与关键参数
Redis 每个 key 的编码不是写死的,而是根据数据特征动态决定。查看编码最直接的方式是 OBJECT ENCODING key 命令,它返回 int、embstr、raw、listpack、quicklist、hashtable、intset、skiplist 等字符串。我测过的所有生产环境,排查 big key 时第一步都是先 OBJECT ENCODING 看一下它现在的形态。
触发转换的核心配置项如下:
| 配置项 | 默认值 | 触发切换条件 |
|---|---|---|
| hash-max-listpack-entries | 128 | Hash 字段数超过 128 时 listpack 转 hashtable |
| hash-max-listpack-value | 64 | Hash 某个 value 长度超过 64 字节时转换 |
| list-max-listpack-size | 128 | List 每节点元素数超过 128 时调整 quicklist 节点 |
| set-max-intset-entries | 512 | Set 元素超过 512 个时 intset 转 hashtable |
| zset-max-listpack-entries | 128 | ZSet 元素超过 128 个时 listpack 转 skiplist + dict |
| zset-max-listpack-value | 64 | ZSet member 长度超过 64 字节时转换 |
这里要注意,转换是惰性的,也就是说只有在写入时发现新数据不满足当前编码条件,才会触发整体转换。比如一个 Hash 已经存了 127 个字段,当你第 128 个字段写入时触发转换,而不会在后台提前转。大 key 的转换过程是一块连续内存的重新分配和数据拷贝,在极端情况下会让主线程卡上几秒,线上碰到大 key 升级之前一定要评估。
3.2 listpack 替代 ziplist:修了一个隐藏最深的坑
如果你翻过旧版本的 Redis 源码或老博客,会看到 ziplist 这个词。7.0 之前 List 的 quicklist 节点、Hash 和 ZSet 的小数据量编码都用的 ziplist。ziplist 的设计在当时很优秀,但它有一个臭名昭著的问题——级联更新。
ziplist 里每个 entry 的头部都记录了前一个 entry 的长度,如果某个 entry 被修改后长度变大,下一个 entry 的“前一个 entry 长度”字段可能装不下,需要扩大,这又会让再下一个 entry 的头部字段变大,引发连锁反应。极端情况下,一次插入可能导致整个列表的所有 entry 依次搬移,时间复杂度从 O(1) 退化到 O(N)。
listpack 的解法很巧妙:每个 entry 只记录自己的长度,不记录前一个 entry 的长度,彻底斩断了级联更新的链条。虽然代价是反向遍历时无法直接跳转,但 Redis 通过额外设计解决了遍历场景,换来的是最坏情况下插入复杂度可控。
这个演进给我们的启示是:即使一个结构在 99% 的场景下表现优秀,剩下 1% 的极端抖动也值得为它重写一版。Redis 7.0 将 ziplist 全面替换为 listpack,正是为了消除长期潜伏的性能隐患。面试时如果被问到“为什么 Redis 7.0 用 listpack 替换 ziplist”,把级联更新讲清楚,同时提一下 listpack 如何从源头解决这个问题,基本就稳了。
3.3 内存优化实战:我如何把缓存内存占用砍掉一半
讲原理之外,我给你一个真实可落地的内存优化思路。有一年我接手一个缓存服务,内存涨到接近上限却找不到原因。逐个 key OBJECT ENCODING 排查后发现,一个存用户昵称的 Hash,键只有几十个字段,但 value 大多是 JSON 串,长度超过了 64 字节,全部从 listpack 升到了 hashtable。hashtable 有大量指针开销,存储小对象时内存利用率非常低。
后来做的事很简单:把字段值从完整 JSON 改成精简字符串,把长 JSON 挪到 String 类型单独缓存,Hash 里只保留短字段,让编码重新回到 listpack。目标其实不是省几个字节,而是让数据形态匹配 Redis 的内存友好编码,这样能同时减少内存碎片和序列化开销。
再补充一个容易被忽略的命令:MEMORY USAGE key 可以精确查看某个 key 实际占用的内存字节数。排查大 key 内存时,用 MEMORY USAGE 比看 DEBUG OBJECT 的 serializedlength(序列化长度)更贴近实际内存占用,因为它包含了 key 本身、值对象、底层结构的所有开销。这个命令在集群中定位内存黑洞非常有用。
4. 高频面试题与线上排查实录:别让底层结构只停留在理论
底层结构这类知识,背概念和真正能解决问题之间隔着一条鸿沟。这一章我把面试高频问题和线上实战排查经验放在一起,既给你背题模板,也给你排查问题的切入思路。面试官问底层结构,本质上不是考记忆,而是在考察你有没有真正理解 Redis 的设计哲学。
4.1 面试高频问题清单与参考思路
我整理了几个被问烂但依然有效的题目,配上回答要点,你可以照着这个思路组织自己的语言。
Q1:Redis 的 String 底层为什么不用 C 字符串?
回答方向:从 O(1) 长度获取、缓冲区溢出防护、空间预分配与惰性释放、二进制安全四个点展开。最后补一句“所以 Redis 可以安全存储序列化对象和图片二进制”,立刻显得有实战深度。
Q2:ZSet 底层为什么用跳表而不用红黑树?
回答方向:先讲跳表的多层链表结构,再说实现的简单性,然后分析范围查询场景下跳表的天然优势,最后提内存占用。如果面试官追问为什么不用 B+ 树,可以补充 B+ 树更适合磁盘 IO 的数据库存储,Redis 是纯内存系统,跳表的指针跳跃在内存里是随机访问,性能已经足够。
Q3:ziplist 被 listpack 替换的原因?
回答方向:级联更新问题讲清楚,再对比 listpack 每个 entry 只记录自身长度、切断连锁反应的做法。若再深入,可以提 7.0 的发布说明里列表、哈希、有序集合的小数据量编码统一切到 listpack,set 也在 7.2 中加入了 listpack。
Q4:什么是 big key?怎么发现?
回答方向:big key 指单个 key 的 value 过大或元素过多,比如一个几 MB 的 String、一个百万字段的 Hash。发现方式:redis-cli --bigkeys 扫描、MEMORY USAGE key 逐点确认、慢日志中超过预期的命令定位。影响主要是阻塞主线程和网络传输瓶颈。
Q5:为什么 Redis 单线程还能这么快?
虽然这题不直接对应底层结构,但很常被连在一起问。回答方向:纯内存访问 + 数据结构高效 + IO 多路复用 + 避免线程切换和锁竞争。可以顺带提一句 6.0 之后引入的网络 IO 多线程,但执行命令依然单线程,因为最核心的数据结构设计已经在单线程模型下足够高效。
4.2 我用 object encoding 排查问题的一次完整案例
分享一个我印象深刻的排查经历。线上某服务偶尔出现命令超时,慢日志指向一个 HGETALL 操作,响应时间波动到 100ms 以上。当时这个 Hash 其实就是个用户资料,理论上应该很快才对。
我第一反应是查 OBJECT ENCODING user:profile:1024,返回结果是 hashtable,说明这个 Hash 已经脱离了 listpack 紧凑编码。再查 HLEN,字段数 400 多,远超 128 的阈值。问题找到了:业务上线初期字段少,listpack 编码跑得很欢;随着字段逐渐增多,超过阈值后自动转换成了 hashtable,虽然单次操作复杂度仍接近 O(1),但连续触发的 rehash 在大对象拷贝时产生了明显的 CPU 峰值。
解决方案也很直接:把这份用户资料拆成多个 Hash,每个 Hash 控制字段数在 100 以内,让它们回到 listpack 编码。上线后 HGETALL 耗时稳定在个位数毫秒。这个案例现在回头看很基础,但当时排查了小半天,核心教训是:看到命令慢,别只盯着命令本身,先看数据结构的编码形态对不对。一个已经转成 hashtable 的大 Hash,和小而美的 listpack 小 Hash,性能是两个世界。
4.3 实战中容易踩的隐藏坑
除了 big key,我再列几个实际运行中很容易踩的坑,有的坑我都是真金白银换来的经验。
- List 的中间插入和索引访问不要用于大列表。
LINSERT和LINDEX是 O(N) 操作,List 一旦有几十万元素,这类操作就可能阻塞主线程。用 List 做队列没问题,因为只动两端;但千万别拿它当数组用。 - 不要频繁 SET 大对象到 String。String 的
raw编码每次修改都可能触发内存重分配,如果你反复修改一个几百 KB 的 JSON,SDS 的扩容、拷贝、可能的内存碎片会逐步拖垮性能。这种数据更适合拆字段存 Hash,或者走对象存储。 - Set 的 SMEMBERS 要小心。集合巨大时,
SMEMBERS一次性返回全部元素,网络包和内存都会爆炸。线上遍历集合用SSCAN,不要图省事一把梭。 - 过期删除和内存淘汰也是有开销的。虽然这和数据类型直接关系不大,但当你用带过期时间的 key 时,过期键的惰性删除会卡在某个访问的命令上,所以高峰期偶尔的偶发延迟可能和过期键清理有关,别误判成结构问题。
4.4 排查工具组合:三分钟定位问题的标准动作
最后给一套我常用的排查“标准动作”,当你怀疑某个 Redis key 的数据结构导致性能问题时,按这个顺序执行,基本不会跑偏。
先 TYPE key 确认类型,再 OBJECT ENCODING key 看编码形态,判断它是否处于预期的小数据量紧凑模式。第三步 STRLEN(String)或 HLEN/ZCARD/SCARD/LLEN 看体量级,判断是否超过配置阈值。第四步用 MEMORY USAGE key 看实际内存占用,确认是不是 big key。最后 redis-cli --bigkeys 全库扫描,一轮就能找出所有体量异常的大 key。
这套流程看起来简单,但真正遇到问题时不慌不乱、按步骤来,比随手拍脑袋要快得多。我处理过的多数 Redis 性能问题,最后都能落到“某个 key 的数据量超过编码阈值”或“某个命令复杂度太高”这两个结论上。
结尾
这篇文章写到这里,我其实最想强调的是:Redis 的底层结构不是一堆需要死记硬背的名词,而是围绕“内存效率”和“操作效率”两个核心目标的工程取舍。SDS 为了二进制安全和减少内存重分配,quicklist 为了平衡链表与紧凑存储,listpack 为了消灭级联更新,skiplist 为了用简单实现换范围查询能力——每一个设计背后都有明确的痛点,理解这些痛点,你在选型和排查时就有了判断依据。
我个人在实际操作中的体会是,花一个小时认真看一遍 OBJECT ENCODING 返回的各种编码,配合 MEMORY USAGE 实测几个 key,比单纯背十篇面经都有用。尤其是刚接手一个 Redis 集群时,先跑一遍 --bigkeys,把那些体量异常的 key 全部列出来,能省掉后面无数个加班的夜晚。你甚至可以把这套排查流程和团队的监控告警结合起来,遇到延迟毛刺时自动抓取编码和内存快照,很多问题就不用在事发后盲人摸象了。Redis 的底层知识是那种“平时用不上、出事时救命”的东西,提前把功课做了,比临时抱佛脚实在得多。