news 2026/10/3 11:25:38

Redis Hash底层编码揭秘:ziplist、listpack与hashtable如何选

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis Hash底层编码揭秘:ziplist、listpack与hashtable如何选

很多人在业务里第一次用 Redis 存对象,第一反应就是 string:对象转成 JSON,塞进去,完事。直到后来要改其中一个字段,才发现每次都要先 get、再反序列化、改完再序列化、最后 set,既麻烦又容易踩并发覆盖的坑。这时候大家才会注意到 Redis 的 value 类型里还有一个专门干这事的 hash——同一个 key 底下挂一堆 field-value,改哪个字段就动哪个字段。但 hash 用着用着,我遇到过一个更隐蔽的问题:同一个 hash 类型的 value,底层可能有两种完全不同的编码方式,老的叫 ziplist,新的叫 listpack,另一种则是大家更熟悉的 hashtable。这两种编码对内存占用、读写性能、甚至返回结果的顺序都有影响,很多人用到生产环境了,还没搞清楚它们是怎么触发、怎么切换的。这篇文章就以 hash 为主线,把 value 类型的编码机制讲透,顺便分享几个我实际排查时踩过的坑。

1. hash这个类型到底是干什么的:从一张业务表说起

1.1 它和string的本质区别

先明确一个最基本的问题:hash 和 string 到底差在哪?

假设要存用户信息,字段包括 name、age、city 三个属性。用 string 的方案是:

  • key 是user:1001
  • value 是{"name":"alice","age":20,"city":"beijing"}这样的 JSON 字符串

用 hash 的方案则是:

  • key 还是user:1001
  • 但下面挂了三个 field:name、age、city,每个 field 有各自独立的 value

这时候要改年龄。string 方案必须先把整串 JSON 拿出来,改掉 age,再整体写回去。hash 方案只需要一条命令:

HSET user:1001 age 21

别小看这个差异。在并发场景下,string 方案的“读-改-写”三步很容易丢更新,你得加锁或者用 Lua 脚本;而 hash 天然支持单字段级别的原子更新,HINCRBY user:1001 age 1一条命令就能把年龄加一,根本不需要考虑中间状态。

我自己的体会是:string 适合存“整体读、整体写”的数据,比如一份配置快照;hash 适合存“结构固定、但需要频繁修改其中某几个字段”的结构化对象。从数据模型角度看,hash 实际上是把一个“小型关系表的一行”塞进了一个 key 里,字段就是列名,值就是列值。

1.2 适合hash的场景

除了用户信息,我实际项目里常用的场景还有这几类:

  • 对象缓存:商品、订单、文章这类结构化数据,天然按 ID 拆 key,字段对应属性。
  • 计数分组:比如统计文章每天不同渠道的阅读量,key 是article:1001:read,field 是channel_wechat、channel_search,用 HINCRBY 逐渠道累加。
  • 购物车:商品 ID 作 field,数量作 value,加购就是 HSET,改数量就是 HINCRBY。
  • 配置项集合:一个业务模块下面几十个配置开关,每个开关是 field,用 hash 打包管理比散落成几十个 string key 更清晰。

hash 不适合的场景也要心里有数:如果 field 数量会无限增长,比如“每个用户的所有操作日志”都塞进一个 hash,这一定是个灾难;如果需要 field 级别独立过期,hash 也做不到,因为过期时间只能挂在 key 上;如果要做范围查询或排序,hash 同样不合适,那是 zset 的主场。

2. 两种编码方式背后的设计逻辑:ziplist与hashtable

2.1 hashtable:教科书级别的哈希表实现

先说大家最熟悉的 hashtable 编码。Redis 里的哈希表结构,跟你在算法课上学过的没什么本质区别:一个 table 数组,数组每个位置是一个桶,桶里挂着 dictEntry 的单向链表。dictEntry 里保存了 key 指针、value 指针和下一个节点的指针。

Redis 的 dict 在扩容时做的是渐进式 rehash:不是一次性把旧表数据全搬过去,而是维护一个rehashidx字段,每次增删改查命令执行时顺便搬一部分桶,把搬运开销平摊到多次操作里,避免一次 rehash 卡住整个 Redis 主线程。

