☰
Redis Hash底层原理:listpack与hashtable编码切换及内存优化指南
2026/10/22 5:25:26 网站建设 项目流程

1. 从一个日常面试题说起:Redis Hash 到底是怎么存数据的

先聊一个最常见的场景。你去看 Redis 的源码或者面试题,十有八九会碰到“Redis 的 Hash 底层是什么”这个问题。很多人的第一反应是“不就是哈希表吗”,但真正把源码打开一看,会发现它根本不是一句话能说清的。Redis 的 Hash 类型在底层其实有两种编码方式:一种是 ziplist(压缩列表),后来改名为 listpack;另一种是真正的哈希表 hashtable。为什么会有两套方案?核心原因就四个字:省内存。

这里先直接说结论。当 Hash 中的字段数量比较少、单个字段的值也比较短的时候,Redis 会用 listpack 这种紧凑型结构来存储,所有的键值对按顺序挤在一块连续内存里,几乎没有任何额外指针开销。一旦字段多了,或者某个值变大了,Redis 就会自动把它升级成真正的哈希表结构,也就是 dict,用拉链法解决哈希冲突。这个升级是一次性的、不可逆的,没有所谓“降级”机制。

这套设计在实测中非常有效。我见过很多业务把用户属性、商品详情、会话信息这类小对象塞进 Hash 里,字段数量常年维持在几十个以内,每个值不到 100 字节,这种情况下 listpack 的内存占用差不多只有 hashtable 的几分之一。但你要是没搞懂触发条件,就会踩到奇怪的坑:明明只是多加了几个字段,内存突然涨了一截,延迟也变高了。这正是编码切换在起作用。

我准备从底层结构讲起,再逐步拆解依赖关系、渐进式 rehash、查找流程与陷阱,把 Hash 的实现完整梳理一遍。内容更适合已经会用 Redis 基本命令、想深入理解原理的同学。你不用去背源码,跟着思路走一遍,后面遇见类似问题基本都能自己推断出来。

2. 两种底层编码:listpack 和 dict 的选型逻辑

2.1 先认识 listpack:紧凑又省内存的小身板

listpack 的前身是 ziplist,Redis 7.0 以后全面替换成了 listpack。你可以把它理解成一个用一块连续内存手写的“数组”,没有维护 free list,也没有节点之间的指针,每个元素紧紧挨着前一个元素。对于 Hash 来说,listpack 里存储的每一个 entry 要么是一个 key,要么是一个 value,它们交替排列:key1、value1、key2、value2……整个 Hash 对象就是这一串连续字节。

每个 entry 由三部分组成:encoding + len + data。encoding 用来标记数据类型,比如是整数还是字符串;len 表示数据的长度;data 就是实际存储的内容。Redis 为了进一步压缩空间,对于整数类型,干脆把数字直接编码在 encoding 字段里,连 data 字段都可以省掉。这种设计思路跟 protobuf 有点像,都是尽可能用元信息去描述后面的数据,让内存中没有多余的死角。

listpack 的遍历逻辑特别简单:从头开始,根据当前 entry 的 encoding 和 len 计算出下一个 entry 的偏移量,一直走到末尾。整个过程不需要像双向链表那样维护 prev 指针,因此它天然不具备从后往前遍历的能力,也没法快速删除中间节点。Redis 针对 Hash 的很多命令,比如 HGETALL、HDEL,在 listpack 编码下都是先定位到目标 entry,然后做整块内存的 memmove 移动,把后面的数据往前搬。这种操作的代价跟数据块长度成正比,所以一旦 Hash 变大,listpack 的劣势就会立刻暴露出来。

举一个直观的数字。一个仅包含 20 个字段的小 Hash,每个 key 和 value 平均 30 字节左右,在 listpack 编码下可能只占 1.5KB 左右。如果切成 hashtable,光 dictEntry 本身就要占 40 字节上下,再加 key 对象和 value 对象各自的 redisObject 头,轻松破 3KB。差距就是这么大。

2.2 再看 dict:真正的哈希表是怎么搭建的

