一、前言:对象存储的“瑞士军刀”

在 Redis 的五大数据类型中,Hash(哈希) 是最贴近“对象”概念的一个。它允许你将一个对象的多个属性(字段)存储在一个键(key)下,形成一个 field -> value 的映射集合。

从存储用户信息(nameageemail)到管理商品详情(pricestockcategory),再到实现购物车(product_id -> quantity),Hash 无处不在。

但你是否想过,Redis 是如何在底层高效地管理这些字段的?为什么对小对象和大对象的处理方式截然不同?

💡 核心价值
Redis Hash 的底层会根据数据规模,在 ZipList(压缩列表)和 Dict(字典)之间智能切换,以实现内存效率与操作性能的最佳平衡

本文将带你:

  • 拆解 ZipList 的紧凑内存布局
  • 揭秘 Dict 如何实现 O(1) 的字段操作
  • 理解编码转换背后的阈值逻辑

二、Hash 的双重身份:ZipList 与 Dict

Redis Hash 并非只有一种底层实现,而是拥有两种编码(encoding),由 redisObject 的 encoding 字段决定:

编码 (encoding) 底层数据结构 适用场景
OBJ_ENCODING_ZIPLIST (Redis < 7.0)
OBJ_ENCODING_LISTPACK (Redis >= 7.0)
ZipList / ListPack 字段数量少、字段值小
OBJ_ENCODING_HT Dict (字典/哈希表) 字段数量多、字段值大

这种设计体现了 Redis “因地制宜” 的优化哲学:对简单、规则的数据用最省空间的结构;对复杂、庞大的数据用最高效的结构。

注意:从 Redis 7.0 开始,ZipList 已被更安全的 ListPack 取代,以解决“连锁更新”问题。但核心思想不变,本文以 ZipList 为例进行讲解。


三、编码一:ZipList - 小对象的内存奇迹

3.1 诞生背景

当 Hash 中存储的是一个只有几个字段的小对象(如用户ID、姓名、年龄)时,使用通用的哈希表(Dict)会显得“杀鸡用牛刀”。因为哈希表需要为每个字段存储一个完整的 dictEntry 结构(包含 key, value, next 指针等),内存开销较大。

为了极致节省内存,Redis 引入了 ZipList

3.2 源码结构

ZipList 是一块连续的内存区域,其结构如下:

+----------+--------+-------+--------+------+-+
| zlbytes  | zltail | zllen | entry1 | ... |zlend|
+----------+--------+-------+--------+------+-+
  • zlbytes:整个 ZipList 占用的字节数。
  • zltail:尾节点距离起始地址的偏移量,用于快速定位尾部。
  • zllen:节点数量。
  • entryX:实际的字段-值对。每个 entry 存储一个 field 和一个 value,格式为:[prevlen][encoding][data]
  • zlend:结束标记 0xFF

✅ 关键特性

  • 内存连续:所有字段-值对紧密排列,无任何指针开销
  • 紧凑编码:整数直接存数值,短字符串存长度+内容,极大节省空间。
  • 顺序存储field1 -> value1 -> field2 -> value2 -> ...

3.3 致命缺陷:连锁更新(Cascading Update)

ZipList 的最大痛点在于其连锁更新问题。

  • 场景:假设 ZipList 中有三个节点 A, B, C。节点 A 的 value 原本很短,现在被更新为一个很长的值,导致 A 占用的空间变大。
  • 后果:节点 B 的 prevlen(记录前一个节点长度的字段)可能不足以存储 A 的新长度,需要扩容。B 扩容后,又可能导致 C 的 prevlen 不够用,需要扩容... 如此连锁反应,可能引发整个 ZipList 的内存重分配。
  • 性能:最坏情况下,一次更新操作的时间复杂度从 O(1) 退化到 O(N²)

Redis 7.0 的改进:引入 ListPack,移除了 prevlen 字段,从根本上解决了连锁更新问题。

3.4 内存优势

假设一个 Hash 包含 10 个短字段(如 name: "Alice"age: "30"):

  • ZipList:所有数据紧凑存储,无指针,总内存可能只需几百字节。
  • Dict:每个 dictEntry 至少需要 8(key)+8(value)+8(next) = 24字节,加上哈希表本身的桶数组,总内存轻松超过 240+ bytes

内存节省高达 50% 以上!


四、编码二:Dict - 通用的高性能解决方案

一旦 Hash 不再满足 ZipList 的苛刻条件,Redis 会立即将其转换为 Dict(字典)

4.1 为什么是 Dict?

Dict 是 Redis 的基石数据结构之一,它是一个哈希表,天然具备以下特性:

  • O(1) 平均时间复杂度:用于添加 (HSET)、删除 (HDEL)、查找 (HGET) 操作。
  • 支持大量数据:通过渐进式 rehash,可以轻松处理百万级字段。

4.2 Dict 在 Hash 中的用法

在 Hash 的场景下,Dict 的使用非常直接:

  • Key:存储 Hash 的 field(字段名)。
  • Value:存储 Hash 的 value(字段值)。
// 伪代码示意
dict *d = dictCreate(&hashDictType, NULL);
dictAdd(d, "name", "Alice");
dictAdd(d, "age", "30");

✅ 优势:保证了即使 Hash 包含海量字段,性能依然稳定。

4.3 渐进式 Rehash

Dict 本身也有一套精妙的扩容/缩容机制(渐进式 rehash),确保在数据量巨大时,单次操作的延迟依然很低。


五、编码转换:阈值与触发条件

Redis 通过两个配置项来控制 Hash 何时从 ziplist 转换为 hashtable

配置项 默认值 说明
hash-max-ziplist-entries 512 当 Hash 中的字段数量超过此值时,会转换为 Dict。
hash-max-ziplist-value 64 当 Hash 中任意一个字段的值长度超过此值(字节)时,会转换为 Dict。

设计考量

  • 512 和 64 这两个阈值:是内存效率和操作性能的平衡点。超过此限制后,ZipList 的 O(N) 查找和连锁更新风险开始显现,而 Dict 的 O(1) 优势则愈发明显。
  • 即时转换:保证了数据模型的一致性。一旦数据不再是“小而美”,就必须切换到通用模型。

六、动手实验:观察 Hash 的编码变化

6.1 验证 ZipList 编码

# 添加少量短字段
> HSET user:1001 name "Alice" age "30" city "Beijing"
(integer) 3

# 查看编码(Redis 7.0+ 应为 listpack)
> OBJECT ENCODING user:1001
"listpack"

6.2 触发转换:超过字段数量阈值

# 添加第513个字段
> for i in {1..513}; do redis-cli HSET big_hash "field_$i" "value_$i"; done

# 查看编码(应为 hashtable)
> OBJECT ENCODING big_hash
"hashtable"

6.3 触发转换:字段值过长

# 创建一个新Hash,并添加一个超长值
> HSET long_value_hash name "Alice" bio "a...a" # (65个'a')
(integer) 2

# 编码直接为 hashtable
> OBJECT ENCODING long_value_hash
"hashtable"

七、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