哈希查找的时间复杂度是 O(1),理想情况下不管 hash 里有多少字段,找某个 field 都很快。代价就是内存开销大:

  • 每个 field 要做成一个独立的 RedisObject 对象,光对象头就占 16 字节。
  • 每个 field-value 对要有自己的 dictEntry,约 24 字节。
  • 哈希表本身要留桶数组,而且桶的数量会按 2 的幂扩容,经常出现大量空桶。

所以 hashtable 是典型的“用空间换时间”。

2.2 ziplist:把一堆小数据塞进连续内存

ziplist 走的是另一个极端。它是一块连续的内存区域,没有指针,没有对象头,所有字段的数据紧凑地一个挨一个排列在一起。

ziplist 整体结构分为几个部分:zlbytes 记录整个列表占用的字节数,zltail 记录最后一个节点的偏移量,zllen 记录节点总数,然后是若干 entry 节点,最后是 zlend 结束标记。

每个 entry 又拆成三块:prevlen、encoding、data。

  • prevlen 记录前一个 entry 的长度,这样从尾部往头部遍历时,可以立刻知道上一个节点在哪;
  • encoding 记录 data 的编码类型,比如是整数还是字符串、字符串有多长;
  • data 就是实际的 field 名称或 value 内容。

这种设计的直接好处是省内存。一个小整数 field 在 ziplist 里可能只占几个字节,而在 hashtable 里光是 RedisObject 对象头就是 16 字节,还不算 dictEntry 和桶数组。所以当 hash 的字段数量少、单个字段值也小时,ziplist 能省下非常可观的内存。

代价是查找效率。ziplist 没有索引结构,找一个 field 就得从头部或尾部顺着链子线性扫描,最坏情况是 O(n)。几百个字段时感觉不出来,几千几万个字段时就会变成性能瓶颈。

2.3 为什么需要两套方案

一句话总结:小数据用 ziplist 省内存,大数据用 hashtable 省时间。

Redis 是个内存数据库,内存就意味着成本,能省一点是一点。同时 Redis 又是单线程模型,任何一条命令都不能长时间阻塞主线程,所以时间上也不能妥协。官方给的策略就是“分段组合”:字段少、值小的时候用紧凑结构把内存压下来,一旦数据规模大到线性扫描扛不住了,就整体换成哈希表,用 O(1) 查找兜底。

这里还藏着一个容易被忽略的细节:RDB 持久化时,ziplist 编码的 hash 会被当成一个紧凑的 blob 整体序列化,加载时直接还原成紧凑内存结构;而 hashtable 编码的 hash 则需要逐个 field-value 保存和重建。这也意味着,编码方式不仅影响运行期的内存和速度,还影响持久化文件的体积和加载速度。

3. 编码转换的阈值和配置:官方默认值背后的玄机

3.1 转换条件与默认参数

hash 什么时候从 ziplist 转成 hashtable?规则特别简单,两个条件满足任意一个就转换:

  • field 数量超过hash-max-ziplist-entries,默认是 512;
  • 任意一个 field 的名称长度或 value 长度超过hash-max-ziplist-value,默认是 64 字节。

注意这两个条件是“或”的关系,不是“且”。也就是说,哪怕只有 100 个字段,但某个 field 的 value 是个 65 字节的字符串,这个 hash 也会立刻转成 hashtable。

怎么确认当前 hash 到底是什么编码?用命令OBJECT ENCODING:

> HSET user:1001 name alice age 20 (integer) 2 > OBJECT ENCODING user:1001 "ziplist"

这是 Redis 7.0 之前的表现。7.0 之后,同样的操作你会看到listpack,这个下一章专门讲。

我刚接触 Redis 时一直有个误解,以为这两个阈值是“同时超过才转换”,直到有一次测试数据量在阈值附近来回横跳,才发现只要有一个条件超了,编码立刻变。

3.2 阈值的来历和调整思路