当 listpack 撑不住的时候,Redis 会把数据迁移到 hashtable。这个 hashtable 就是 Redis 整个键空间的底层实现,Redis 所有 key 都放在这样一个 dict 里。每个 dictEntry 包含三部分:key 指针、value 指针、next 指针。next 指针就是拉链法的链,所有哈希冲突的 entry 会串在同一个桶下面。

dict 本身有两个哈希表,编号为 0 和 1。正常工作时数据只放在 ht[0] 里,ht[1] 只在 rehash 过程中扮演临时中转站的角色。每个 dictEntry 通过哈希函数计算出索引位置,然后挂到对应的桶上。Redis 采用的哈希函数是 MurmurHash 的一个变种或 SipHash(版本不同有差异),核心要求就是分布均匀、计算够快。

为了控制负载因子(load factor),也就是 entry 总数除以桶数量的比值,Redis 会在条件满足时触发扩容或缩容。默认配置下,Redis 的常态负载因子会保持在 1 左右,当负载因子超过 1 并且允许扩容时,桶数量会翻倍;如果负载因子超过 5,即便正在做持久化,也强制扩容。缩容则发生在负载因子小于 0.1 的时候。桶数量始终以 2 的幂次增长,这样计算索引时可以用位运算代替取模,hash & (size - 1),性能更高。

2.3 编码切换的触发条件与实战配置

Redis 对 Hash 编码切换有两个核心配置项,分别是:

  • hash-max-listpack-entries:默认 128,表示字段数量不超过 128 时,允许使用 listpack。
  • hash-max-listpack-value:默认 64,表示每个字段的 key 或 value 长度不超过 64 字节时,允许使用 listpack。

只有在字段数不超过 128 且所有 key 和 value 的长度都不超过 64 字节这两个条件同时成立时,Hash 才会选择 listpack。任何一个条件被打破,Redis 就会触发转换,把整个结构升级成 hashtable。也就是说,如果你的某个字段值是 100 字节的字符串,哪怕字段总数只有 3 个,也直接使用 hashtable。

这里有一个非常容易被忽略的细节:listpack 里存的每个 value 长度是指原始字节长度,不是 UTF-8 字符数。如果你的业务用中文存值,一个汉字在 UTF-8 下占 3 个字节,那么 20 个汉字的字符串就已经 60 字节了,逼近临界值。很多同学在测试环境复现不出内存问题,就是因为用的是英文短字符串,生产环境一上中文数据立刻触发切换。

对大多数业务来说,64 字节这个限制其实是比较省心的。因为 Redis 官方认为,一旦单个 value 超过 64 字节,listpack 省下的内存已经不足以弥补查找和更新时的拷贝开销,不如直接用哈希表。但如果你明确知道自己的 Hash 只用来存少量短字段,且想进一步压缩内存,可以适当调大这两个参数。反过来,如果你的 Hash 字段大小不确定,建议保持默认值,不要盲目把 hash-max-listpack-value 调得很大,否则一次 HGET 触发大块内存读写的延迟会让你很难受。

注意:编码切换是不可逆的。一旦从 listpack 升级到 hashtable,即使之后删除字段、缩短 value,也不会再退回 listpack。原因很简单,Redis 不维护“降级”路径,避免频繁切换带来额外的 CPU 和内存抖动。

3. 重建依赖树:从配置参数到内存布局的完整链路

3.1 Hash 对象的创建流程

当我们执行 HSET myhash field value 时,服务端并不是一开始就创建好最终结构。整个流程大致是:

  1. 根据 key 在全局 dict 中查找 myhash 对应的 redisObject。
  2. 如果对象不存在,创建一个新的 Hash 对象,此时默认按 listpack 编码初始化。
  3. 如果对象已存在,直接进入后续写入流程。
  4. 每当写入一个新字段,都会检查当前字段数量以及新字段 key/value 的长度。
  5. 如果任何一项超出阈值,先执行编码升级,把 listpack 转换成 hashtable,再执行真正的写入。

