3589 字
18 分钟
数据结构:Hash 哈希底层原理

一、为什么面试必考 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),导致性能下降。查找操作是顺序遍历,时间复杂度为 O(N)O(N)。

2. 哈希表(Hash Table)#

当 Hash 中的数据量增大,或者存入了较大的字符串时,Redis 会将底层结构切换为真正的哈希表(类似于 Java 中的 HashMap 或 Go 中的 map)。

  • 原理:使用数组和链表(解决哈希冲突)来实现。通过计算键的哈希值,将其映射到数组的特定位置。
  • 优势:查找、更新操作的时间复杂度降低为 O(1)O(1)(平均情况下)。即使数据量很大,也能保持极高的查询效率。
  • 劣势:相比压缩列表,哈希表需要维护大量的指针,并且存在空闲槽位,因此会占用更多的内存空间。
数据结构内存占用查找效率CPU 缓存友好度主要风险
Ziplist / Listpack极低O(N)O(N),顺序遍历好(内存连续)连锁更新、数据量大时性能下降
Hash Table高(指针开销)O(1)O(1),平均差(内存不连续)内存占用大、需 Rehash

3. 两种结构的切换条件#

Redis 内部有两个配置参数决定了何时从压缩列表切换到哈希表:

配置参数含义默认值
hash-max-ziplist-entriesHash 中元素的数量超过此值512
hash-max-ziplist-valueHash 中任一元素(Field 或 Value)的长度超过此值64 字节

⚠️ 关键点:只要满足以上任意一个条件,Redis 就会执行转换操作,且这个转换是单向的——一旦转为哈希表,通常不会再转回压缩列表。

三、实际场景解析#

在面试中,你可能需要描述这两种结构在实际场景下的表现。以下是针对这两种结构的底层存储方式的解析。

1. 示例一:使用压缩列表存储小对象#

假设我们在 Redis 中存储一个简单的用户信息:HSET user:1001 name "Alice" age 25。由于数据量小,底层将使用压缩列表。

① 分配连续内存:Redis 会在内存中分配一块连续的空间用于存储这个 Hash。

② 顺序写入键值对:Field 和 Value 交替。按顺序写入:先写入 "name",紧接着写入 "Alice";然后写入 "age",紧接着写入 "25"。

③ 读取数据过程:O(N)O(N) 遍历查找。如果我们要执行 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" 逐个计算哈希值,放入新的哈希表中。

③ 存入新数据:O(1)O(1) 存储。计算新字段 "bio" 的哈希值,将其和它的长文本值放入哈希表中对应的槽位。后续的查询将全部通过哈希表进行,达到 O(1)O(1) 的速度。

四、哈希冲突与渐进式 Rehash#

哈希冲突和渐进式 Rehash 绝对是后端开发面试中 Redis 领域的”重头戏”。面试官问这个问题,本质上是在考察你对 “单线程模型下的性能保护” 以及 “海量数据下的平滑扩缩容” 的理解。

1. 如何解决哈希冲突?(链地址法)#

当 Redis 内部决定使用真正的哈希表(Hash Table)来存储数据时,它的底层结构是一个名为 dict(字典)的 C 语言结构。当不同的 Key 计算出相同的哈希值,被映射到哈希表数组的同一个索引(桶槽)时,就会发生哈希冲突。

Redis 解决冲突的方式是使用最经典的 链地址法(Separate Chaining)。

  • 原理:哈希表数组中的每一个元素(桶)其实是一个指向链表节点的指针。如果发生冲突,Redis 会将新来的键值对组装成一个链表节点,然后挂在这个槽位所在的链表上。
  • 头插法(O(1)O(1) 性能):Redis 采用的是 “头插法”。也就是说,新发生冲突的节点会被直接插入到链表的最头部,而不是尾部。

💡 面试亮点:为什么要用头插法?因为 Redis 想要追求极致的速度。如果是尾插法,每次都需要遍历整个链表找到尾部(时间复杂度 O(N)O(N));而头插法只需要修改指针,时间复杂度永远是 O(1)O(1)。此外,由于 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 会执行以下步骤:

  1. 分配空间:为 ht[1] 分配足够大的内存空间(例如,扩容时,大小通常是目前 ht[0] 包含元素数量的 2 倍的最小 2 的 N 次方)。
  2. 开启状态:在字典中设置一个索引计数器变量 rehashidx,将其值设为 0,标志着渐进式 Rehash 正式开始。
  3. 分步搬运(核心):在 Rehash 期间,每次客户端对这个字典执行 新增、删除、查找或更新 操作时,Redis 除了执行指定的操作外,还会顺手将 ht[0] 在 rehashidx 索引位置上的所有键值对,重新哈希并搬运到 ht[1] 上。搬运完成后,rehashidx 的值加 1。
  4. 后台兜底:如果服务器比较空闲,很久都没有客户端发起操作,Redis 的后台定时任务(serverCron)也会以毫秒级的粒度主动搬运一部分数据,防止 Rehash 过程停滞。
  5. 完成收尾:当 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.2Listpack 逐渐成熟,Redis 社区开始在底层的部分场景试点替换 Ziplist。
Redis 7.0(全面上位)这是一个底层结构的里程碑版本。Redis 官方正式使用 Listpack 全面替换了 Hash、ZSet 中的 Ziplist。同时,List 类型底层 quicklist 内部原本嵌套的 Ziplist 节点,也全部升级换成了 Listpack 节点。

📝 总结:Listpack 本质上是一个去除了 previous_entry_length 字段、将自身长度记录在尾部的”升级版 Ziplist”。它在保留了内存连续性的同时,让每次节点更新的时间复杂度严格保持在常量或线性范围内,彻底避免了最坏情况下的性能灾难。

数据结构:Hash 哈希底层原理
https://shirone.mysqil.com/posts/redis/数据结构-hash-哈希底层原理/
作者
HikariTish
发布于
2026-07-18
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取