512 和 64 这两个数字不是拍脑袋定的。官方在压测中发现,在一个紧凑排列的 ziplist 里线性扫描几百个 entry,最坏情况下的开销依然远小于一次网络 IO 的延迟,完全在可接受范围内。64 字节则是因为单个 entry 超过这个大小后,内存搬移和复制成本会明显上升,紧凑结构带来的收益开始被复制开销抵消。

但在实际业务里,这两个默认值不一定适合你。我的调整经验是:

  • 如果业务里的 hash 字段平均都很短(比如几字节的 ID 或状态码),字段总数又长期稳定在几百个,可以适当调大 entries,让更多 hash 继续留在紧凑编码,省下内存;
  • 如果某个 hash 的 field 里存的是 URL、Token、序列化片段这类长文本,建议主动调小 value 阈值,让这类 hash 早点转成 hashtable,避免在紧凑编码里反复搬运大块内存;
  • 要记住这个配置是全局生效的,对所有 hash 类型的 key 都起作用,不是针对某一个 key 设置的。

查看和修改配置用 CONFIG 命令:

CONFIG GET hash-max-* CONFIG SET hash-max-ziplist-entries 1024 CONFIG SET hash-max-ziplist-value 128

CONFIG SET 是运行时生效,但重启后会丢。要持久化的话,记得同步改 redis.conf 里的对应项。

3.3 转换是单向的吗

这个问题我问过不少人,答案是:hash 从 ziplist 转成 hashtable 之后,即使后面删掉大量字段,数量降到阈值以下,Redis 也不会自动转回 ziplist。

原因很简单:降级操作在工程上收益不高,却要额外承担一次重建结构的 CPU 开销,而且如果数据反复在阈值边缘横跳,Redis 可能会频繁地转换来转换去,平白消耗性能。所以 Redis 只在“从无到有、从小到大的方向”做升级转换,不做反向降级。

这个特性对运维很重要。如果线上有个 hash 因为高峰期的字段数暴增转成了 hashtable,后面业务收缩字段数降下来了,它依然保持 hashtable。你想让它重新变成紧凑编码,只能把这个 key 删掉,再让它从零开始重新写入。

4. Redis 7.0之后的变化:listpack登场

4.1 ziplist的连锁更新隐患

ziplist 虽然省内存,但有一个理论上的性能隐患,叫“连锁更新”。

回到前面的结构:每个 entry 的 prevlen 记录的是前一个 entry 的长度。当上一个 entry 的长度小于 254 字节时,prevlen 只需要 1 字节;一旦上个 entry 的长度达到或超过 254 字节,prevlen 就要扩容成 5 字节。

现在设想一个极端场景:ziplist 里有一连串 entry,每个 entry 的长度都在 250 字节左右,prevlen 都恰好是 1 字节。这时如果在某个位置插入一个 254 字节的新 entry,它后面那个 entry 的 prevlen 就得从 1 字节变成 5 字节,这个 entry 的总长度随之变大,又可能触发再后面一个 entry 的 prevlen 扩容……像多米诺骨牌一样一路推下去。

最坏情况下,一次插入操作要引发 O(n²) 次字节拷贝。虽然现实中很难精准构造这种数据,但对于一个被高频写入的 hash 来说,这始终是一颗定时炸弹。

4.2 listpack怎么解决

listpack 的设计目标就是干掉连锁更新。它把“记录前一个节点的长度”改成了“记录当前节点的长度”。具体做法是:每个 entry 的末尾加一个 backlen 字段,专门记录本节点从头部到末尾的完整长度。

后一个节点根本不需要关心前一个节点有多长,它自己长什么样、占多少内存,只取决于自身数据。这样前面节点怎么变,都不会影响后面节点的布局,连锁更新从根上被消灭了。

从遍历方式上看,listpack 仍然支持双向遍历:从前往后走,每个 entry 自带 encoding 信息能算出长度;从后往前走,就看每个 entry 尾部那个 backlen 字段,直接跳到上一个节点的起点。