这里有个隐藏的先后顺序:升级发生在字段写入之前,而不是之后。Redis 的做法是,先判断是否满足切换编码的条件,如果满足,先把已有的 listpack 数据全部重新插入到新建的 hashtable 中,然后才把新的字段添加到 hashtable。这么设计是为了避免先写入、再升级时要做一次多余的插入操作。

最新版本的 Redis(7.x)已经没有 ziplist 相关配置了,统一叫 listpack。如果你在网上搜索资料,看到“hash-max-ziplist-entries”这样的名词,说明那篇文章针对的是 Redis 5.0 或更早版本。两个配置项的作用完全一样,只是内部实现从 ziplist 换成了 listpack,接口和语义保持兼容。

3.2 依赖关系全景图

从源码依赖上看,Hash 的实现可以整理成下面这张逻辑依赖链:

  • Redis 对象系统(redisObject)负责保存编码类型 OBJ_ENCODING_LISTPACK 或 OBJ_ENCODING_HT。
  • 编码类型决定后续调用命令时的具体实现函数。
  • OBJ_ENCODING_LISTPACK 分支会走 listpack 的通用迭代器,依次解析 key/value。
  • OBJ_ENCODING_HT 分支会走 dict 的相关接口,操作真正的哈希表。
  • dict 依赖 ht[0]、ht[1] 两张表、rehashidx 字段、iterators 字段来支撑渐进式扩容。
  • listpack 依赖自身的 encoding、len、data 三段式布局完成顺序读写。

这套依赖关系可以用一个简单的实际场景来串联。假设你想知道某个 Hash 到底用了什么编码,直接执行 OBJECT ENCODING myhash 就能看到结果。如果返回 listpack,说明它还是紧凑模式;如果返回 hashtable,说明已经升级。这个命令在排查内存问题时非常有用,我建议你把它加进日常定位流程,第一时间确认编码状态,再决定要不要优化数据模型。

3.3 内存布局对比

我用一个具体的 Hash 来对比两种编码的内存布局。假设字段如下:

  • name -> tom
  • age -> 28
  • city -> beijing

在 listpack 编码下,内存布局大约是这样:

[entry: name][entry: tom][entry: age][entry: 28][entry: city][entry: beijing]

每个 entry 先写 encoding 和 len,再写实际数据。整数 28 可以直接以内嵌整数形式编码,所以它的 entry 只有 encoding 和 data,甚至没有独立的 len 字段。整体是一段连续内存,Redis 用一个头部结构记录总字节数、元素个数等元信息。

在 hashtable 编码下,结构变成这样:

  • 一个 dict 主要结构,包含两个哈希表数组指针。
  • ht[0] 有若干桶(buckets),每个桶是一个 dictEntry 指针。
  • 每个 dictEntry 内部有 key 指针、value 指针、next 指针。
  • key 和 value 分别是指向 redisObject 的指针。

这个差异带来的内存差距非常直观。hashtable 里每个字段都多出一个独立的 key redisObject、value redisObject 以及一个 dictEntry,光对象头就是三份额外开销。对于几千个这样的小 Hash 来说,内存差距能到一半以上。这也是为什么 Redis 官方坚持保留 listpack 这个看起来“不高级”的结构的根本原因。

4. 核心操作流程拆解:写入、读取、删除、遍历

4.1 写入流程:从 listpack 追加到 hashtable 插入

对 listpack 编码的 Hash 执行 HSET,Redis 会沿着 listpack 逐个比较字段名。这里要注意,listpack 的查找是 O(n) 线性的,比较完一个 entry 之后根据长度跳到下一个 entry。如果字段不存在,就在 listpack 末尾追加两个 entry,一个 key 一个 value。如果字段已存在,则直接替换对应的 value entry。由于 listpack 是连续内存,替换 value 时如果新旧长度不一致,需要先删除旧 entry,再把新数据插入,这就会引发一次内存移动。

对 hashtable 编码的 Hash 执行 HSET,流程就变成:

  1. 对 field 做哈希计算,得到索引。
  2. 在对应桶的链表里查找 field。
  3. 如果找到了,替换 value 指针并释放旧 value。
  4. 如果没找到,创建一个新的 dictEntry,插入链表头部。

