一、为什么面试必考 List 的底层结构?
作为后端实习生,在 Redis 面试中,List(列表) 的底层数据结构是一个高频考点。面试官不仅想知道你会用 LPUSH 和 RPOP 这样的基本命令,更想考察你对 Redis 内存优化的理解。理解双向链表、压缩列表以及 quicklist 的演进过程,能向面试官展示你具备扎实的基础知识和关注底层性能的意识。
二、List 底层结构的演进总览
Redis 的 List 数据类型在底层并非一成不变,而是随着版本的迭代经历了从双向链表(LinkedList) 和压缩列表(Ziplist) 到统一使用 quicklist 的演变过程。
三、早期版本:双向链表与压缩列表的博弈
在较早的 Redis 版本(3.2 之前)中,List 类型的底层实现会根据数据的特征在两种结构之间切换。
1. 双向链表(LinkedList)
- 特点:标准的链表结构,每个节点包含前驱指针、后继指针和数据。
- 优点:插入和删除操作效率高(时间复杂度 )。
- 缺点:内存开销大。每个节点都需要额外的指针空间;节点在内存中不连续,容易产生内存碎片,降低缓存命中率。
2. 压缩列表(Ziplist)
- 特点:为了节约内存而开发的一块连续内存块组成的顺序型数据结构。
- 优点:极度节省内存;内存连续,对 CPU 缓存友好。
- 缺点:插入和修改操作可能引发连锁更新(Cascade Update),导致性能急剧下降;不适合存储大量或大尺寸的数据。
3. 两种结构的切换条件
只有当 List 元素数量较少且每个元素较短时,Redis 才会使用压缩列表。一旦超过阈值(通常是元素数量过多或元素体积过大),就会退化为双向链表。
| 数据结构 | 内存占用 | 插入/删除效率 | CPU 缓存友好度 | 主要风险 |
|---|---|---|---|---|
| LinkedList | 高(指针开销) | ,高效 | 差(内存不连续) | 内存碎片、缓存命中率低 |
| Ziplist | 极低 | ,可能更差 | 好(内存连续) | 连锁更新 |
四、现代 Redis 标配:quicklist(快速列表)
从 Redis 3.2 开始,为了结合 LinkedList 和 Ziplist 的优点,同时规避它们的缺点,Redis 引入了 quicklist。
1. 概念与结构
quicklist 本质上是一个由 Ziplist 组成的双向链表。外层是一个双向链表,但链表中的每个节点不再只存一个元素,而是存储一个 Ziplist(或者压缩后的 Ziplist)。
2. 核心优势
- 内存优化:节点内部使用 Ziplist,复用了其极高空间利用率的优点。
- 缓解连锁更新:虽然 Ziplist 仍可能发生连锁更新,但由于 quicklist 将大 List 拆分成多个小的 Ziplist 节点,连锁更新的范围被严格限制在单个节点内部,不会波及整个数据结构。
- 灵活性:可以通过配置参数(如
list-max-ziplist-size)调整每个 Ziplist 节点的大小,在内存利用率和修改性能之间寻找平衡。
📝 总结:quicklist 是一种折中方案,它在时间效率和空间效率之间取得了良好的平衡,是 Redis 内存优化艺术的典型体现。
五、面试常见问题解析
问题一:为什么 Redis 要引入 quicklist 取代之前的双向链表和压缩列表?
回答这道题可以分三步走:
- 指出旧方案的痛点:LinkedList 和 Ziplist 的局限 LinkedList 内存碎片多、指针开销大;Ziplist 虽省内存,但数据量大时修改可能导致耗时的”连锁更新”。
- 解释 quicklist 的结构:双向链表 + 压缩列表 quicklist 是一种宏观上是双向链表、微观节点是 Ziplist 的混合结构。
- 总结核心优势:取长补短 quicklist 既保留了 Ziplist 的内存连续性和高空间利用率,又通过将数据拆分到多个节点中,有效限制了”连锁更新”的爆炸范围,综合性能更好。
问题二:什么是 Ziplist 的连锁更新问题?
💡 面试加分点:能从宏观的数据结构切换,深入到微观的字节级编码,再到动态的内存再分配过程,足以向面试官证明你拥有极强的底层钻研能力。
下面我们抽丝剥茧,彻底把压缩列表(Ziplist) 的结构和连锁更新(Cascade Update) 的来龙去脉理清楚。
六、深度解析:Ziplist 的内部结构
为了节约内存,Redis 并没有像传统的双向链表那样为每个节点分配独立的空间并用指针连接,而是开发了 Ziplist。
1. 宏观上看:连续的内存块
Ziplist 是一块连续分配的内存,对 CPU 缓存极其友好。它就像一节一节紧密相连的火车车厢,没有指针开销。
2. 微观上看:Entry 节点的精妙设计
真正引发连锁反应的,是这列”火车”里每一节”车厢”(Entry 节点)的内部结构。每个 Entry 包含三个关键部分:
| 字段 | 作用 |
|---|---|
previous_entry_length | 记录前一个节点的长度 |
encoding | 记录当前节点的属性(数据类型和长度) |
content | 实际存储的数据内容 |
七、核心导火索:254 字节的阈值
Redis 为了将内存榨干到极致,对 previous_entry_length 字段本身的大小也做了动态分配:
- 如果前一个节点的长度 < 254 字节:那么
previous_entry_length只需要占用 1 字节 的空间。 - 如果前一个节点的长度 ≥ 254 字节:那么 1 个字节存不下了,
previous_entry_length必须扩展为占用 5 字节 的空间(其中第 1 个字节固定为 254 作为标记,后 4 个字节存实际长度)。
⚠️ 关键点:这个 1 字节 与 5 字节 的动态切换,就是埋下连锁更新隐患的定时炸弹。
八、多米诺骨牌倒下:连锁更新的过程
想象一个极端的场景:你现在有一个 Ziplist,里面有连续多个节点(例如 Node 1 到 Node 5),它们的实际长度刚好都在 250 到 253 字节 之间。
因为它们都小于 254 字节,所以它们每个节点的 previous_entry_length 都只占用 1 字节。
触发连锁反应的步骤
- 突发变故:你在最前面(Node 1 之前)插入了一个名为 New Node 的新节点,并且它的数据很大,长度达到了 260 字节。
- 第一张骨牌:原来的 Node 1 现在跟在 New Node 后面。它发现前一个节点(260 字节)突破了 254 字节的阈值。于是,Node 1 的
previous_entry_length被迫从 1 字节扩展到 5 字节。 - 蝴蝶效应:Node 1 增加了 4 个字节的体积!假设 Node 1 原本是 251 字节,现在变成了 255 字节。
- 第二张骨牌:Node 2 发现它的前一个节点(现在的 Node 1,255 字节)也突破了 254 字节阈值!于是 Node 2 的
previous_entry_length也被迫从 1 字节扩展到 5 字节。 - 雪崩:Node 2 变大,导致 Node 3 扩展;Node 3 变大,导致 Node 4 扩展……一连串的空间扩展反应就此发生。
📝 补充:这种连锁反应不仅限于插入操作,修改节点内容导致其长度跨越 254 字节阈值时,同样会引发。
九、连锁更新的致命影响
| 影响维度 | 具体表现 |
|---|---|
| 疯狂的内存重分配 | 每次节点的扩展,都意味着 Redis 需要对整个 Ziplist 连续内存块执行空间重分配(realloc)操作,并拷贝后续的数据。 |
| 性能断崖式下跌 | 正常的插入操作时间复杂度是 ,但在最坏情况下(发生连锁更新),每次更新都要进行 N 次内存重分配,导致时间复杂度直接退化为 。这对于单线程的 Redis 来说,会造成严重的线程阻塞,直接影响整体吞吐量。 |
这也是为什么早期版本中,一旦元素变多或变大,Redis 就会将其退化为双向链表,以及现代版本中强制使用 quicklist 将 Ziplist 切片,以此来将”爆炸范围”严格限制在单个 quicklist 节点内部的原因。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 89 天
部分内容可能已过时