Redis 从 7.0 开始,hash 和 zset 在创建新 key 时,默认的紧凑编码不再是 ziplist,而是 listpack。你再用 OBJECT ENCODING 看小 hash,返回的就是listpack。后续版本里,list 类型底层的 quicklist 节点也迁移到了 listpack。

4.3 新配置项与兼容性

listpack 成了默认编码之后,配置项也换了新名字:

旧配置(Redis 7.0之前)新配置(Redis 7.0之后)默认值
hash-max-ziplist-entrieshash-max-listpack-entries128
hash-max-ziplist-valuehash-max-listpack-value64

注意新配置里 entries 的默认值是 128,不是老配置的 512。这说明官方在换用 listpack 时,顺手把触发阈值下调了。原因在于 listpack 虽然解决了连锁更新,但它的查找依然是线性扫描,把阈值控制在 128,能保证单次操作最坏情况下的耗时更低。

兼容性方面,7.0 之后在 redis.conf 里写旧的hash-max-ziplist-entries依然能被识别,但已经被标记为弃用,日志里会有提醒,未来版本会移除。所以我建议所有新项目直接写新配置名,别为了省事继续沿用老名字。

5. 动手测一测:编码方式对内存和性能的真实影响

5.1 怎么查看hash当前的编码

上生产环境之前,建议先掌握三个排查命令:

# 查看编码类型 OBJECT ENCODING user:1001 # 查看内存占用(4.0+ 支持) MEMORY USAGE user:1001 # 查看更多对象信息,包括序列化长度、LRU等 DEBUG OBJECT user:1001

OBJECT ENCODING是最常用的,轻量且信息直接。MEMORY USAGE返回的是这个 key 在当前内存里的实际字节数,对 hash 来说会把所有 field 和 value 的开销都算进去,是评估内存的好工具。DEBUG OBJECT会返回 serializedlength 之类的字段,但它不是纯粹的运行期内存占用,而是 RDB 序列化后的长度,参考价值有限,而且 DEBUG 命令在线上要慎用,不是所有环境都允许执行。

5.2 内存对比参考

我自己在测试环境做过一组对比。向一个空的 hash 里写入 500 个字段,每个 field 的 key 大约 8 字节,value 也大约 8 字节:

  • 在 listpack/ziplist 编码下,所有字段紧凑排列在连续内存里,每个 entry 额外开销只有 encoding 和长度信息,通常是几字节到十几字节,整个 key 的内存占用在十几 KB 到几十 KB 量级。
  • 在 hashtable 编码下,每个 field 和 value 都要创建独立的 RedisObject(16 字节头),每个 field-value 对还要配一个约 24 字节的 dictEntry,再加上按 2 的幂扩容的桶数组,整体内存往往是紧凑编码的两倍以上。

字段越多,差距越夸张。这也是为什么官方始终把紧凑编码作为小 hash 的默认选择。你可以在你自己的环境里用上面的命令实测,具体数字会因为数据内容、Redis 版本、内存分配器不同而浮动,但“hashtable 明显更占内存”这个趋势是稳定的。

5.3 性能到底差多少

性能差异的底层逻辑也很清晰:紧凑编码是线性扫描,hashtable 是哈希查找。500 个字段以内的 hash,线性扫描几十上百个 entry,开销在微秒量级,完全无感;但字段数过万之后,一次 HGET 可能在紧凑编码里要扫描上万个 entry,时间和 CPU 都扛不住。

实操里可以用一个 Lua 脚本快速模拟编码切换的过程:

# 先写100个字段 redis-cli eval "for i=1,tonumber(ARGV[1]) do redis.call('hset',KEYS[1],'f'..i,'v'..i) end" 1 user:test 100 # 查看编码 redis-cli OBJECT ENCODING user:test # 7.0之前返回 ziplist,7.0之后返回 listpack # 再写600个字段,总数超过阈值 redis-cli eval "for i=1,tonumber(ARGV[1]) do redis.call('hset',KEYS[1],'f'..i,'v'..i) end" 1 user:test 600 redis-cli OBJECT ENCODING user:test # 会变成 hashtable