整个过程的时间复杂度是 O(1)。你可能会好奇,为什么插入是头插法而不是尾插法?因为头插法不需要遍历链表到底,直接把新节点挂到桶的第一个位置,代码更简单,性能也更好。不过头插法对同一字段的重复写入并不友好,因为链表顺序跟写入顺序相反,但这不影响正确性。

4.2 读取流程:HGET 时 Redis 做了什么

当我们执行 HGET myhash field 时,Redis 先通过 key 找到 Hash 对象,然后根据编码分派到不同的查找逻辑。

listpack 编码下,Redis 逐个 entry 比较字段名,直到找到匹配项,然后读取紧随其后的 value entry。如果字段不存在,需要把整个 listpack 全部扫描一遍才能得出“不存在”的结论。这里有性能陷阱:一个有着 100 个字段的 listpack Hash,读取某个尾部字段,可能需要比较 100 次。虽然单个比较很快,在高 QPS 下还是会造成额外的 CPU 损耗。

hashtable 编码下,Redis 先对 field 做哈希,直接定位到某个桶,再沿着链表查找。理想情况下一次命中,最差情况要看链表长度。除非你的业务出现严重哈希冲突,否则冲突链长度会保持在个位数,性能非常稳定。

实际生产中,如果遇到 QPS 特别高但仍然在用默认 listpack 的小 Hash,可能会出现一种很有意思的现象:内存占用低,但 CPU 占用偏高。因为 listpack 的线性查找在字段数多时并不划算。这时候你需要权衡,是保持低内存还是换更高的吞吐。我个人倾向于,如果单个 Hash 的字段数会稳定超过 100,与其依赖 Redis 自动升级,不如一开始就设计成更小的 Hash 分片,或者直接用 String + JSON,提前绕开性能拐点。

4.3 删除流程:listpack 的痛点与 hashtable 的优势

HDEL 删除字段时,listpack 的劣势特别明显。因为要在连续内存中删除一个 entry,后续所有数据都要往前移动。假设你有一个 128 字段的 listpack Hash,删除第一个字段,理论上要搬动剩余 127 个字段的全部字节。虽然 Redis 内部用了 memmove,速度很快,但数据量大时还是会产生明显延迟。

hashtable 删除则简单很多:找到对应桶,把目标 dictEntry 从链表中断开,释放 key、value 对象,更新计数。整个过程是 O(1) 的,代价非常低。这也是为什么一旦 Hash 变大,Redis 宁可放弃 listpack 也绝不回头。

4.4 遍历流程:顺序读与无序读

HGETALL、HKEYS、HVALS 这类全量遍历命令,在不同编码下的行为也完全不同。

listpack 编码下,顺序扫描所有 entry,读出来的字段顺序跟写入顺序一致。这一点很实用,如果你存的是一个有顺序要求的小配置对象,可以用 Hash 的 listpack 编码白嫖一个“有序字典”效果。

hashtable 编码下,遍历顺序由哈希结果决定,不是写入顺序,也不是字典序,而是一个接近随机的桶遍历顺序。如果你对返回顺序有要求,必须在应用层自己排序。千万不要依赖 HGETALL 的顺序做业务判断,因为哈希函数一旦变化,顺序就变了。同一个 Redis 版本下顺序可能是稳定的,但升级版本后可能完全变样。

5. 渐进式 rehash:大 Hash 扩容时为什么不卡

5.1 rehash 触发的边界条件

hashtable 编码的 Hash 在运行中,如果负载因子超过阈值,就会触发扩容。Redis 选择把 ht[1] 初始化成 ht[0] 的两倍大小,然后把字典的 rehashidx 从 0 开始,标记为 rehash 进行中。

触发扩容的具体条件有两个:

  • 服务器没有执行 BGSAVE 或 BGREWRITEAOF 时,负载因子超过 1。
  • 服务器正在执行 BGSAVE 或 BGREWRITEAOF 时,负载因子超过 5。

