一、为什么面试必考 Hash 的底层结构?
Hash(哈希) 是 Redis 中最常用的数据结构之一,特别适合用于存储对象(如用户信息、商品信息等)。作为后端开发实习生,面试官经常会问到 Redis Hash 的底层实现,因为这不仅考察你对 Redis 数据结构的熟悉程度,还能评估你对内存优化和查询效率的理解。掌握压缩列表(Ziplist,在较新版本中被 Listpack 替代)和哈希表(Hash Table)的切换机制,能让你在面试中脱颖而出,展示出你不仅会”用”,更懂”底层原理”。
二、Hash 底层的双结构博弈
Redis Hash 的底层实现并非单一的数据结构,而是为了在”内存占用”和”查询效率”之间取得平衡,采用了两种不同的实现方式,并根据实际情况动态切换。
1. 压缩列表(Ziplist)/ 紧凑列表(Listpack)
当 Hash 存储的元素数量较少,且每个元素的值较小时,Redis 会使用压缩列表(在 Redis 7.0 之后被替换为 Listpack)作为底层实现。
- 原理:压缩列表是一块连续的内存空间,类似于数组,但它没有严格的索引,而是将键值对紧凑地顺序存储。键(Field)和值(Value)交替排列在一起。
- 优势:极大地节省了内存空间。因为数据是连续存储的,减少了内存碎片,也避免了哈希表带来的额外指针开销。
- 劣势:由于是连续内存,当修改数据或数据量变大时,可能会引发连锁更新(Cascade Update),导致性能下降。查找操作是顺序遍历,时间复杂度为 。
2. 哈希表(Hash Table)
当 Hash 中的数据量增大,或者存入了较大的字符串时,Redis 会将底层结构切换为真正的哈希表(类似于 Java 中的 HashMap 或 Go 中的 map)。
- 原理:使用数组和链表(解决哈希冲突)来实现。通过计算键的哈希值,将其映射到数组的特定位置。
- 优势:查找、更新操作的时间复杂度降低为 (平均情况下)。即使数据量很大,也能保持极高的查询效率。
- 劣势:相比压缩列表,哈希表需要维护大量的指针,并且存在空闲槽位,因此会占用更多的内存空间。
| 数据结构 | 内存占用 | 查找效率 | CPU 缓存友好度 | 主要风险 |
|---|---|---|---|---|
| Ziplist / Listpack | 极低 | ,顺序遍历 | 好(内存连续) | 连锁更新、数据量大时性能下降 |
| Hash Table | 高(指针开销) | ,平均 | 差(内存不连续) | 内存占用大、需 Rehash |
3. 两种结构的切换条件
Redis 内部有两个配置参数决定了何时从压缩列表切换到哈希表:
| 配置参数 | 含义 | 默认值 |
|---|---|---|
hash-max-ziplist-entries | Hash 中元素的数量超过此值 | 512 |
hash-max-ziplist-value | Hash 中任一元素(Field 或 Value)的长度超过此值 | 64 字节 |
⚠️ 关键点:只要满足以上任意一个条件,Redis 就会执行转换操作,且这个转换是单向的——一旦转为哈希表,通常不会再转回压缩列表。
三、实际场景解析
在面试中,你可能需要描述这两种结构在实际场景下的表现。以下是针对这两种结构的底层存储方式的解析。
1. 示例一:使用压缩列表存储小对象
假设我们在 Redis 中存储一个简单的用户信息:HSET user:1001 name "Alice" age 25。由于数据量小,底层将使用压缩列表。
① 分配连续内存:Redis 会在内存中分配一块连续的空间用于存储这个 Hash。
② 顺序写入键值对:Field 和 Value 交替。按顺序写入:先写入 "name",紧接着写入 "Alice";然后写入 "age",紧接着写入 "25"。
③ 读取数据过程: 遍历查找。如果我们要执行 HGET user:1001 age,Redis 会从头部开始顺序遍历这个连续内存,先看到 "name",跳过它的值,找到 "age",然后读取并返回其后面的值 "25"。
2. 示例二:切换为哈希表存储长文本
现在,我们给这个用户添加一段很长的个人简介(超过 64 字节):HSET user:1001 bio "这是一段非常非常长的个人简介,超过了默认的限制..."。
① 触发阈值:超过 hash-max-ziplist-value。Redis 发现即将存入的 Value 长度超过了 64 字节的限制。
② 结构转换:从 Ziplist 到 Hash Table。Redis 停止使用压缩列表,申请一个新的哈希表结构。将原来压缩列表中的 "name"、"Alice"、"age"、"25" 逐个计算哈希值,放入新的哈希表中。
③ 存入新数据: 存储。计算新字段 "bio" 的哈希值,将其和它的长文本值放入哈希表中对应的槽位。后续的查询将全部通过哈希表进行,达到 的速度。
四、哈希冲突与渐进式 Rehash
哈希冲突和渐进式 Rehash 绝对是后端开发面试中 Redis 领域的”重头戏”。面试官问这个问题,本质上是在考察你对 “单线程模型下的性能保护” 以及 “海量数据下的平滑扩缩容” 的理解。
1. 如何解决哈希冲突?(链地址法)
当 Redis 内部决定使用真正的哈希表(Hash Table)来存储数据时,它的底层结构是一个名为 dict(字典)的 C 语言结构。当不同的 Key 计算出相同的哈希值,被映射到哈希表数组的同一个索引(桶槽)时,就会发生哈希冲突。
Redis 解决冲突的方式是使用最经典的 链地址法(Separate Chaining)。
- 原理:哈希表数组中的每一个元素(桶)其实是一个指向链表节点的指针。如果发生冲突,Redis 会将新来的键值对组装成一个链表节点,然后挂在这个槽位所在的链表上。
- 头插法( 性能):Redis 采用的是 “头插法”。也就是说,新发生冲突的节点会被直接插入到链表的最头部,而不是尾部。
💡 面试亮点:为什么要用头插法?因为 Redis 想要追求极致的速度。如果是尾插法,每次都需要遍历整个链表找到尾部(时间复杂度 );而头插法只需要修改指针,时间复杂度永远是 。此外,由于 Redis 是单线程模型,不需要像多线程环境(比如 Java 早期的 HashMap)那样担心头插法引发的并发死链问题。
2. 什么是渐进式 Rehash?(平滑过渡的艺术)
随着数据的不断写入或删除,哈希表里的元素会越来越多(导致链表过长,查询变慢)或越来越少(导致内存浪费)。这时,Redis 需要对哈希表进行扩容或缩容,这个重新计算哈希值并将节点放入新表的过程就叫 Rehash。
2.1 为什么需要”渐进式”?
假设你的 Redis 哈希表里存了 1000 万个键值对,如果一次性把这 1000 万个数据重新计算哈希并搬运到新表,由于 Redis 是单线程处理命令的,这个庞大的搬运工程会耗费几秒甚至十几秒钟。在这期间,Redis 会完全阻塞,无法响应任何客户端的读写请求,这在生产环境中是绝对灾难性的。
因此,Redis 采用了 “分而治之” 的策略——渐进式 Rehash,把一次性集中完成的庞大工作量,分散到后续的每一次操作中。
2.2 渐进式 Rehash 的详细过程
Redis 的字典(dict)结构里,其实包含了 两个 哈希表数组:ht[0] 和 ht[1]。平常只使用 ht[0],ht[1] 是空的。
当需要扩容或缩容时,Redis 会执行以下步骤:
- 分配空间:为
ht[1]分配足够大的内存空间(例如,扩容时,大小通常是目前ht[0]包含元素数量的 2 倍的最小 2 的 N 次方)。 - 开启状态:在字典中设置一个索引计数器变量
rehashidx,将其值设为0,标志着渐进式 Rehash 正式开始。 - 分步搬运(核心):在 Rehash 期间,每次客户端对这个字典执行 新增、删除、查找或更新 操作时,Redis 除了执行指定的操作外,还会顺手将
ht[0]在rehashidx索引位置上的所有键值对,重新哈希并搬运到ht[1]上。搬运完成后,rehashidx的值加 1。 - 后台兜底:如果服务器比较空闲,很久都没有客户端发起操作,Redis 的后台定时任务(
serverCron)也会以毫秒级的粒度主动搬运一部分数据,防止 Rehash 过程停滞。 - 完成收尾:当
ht[0]的所有节点都被全部搬运到ht[1]之后,Redis 会把ht[1]变成新的ht[0],释放旧的ht[0]内存,并创建一个新的空ht[1],最后把rehashidx重新设为-1,标志 Rehash 彻底完成。
2.3 Rehash 期间的读写规则(面试高频追问)
既然数据分布在两个表里,此时如果有读写请求进来,Redis 怎么处理?
| 操作类型 | 处理规则 |
|---|---|
| 查找 / 更新 / 删除(GET / SET / DEL) | 先在 ht[0] 里找,若未找到再去 ht[1] 里找 |
| 新增(ADD) | 一律只保存到 ht[1] 中,绝对不会再放到 ht[0] 里 |
⚠️ 关键点:新增加的键值对一律只保存到
ht[1]中,这就保证了ht[0]里的数据只会越来越少,最终变成空表。
3. 结合 Golang 后端开发的加分项
如果你在面试中不仅讲清了 Redis 的原理,还能把它和 Go 语言联系起来,这就是绝对的降维打击了:
其实 Go 语言内置的
map在扩容机制上,和 Redis 的渐进式 Rehash 有着异曲同工之妙。Go 的 map 在触发扩容时,也会分配一个更大的buckets数组,但它也不会一次性把旧的oldbuckets迁移完,而是采用 渐进式驱逐(Evacuation)。在每次向 map 插入或修改数据时,Go 运行时最多会搬运 2 个 bucket 的数据到新数组中。这两者都是典型的为了避免出现长时间的性能抖动(Latency spike)而采用的平滑过渡策略。
五、Listpack:彻底终结连锁更新
既然前面提到了 Ziplist(压缩列表)因为 previous_entry_length 字段导致的连锁更新(Cascade Update) 噩梦,那么顺理成章地,Redis 的核心开发者必须解决这个问题。
Listpack(紧凑列表) 就是 Redis 官方为了彻底干掉 Ziplist 而设计出的完美替代品。
1. 什么是 Listpack?
Listpack 也是一块连续的内存空间,其核心目的与 Ziplist 完全一样:极致节省内存。它在宏观上的表现和 Ziplist 几乎一致,没有传统的链表指针,对 CPU 缓存极为友好。
它最大的壮举在于:在保持极高内存利用率的同时,从数据结构底层彻底杀死了”连锁更新”的可能性。
2. 它是如何根除”连锁更新”的?
我们可以回顾一下,Ziplist 发生连锁更新的”罪魁祸首”是:每个节点里存了上一个节点的长度(previous_entry_length)。前一个节点变大,当前节点的这个字段就可能被迫膨胀,进而引发多米诺骨牌效应。
Listpack 的破局思路极其简单粗暴:砍掉这个关联!
在 Listpack 中,节点不再关心上一个节点有多长,它只记录自己的长度。既然我不记录你,你变大变小就跟我毫无关系,连锁更新的导火索自然就被完全掐断了。
3. Listpack 的微观结构
既然不记录上一个节点的长度,那 Listpack 是怎么实现从后往前倒序遍历的呢?(双向遍历是 List、ZSet 等结构的基本需求)。
这就是 Listpack 节点(Entry)设计的精妙之处。每个 Listpack Entry 包含三个部分:
| 字段 | 作用 |
|---|---|
encoding | 记录当前节点的数据类型(整数还是字符串)以及数据的实际长度 |
data | 存储具体的业务内容 |
backlen | ⚠️ 核心设计:存储当前 Entry 本身的长度,并被放置在 Entry 的最末尾 |
如何实现反向遍历?
当 Redis 内部的指针停留在某个 Entry 的开头时,如果想找上一个节点,指针只需要往后退一步(减去一点指针偏移量),就能精准读取到上一个节点末尾的 backlen。既然知道了上一个节点的总长度,指针再往回跳跃这个长度的距离,就完美定位到了上一个节点的开头!
这种”把自身长度写在尾部”的设计,既满足了双向遍历的需求,又做到了节点之间的绝对空间解耦。
4. Listpack 在 Redis 中的演进历程
作为后端开发实习生,如果在面试中能准确说出底层数据结构的版本演替,能向面试官证明你不仅在背”八股文”,而且真正在关注核心技术的生命周期:
| 版本 | 演进节点 |
|---|---|
| Redis 5.0(初露锋芒) | Listpack 首次被引入,但最初只是为了服务当时新增的数据类型 Stream(Redis 实现的消息队列)。 |
| Redis 6.2 | Listpack 逐渐成熟,Redis 社区开始在底层的部分场景试点替换 Ziplist。 |
| Redis 7.0(全面上位) | 这是一个底层结构的里程碑版本。Redis 官方正式使用 Listpack 全面替换了 Hash、ZSet 中的 Ziplist。同时,List 类型底层 quicklist 内部原本嵌套的 Ziplist 节点,也全部升级换成了 Listpack 节点。 |
📝 总结:Listpack 本质上是一个去除了
previous_entry_length字段、将自身长度记录在尾部的”升级版 Ziplist”。它在保留了内存连续性的同时,让每次节点更新的时间复杂度严格保持在常量或线性范围内,彻底避免了最坏情况下的性能灾难。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 89 天
部分内容可能已过时