编码切换本身是一次重建操作:把紧凑结构里的所有 entry 逐个取出来,插入新哈希表。这个过程的耗时和字段数成正比,字段越多,切换瞬间越慢。如果字段数达到几万级,单次切换可能让命令阻塞几十毫秒,虽然不至于把 Redis 打挂,但在高并发链路上,这种毛刺会直接体现为接口超时。

5.4 一个容易被忽略的细节:字段顺序

紧凑编码下,hash 的字段是按照插入顺序排列的,HGETALL 返回的顺序基本上就是插入顺序。而转成 hashtable 之后,字段顺序由哈希函数决定,完全没有规律。

这个变化平时无所谓,但如果你在业务里依赖 HGETALL 或 HKEYS 的返回顺序,比如按顺序展示用户属性、按顺序渲染购物车商品,编码一旦切换,展示顺序就可能变。我线上就踩过一次:一个用户信息展示接口,在 hash 字段数超过阈值后,列表页面的展示顺序全变了,排查到最终才发现是编码切换引起的。这个坑很少见,但遇到了非常难定位。

6. 使用hash时的常见坑和排查思路

6.1 大key:字段过多时的阻塞隐患

hash 最大的坑就是字段无限增长变成大 key。Redis 是单线程处理命令,一个 hash 的字段达到上万甚至十万级时,任何涉及全量操作的命令都可能把主线程卡住。

最典型的是 HGETALL:它一次性返回所有 field-value。一个上万字段的 hash,HGETALL 的内存拷贝和网络传输开销会把 Redis 拖出明显延迟,而这段时间里,所有其他 key 的请求都要排队。

我之前接手过一个项目,有个存用户扩展信息的 hash,字段在业务演进中涨到几万,每天固定时间会触发一次全量刷新,每次都能看到慢查询日志里出现 HGETALL,紧接着就是接口超时告警。排查时先用HLEN确认字段数量,再用redis-cli --bigkeys全局扫了一遍,大 hash 直接暴露出来。

redis-cli --bigkeys这个工具值得养成习惯,它会对整个实例做一次渐进扫描,统计出最大的 hash、最大的 string、最大的 list 等等,是发现大 key 的第一利器。

6.2 编码切换带来的连锁反应

编码切换不是一个纯粹内部的、无感知的变化,它对上层业务有实际影响:

  • 内存占用可能突然上涨,特别是从紧凑编码转成 hashtable 的瞬间;
  • 字段返回顺序可能变化,依赖顺序的业务会出现诡异 bug;
  • 切换过程本身有耗时,字段多的时候会形成一次慢命令。

所以如果业务里 hash 的字段数长期在阈值附近波动,我建议要么主动调大hash-max-listpack-entries,让它在紧凑编码里待得更久;要么干脆接受 hashtable,从一开始就按 hashtable 的脾气设计。最怕的是“左右横跳”,性能和一致性都会受影响。

6.3 排查与治理手段

针对已经出现的大 hash,常用的处理思路有这些:

  • 用 HSCAN 代替 HGETALL:HSCAN 支持游标式增量遍历,一次只返回一小批字段,不会一次性打爆内存和网络。
  • 分批删除:清理字段用 HDEL 配合 HSCAN 一批批删,不要直接用 DEL 删整个大 key,不然删除瞬间也会卡住 Redis。
  • UNLINK 异步删除:如果确实要删整个 key,Redis 4.0 以后可以用UNLINK key,让后台线程慢慢释放内存,主线程不阻塞。
  • 拆 hash:按业务维度拆分,比如用户 ID 分段,把一个大 hash 拆成多个小 hash,每个 hash 的字段数控制在几百以内。
  • 注意 field 过期问题:hash 的 TTL 只能挂在 key 上,field 没有独立的过期能力。需要 field 级过期的场景,要么拆成独立的 string key,要么在业务层做定时清理。
  • 配合监控:给慢查询日志配上告警,凡是 HGETALL、DEL 这类命令出现在慢日志里,都要第一时间追查字段规模。