为什么持久化期间阈值要调高?因为执行 BGSAVE 时会 fork 子进程,子进程需要复制父进程的页表,如果此时父进程大量写入新内存,就会触发 COW 复制,造成额外内存和 CPU 消耗。为了降低 fork 期间的内存页变化,Redis 故意推迟扩容,尽量复用已有内存页。这是一个典型的内存与性能的权衡设计。

5.2 渐进式 rehash 的步进机制

很多人以为 rehash 是一次性把所有数据从 ht[0] 搬到 ht[1],其实不是。Redis 采用每次处理一个桶的渐进式策略。

rehash 过程中,每次对 dict 的增删改查操作,都会顺带执行一步 _dictRehashStep,把 ht[0] 上当前 rehashidx 指向的那个桶里的所有 entry,重新哈希后迁移到 ht[1],然后把 rehashidx 加一。这样就把一次大搬家分摊到了后续每一次命令执行中。rehash 期间,所有新插入的字段只会被放到 ht[1],ht[0] 只减不增,直到全部迁移完成,Redis 再释放 ht[0],把 ht[1] 变成新的主表。

这个设计的核心价值在于:扩容操作不会阻塞 Redis 主线程太长时间。无论你的 Hash 有多大,最多只会在某一次命令执行时搬迁一个桶,延迟始终可控。相比一次性搬迁,渐进式 rehash 避免了“扩容瞬间卡顿数百毫秒”的极端情况。

不过要注意,rehash 期间查找一个字段需要同时查两张表。Redis 的做法是:先查 ht[0],没命中再查 ht[1]。因为大部分数据应该在 ht[0] 中,这样的顺序能尽量提升命中率。为了保证迁移不遗漏,新字段一律进 ht[1]。

5.3 遍历时遇到 rehash 怎么办

如果有人在 rehash 期间执行 HGETALL 或者 SCAN,Redis 会同时遍历两张表。它先把 ht[0] 上从 rehashidx 到末尾的桶扫完,再扫 ht[1] 的全部桶。这种顺序设计保证了已经迁移到 ht[1] 的 entry 不会被漏掉,也不会被遍历两次。但对于 SCAN 这种增量式遍历,rehash 期间可能出现某个 key 被返回多次或漏掉的情况,这是所有动态哈希表遍历的固有难题。Redis 通过反向二进制位迭代器有效降低了这种概率,但无法完全避免。

如果你在生产环境做大 Key 排查,恰好又遇到 rehash,会发现 SCAN 的结果可能有一点点抖动。这是正常现象,不影响正确性。真正的业务热点数据,建议在低峰期做迁移操作,把影响降到最低。

6. 哈希冲突与扩容策略的底层原理

6.1 为什么链表法在这里够用

hashtable 解决哈希冲突的方式是链表法,也就是每个桶下面挂一个链表。当多个字段算出来同一个索引时,它们会依次挂到同一个桶上。

跟开放寻址法相比,链表法的最明显优点是删除简单。开放寻址法删除一个元素往往需要特殊标记,否则会破坏后续查找的路径;链表法则直接断开节点即可。另一个优点是负载因子可以容忍更高,允许桶数量略小于元素数量,因为链表可以无限增长下去。缺点则是需要额外存储 next 指针,内存占用偏高。

Redis 选链表法还有一个现实原因:它的主线程是单线程。链表法在插入和删除时不需要考虑锁竞争,代码路径更直接。在 C 语言层面,维护一个单向链表也比实现一个复杂的探测序列更简洁、更不容易出错。

6.2 哈希函数的碰撞概率与安全视角

Redis 的 dict 哈希函数不只追求速度快,还考虑了一个安全问题:防止恶意构造 key 造成哈希碰撞攻击。假如哈希函数非常弱,攻击者可以精心构造大量哈希值相同的字段,让所有 entry 都挂到同一个桶下面,把 O(1) 的操作变成 O(n),最终拖垮 Redis。

因此 Redis 在部分版本中引入了 SipHash,它带有一个随机种子,每次进程启动时种子不同。这就导致同一个字段在不同启动实例中算出的哈希值不一样,攻击者无法提前预测哈希结果,也就很难构造针对性的碰撞攻击。这个细节常被忽略,但它是 Redis 作为基础组件在安全性上很重要的设计。