这些手段里,我最想强调的还是“预估字段规模”这个前置动作。大部分大 key 问题都不是设计时想好的,而是一年半载后业务字段悄悄膨胀出来的。在代码里给 hash 的字段数加一个巡检,比如定期跑 HLEN 和 MEMORY USAGE,比等到线上出故障再排查舒服太多。

6.4 给新手的几条建议

最后整理几条我个人认为值得刻在脑子里的经验:

  • 写代码前先跑一下 OBJECT ENCODING,搞清楚当前 hash 到底是紧凑编码还是 hashtable,这是排查一切 hash 相关问题的起点;
  • 字段数几百以内、value 又小,放心用紧凑编码;预计字段数会超过阈值,就提前想好拆 key 方案;
  • 不要迷信“ziplist 省内存”这句话,数据量上去之后 hashtable 才是更稳的选择,内存换时间在 Redis 里是值得的;
  • 大 key 的排查要常态化,慢查询、内存告警、接口超时背后,大 hash 是绕不开的嫌疑对象。

最后再分享一个小技巧吧。我在新项目里的约定是:hash 的字段数控制在 1000 以内,单个 field 的 value 控制在 100 字节以内,超过就考虑拆分。这个约定没有绝对依据,但能让 hash 稳定地待在紧凑编码或者低负载的 hashtable 区间,避免卡在阈值边缘反复切换。你也正在用 hash 存业务数据的话,建议现在就跑一下 OBJECT ENCODING 和 MEMORY USAGE,看看你到底踩在编码的哪一侧。这个动作花不了几秒钟,但能帮你躲开后面一堆麻烦。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 11:24:20

本地知识助手:用RAG打通Wiki与代码仓库,解决文档漂移困扰

你有没有过这种体验:翻了大半天的团队 Wiki,好不容易找到一篇接口文档,对着代码一看,页面里写的参数名早改了三个版本。反过来,代码里明明用注释和命名讲清楚了核心业务逻辑,但你在 Wiki 里搜破头都搜不到—…

作者头像 李华
网站建设 2026/10/3 11:23:42

Zynq UltraScale+裸机LwIP以太网通信全链路解析

1. 项目概述:在Zynq UltraScale MPSoC的PS端跑通LwIP以太网通信,不是调通一个Demo,而是真正理解“数据怎么从ARM核出发、穿过GEM控制器、经由PHY芯片、最终抵达另一台设备”的完整链路 你手上有一块Xilinx Zynq UltraScale MPSoC开发板——比…

作者头像 李华
网站建设 2026/10/3 11:22:44

FreeRTOS下STM32串口中断收发全链路实战指南

1. 为什么串口调试不能只靠轮询——FreeRTOS环境下中断收发的底层逻辑在STM32项目里,我见过太多人把串口当成“会说话的GPIO”来用:主循环里反复调用HAL_UART_Receive()、HAL_UART_Transmit(),再加个超时判断,就以为搞定了。结果一…

作者头像 李华
网站建设 2026/10/3 11:22:17

AI-Native SDLC:从需求到交付全流程AI落地实践

这几年做软件开发,最明显的感觉就是:AI早就进来了,但大部分团队只是把它当成一个“高级点的自动补全”。代码是AI写的,流程还是老流程——需求靠人传话,设计靠人拍脑袋,测试靠人堆用例,AI只在最…

作者头像 李华
网站建设 2026/10/3 11:21:12

本地优先云端兜底:Dify+Ollama+DeepSeek 私有AI平台实战

每个月账单出来的时候,我都有一种强烈的“被绑架感”。明明只是做点文本摘要、知识库问答,API 调用量却像拧开了的水龙头一样停不住,费用蹭蹭往上走。后来更麻烦的是数据安全问题,公司内部有一批文档不能直接往外传,但…

作者头像 李华
网站建设 2026/10/3 11:20:36

KV Cache原理与实战:突破大模型推理速度瓶颈

1. 为什么LLM推理这么慢:先搞懂transformer的自回归机制 第一次跑大模型推理的人,十个里面有八个会问同一个问题:明明我显卡也不差,为什么生成一个字要憋半天?很多人第一反应是模型太大、显存不够,但实际上…

作者头像 李华