6.3 容量增长为什么是 2 的幂次

Redis 的 dict 扩容时,新桶数量必须是 2 的幂次。原因很纯粹:用位运算替代取模。当你需要计算一个哈希值 h 对应的桶索引时,正常数学逻辑是 h % size,但取模运算在 CPU 层面比较贵。如果 size 是 2 的幂次,那么 h % size 等价于 h & (size - 1),一步位运算搞定。

举个例子,size 为 8 时,size - 1 就是 7,二进制是 111,任何哈希值跟 111 做按位与,只保留低 3 位,正好是 0 到 7 的索引。这个技巧在哈希表实现里非常常见,不只是 Redis 在用。

6.4 遇到大量冲突时的连锁反应

极端情况下,如果某个 Hash 字段数极多,且哈希函数对这批字段分布特别不均匀,会导致某个桶链表特别长。这会让所有针对该字段的查询都退化成链表扫描,性能一落千丈。

但别慌,Redis 哈希函数本身分布很好,正常业务几乎不可能碰到这种问题。真正需要担心的是:你有没有把一个 Hash 当成无限大的集合来用,塞了几百万个字段。这种情况下即便哈希分布均匀,内存、遍历、删除都会是大问题。我见过一个大 Hash 存了几百万条用户行为记录,平时读写只用了其中一小部分,但一次 HGETALL 直接把服务打挂。这种问题跟编码无关,纯粹是数据模型设计失误。

7. 常见问题与排查技巧实录

7.1 为什么我的小 Hash 变成了 hashtable

排查这个问题,先执行 OBJECT ENCODING yourkey 确认编码。如果是 hashtable,再看具体原因:

  1. 单个 value 是否超过了 hash-max-listpack-value 配置值,默认是 64。
  2. 中文、Emoji、Base64 这类字符会让实际字节数远超你肉眼看到的内容。比如一个 20 字的 UUID 是 36 字节,一个 20 个中文的词组就是 60 字节,离 64 很近了。
  3. 字段数是否超过了 hash-max-listpack-entries 配置值。

这里给一个快速验证方法:直接暴力写入一个超长 value 触发升级,然后用 OBJECT ENCODING 确认,再用 DEBUG OBJECT 查看对象信息,把编码状态、内存大小都打出来。这套流程能帮你形成第一直觉:遇到 Hash 相关内存问题,先看编码。

7.2 内存一下子涨了很多,怎么定位

某一天你的 Redis 内存突然多了好几 GB,点开监控发现一堆 Hash 键从 listpack 变成了 hashtable。最直接的原因是字段数或单个 value 长度超过了阈值。想确认具体是哪些键,可以用 SCAN 遍历然后逐个执行 OBJECT ENCODING,但千万别用 KEYS,线上会卡。

更实用的是提前预防。如果你知道某个业务的 Hash 必然要存长文本,就不要让它走 listpack 这条省钱路线了,直接把它设计成 hashtable,然后在业务逻辑上避免全量读写。反过来,若是短字段业务,可以适当调大 listpack 阈值,尽量让数据留在连续内存中。

7.3 HGETALL 大 Key 卡顿怎么办

Hash 变成大 Key 之后,HGETALL、HKEYS、HVALS 这类命令会一次性读出所有字段,造成网络包过大、内存分配过高、主线程阻塞。解决方案不是优化 Redis 编码,而是从业务侧改造:

  • 把一个大 Hash 拆成多个小 Hash,按业务维度分片。
  • 使用 HSCAN 分批读取,每一批只取一定数量的字段,再在应用层合并。
  • 如果是纯缓存数据,干脆换成 String 存储序列化后的整体对象,读一次搞定。

这三种方案各有优劣。拆 Hash 能保持 Redis 的字段级操作能力,HSCAN 不改变数据结构但增加应用层复杂度,String 则牺牲字段级更新能力换取极简读逻辑。我个人倾向于优先拆 Hash,因为字段级操作是 Hash 的核心优势,拆完以后还可以顺手做数据过期,操作更灵活。

7.4 如何判断一个 Hash 是否需要拆分

提供一个简单的判断标准:

  • 如果单个 Hash 字段数接近或超过 10 万,几乎必然要拆。
  • 如果单个字段 value 经常超过几百字节,也要考虑换成 JSON。
  • 如果业务总是需要操作大部分字段,避免让 Hash 承担“大对象”的职责。
  • 如果内存非常敏感且字段短,延迟暂时可接受,小 Hash 用默认 listpack 就好。

7.5 渐进式 rehash 会不会影响访问延迟

正常情况不会。每次命令执行只搬运一个桶,耗时通常在微秒级。但要小心一种特殊情况:你的 Hash 里很多字段在哈希计算后恰好落在同一个桶,也就是说桶负载极不均匀,一次迁移就可能要移动几百万个 entry。这种极端场景会把单次命令卡到几十毫秒甚至更久。

解决办法是:提前监控大 Hash,避免出现超大规模字段的集合。真的遇到了,选择在业务低峰期主动触发扩容,或者直接重启实例让 dict 重建,也能绕开 rehash 高峰期。

7.6 使用 DEBUG OBJECT 查看 Hash 内部信息

排查时可以用:

DEBUG OBJECT myhash

输出里包含 serializedlength、encoding 等信息。serializedlength 代表序列化后的近似内存大小,对估算该键所占内存有参考意义。注意这是调试命令,线上谨慎使用,频繁执行会带来额外开销。

7.7 日常监控指标推荐

建议在两个层面监控 Hash:

  • 对象层面:定期抽样执行 OBJECT ENCODING,统计 listpack 与 hashtable 的比例变化。
  • 内存层面:关注 used_memory 的突增,结合 RDB 分析工具找出大 Key。

如果比例在短时间内出现大量从 listpack 变成 hashtable 的键,说明业务数据模型发生了变化,要么是字段数变大,要么是 value 长度变大,需要及时跟进。

8. 个人实操经验与最终建议

先分享一个我在实际项目中踩过的坑。某个内部系统把用户的基础信息存成 Hash,每个用户一个键,字段大概 30 个左右,value 多是中文字段。一开始内存表现很好,listpack 编码,单实例稳定跑了大半年。后来产品加了一个“备注”字段,随手存了一段 100 多字的文本,结果一夜之间所有用户键全部升级成 hashtable。内存占用直接翻了一倍多,扩容了两台机器才缓过来。

教训很直接:不要忽略编码切换的临界点。你永远不知道业务什么时候会加一个超长字段,所以对 Hash 这种数据结构,上线前就要明确约定 value 长度上限,最好在代码层做强制校验。如果你无法保证长度,就把这个键设计成真正的大表结构,接受 hashtable 的内存成本,而不是让它半路升级。

还有一个细节想提醒:Redis 源码里的 listpack 和 dict 都经过了很多版本的迭代,不同版本的行为可能有所不同。比如 Redis 6.2 之前还叫 ziplist,7.0 换成 listpack 后,内存布局更紧凑,连续内存碎片也更少。但命令层的表现几乎一致,所以你只需要关注 OBJECT ENCODING 返回的结果即可。

最后,如果你只是想用 Redis 的 Hash 存一些少量字段的配置,直接默认配置就好,不用刻意调参。如果你确认自己的字段数永远不会超过 128 且 value 很短,可以把 hash-max-listpack-entries 和 hash-max-listpack-value 稍微调大,让更多键维持 listpack 编码。这种调优带来的收益在大量小对象场景下非常明显,能省下不少内存成本。

但请记住一个原则:任何编码层面的优化都是锦上添花,真正决定 Redis 性能的,是你对业务数据模式的理解。Hash 字段多、值长、频繁全量读写,这些问题不是调配置能解决的,需要从模型设计上做取舍。

懂得什么时候该用 Hash,什么时候该拆成多个键,什么时候该换成别的数据结构,比记住源码里的每一行代码都重要。希望你读完这篇之后,再看 Redis 的 Hash,不再把它当成一个黑盒。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询