☰
Redis Hash内部编码详解:listpack与hashtable切换实践
2026/10/2 14:11:51 网站建设 项目流程

1. 先从 value 类型和内部编码说起

1.1 一次对象缓存设计让我重新审视 Hash

接手过一个用户中心服务,原来的做法很简单:把用户资料整个序列化成 JSON 字符串丢进 Redis,key 是 user:5678,value 是{"name":"张三","age":30,"city":"北京"}这样一大串。刚上线毫无压力,等到业务量起来,问题就全出来了。用户改一次手机号,我们就得执行一次完整的“GET、反序列化、改字段、序列化、SET”,一个字段的变动要搬运整个对象;如果用户连续修改资料,并发场景下还容易互相覆盖,丢更新。

后来我把这部分数据全部迁到了 Redis 的 hash 类型。每个用户用一个 key,属性就是 hash 里的 field,属性值就是 field 对应的 value。改 age 就执行HINCRBY user:5678 age 1,改手机号就执行HSET user:5678 phone 138xxxx,其他字段完全不受影响。上线之后效果非常明显,接口耗时下来了,内存占用也比原来存 JSON 字符串低了不少。

这个案例让我意识到一件事:Redis 的 value 类型选择,不能只看“语义上像不像”,还得理解每个类型底层是怎么存储的。hash 之所以适合对象型数据,除了天然的“字段-值”结构,更关键的是它内部有编码方式上的设计——小数据量用紧凑结构省内存,大数据量切换成标准哈希表保性能。这篇就把 hash 的 value 类型和编码方式一次讲透,结合我实际踩过的坑,给你一套能直接参考的选型思路。

1.2 数据类型的逻辑结构与内存中的编码方式

先说清楚两个概念:数据类型和编码方式不是一回事。数据类型是你用命令行、用客户端看到的逻辑结构,string 就是字符串,hash 就是 field-value 字典;编码方式是 Redis 内存里真正存放这些数据时使用的底层数据结构。同一个 hash,执行 OBJECT ENCODING 命令,可能返回 listpack,也可能返回 hashtable,两者在内存布局、读写性能上差别很大。

Redis 设计这种“一类型多编码”的思路,本质是在内存和 CPU 之间做取舍。小对象如果用标准哈希表,每个字段都要额外承担指针、对象头、桶位空置这些开销,一个只有几十个字段的小 hash 可能白白多耗成百上千字节;而紧凑结构虽然某些操作是 O(n),但数据量小的时候 n 可以忽略不计,缓存命中率还高。等数据量涨上去,紧凑结构写入代价变大,Redis 就自动换成哈希表。这个过程对客户端完全透明,但理解它的人在做数据建模、排查内存问题时,会明显更有底。

2. hash 的两种内部编码方式

2.1 listpack 和 ziplist:小 hash 的省内存秘密

Redis 7.0 之前,小 hash 用的是 ziplist 编码。ziplist 是一整块连续内存,所有元素挨个排列,每个节点除了真实数据,还要记录前一个节点的长度(prevlen)和一段编码信息。这样做的好处是可以通过 prevlen 从尾部向前遍历,也方便定位前一个元素。对于一个小的 hash,每个 field 和 value 会变成 ziplist 里的两个相邻节点,整体看就是一个压缩的线性列表。

ziplist 最大的优点是内存紧凑:没有多余指针,没有对象头,没有散列桶空位,数据紧挨着放,能省就省。缺点也藏在设计里:往中间插入或者删除节点时,如果前一个节点的长度发生变化,后面一串节点的 prevlen 都要跟着更新,也就是所谓的“级联更新”。字段少时无所谓,字段一旦多起来,一次插入可能引发连锁内存移动,写入性能明显劣化。

Redis 7.0 开始,官方引入 listpack 来逐步取代 ziplist。listpack 的每个节点只保存自己的长度,不再依赖前一个节点的长度,从设计上消除了级联更新的风险。也就是说,新版本里你创建的小 hash,执行 OBJECT ENCODING 返回的是 listpack 而不是 ziplist。你在 API 层面完全感知不到这个变化,命令照用,但底层的健壮性更好了。升级到 Redis 7 之后,老数据依旧能读,只是新写入的小对象会优先以 listpack 编码。

2.2 hashtable:字段变大之后的正式形态

当 hash 的字段数量超过阈值,或者某个字段名/字段值的长度超过阈值,Redis 就会把内部结构切换为 hashtable。hashtable 就是经典散列表,用链表法解决冲突,每个字段对应 dictEntry 里的一个键值对,指针指向实际的键和值对象。

hashtable 的优势是读写复杂度 O(1),不管字段是 200 个还是 2 万个,HGET 都只需要一次哈希计算、一次桶查找。缺点是内存开销大得多。可以粗略估算下:64 位系统下一个 dictEntry 大约 24 字节,指向的 key 和 value 还有 redisObject 之类的基础开销,哪怕字段名和值都只有几个字节,一个字段也可能吞掉五六十甚至七八十个字节。字段数量少的时候无所谓,字段一多,哈希表反而是更合理的选择。

hashtable 还有一层隐藏行为:扩容和缩容时会触发 rehash。Redis 采用渐进式 rehash,把一次大迁移拆成多次,在服务请求的间隙逐步完成。不过如果某个时刻写入特别密集,rehash 还是可能带来 CPU 抖动。这个问题在线上出现过,后面排查部分会专门说。

2.3 编码切换的触发条件与配置参数

旧版本里,控制 hash 从 ziplist 切换为 hashtable 的是这两个参数:

hash-max-ziplist-entries 128 hash-max-ziplist-value 64

Redis 7.0 之后,参数改为:

hash-max-listpack-entries 128 hash-max-listpack-value 64

含义保持一致:第一个是 hash 最多能容纳的字段数量,第二个是单个字段名或字段值允许的最大字节数。只要字段数量超过 entries 阈值,或者任意字段名/字段值的长度超过 value 阈值,这个 hash 就会被升格成 hashtable。两个条件满足任意一个就会触发,不是非等同时满足。

这里必须提醒一个容易踩的坑:阈值单位是字节,不是字符数。中文字符在 UTF-8 编码下通常占 3 个字节,一个字段值只要超过二十来个汉字,就可能直接把编码从紧凑结构顶到 hashtable。测试环境用拼音、英文模拟也许没问题,线上真实中文数据一上来,很多“莫名其妙变成 hashtable”的 key 就是栽在这里。

3. 实操验证编码切换过程

3.1 OBJECT ENCODING 一眼看清内部结构

想知道某个 hash 当前的编码方式,不需要猜,一条命令就能看到:

127.0.0.1:6379> HSET book:1001 title "Redis实战" price 79 (integer) 2 127.0.0.1:6379> OBJECT ENCODING book:1001 "listpack"

Redis 7.x 环境返回 listpack,Redis 6.x 及更早版本返回 ziplist,含义一样:这个 hash 还在紧凑编码状态。当返回结果变成 hashtable,说明已经升格为标准哈希表。OBJECT ENCODING 同样适用于 string、list、set、zset,是排查内存问题时首先要掌握的命令。

线上想批量检查大量 key 的编码,应该用 SCAN 循环配合 OBJECT ENCODING,而不是一把 KEYS 全部拉出来。SCAN 每次取一批,对实例的影响小得多,不会出现一次性全量扫描把 Redis 卡住的问题。

3.2 字段数量和字段长度哪一个先触顶

我做过一个简单测试,用来验证切换边界。先创建一个只有几个短字段的 hash,然后不断加字段:

127.0.0.1:6379> HSET user:1001 f1 v1 f2 v2 (integer) 2 127.0.0.1:6379> OBJECT ENCODING user:1001 "listpack"

只要字段数量不超过 hash-max-listpack-entries,每个字段名和值都短于 hash-max-listpack-value,编码就一直是紧凑型。继续灌字段灌到超过 128 个,再查:

127.0.0.1:6379> OBJECT ENCODING user:1001 "hashtable"

另一种情况也试过:字段很少,但某一个值特别长。比如一个只有 3 个字段的 hash,塞一个 200 字节的长文本进去,结果照样切到 hashtable。这说明控制切换的两条线是“或”的关系,任何一条越线都会触发升格。

这个规律在做数据建模的时候非常有用。如果你希望对象长期留在紧凑编码里,就得同时控制字段数量和字段长度。有些团队习惯把特别长的文本从 hash 里拆出去,放到独立 string key 或者专门的对象存储,hash 里只留短字段,就是为了保住内存优势。

3.3 实际中容易忽略的一点:切换是单向的

一旦编码从紧凑结构切换成 hashtable,就算你把字段删到只剩几个,Redis 也不会自动“缩回去”变回 listpack。这是 Redis 的既定行为,它不想在每次写入后都去判断“现在是不是又变小了”,频繁升降编码会带来额外复杂度和性能开销。

所以你会遇到这种局面:一个 hash 曾经有 500 个字段,后来清理到只剩 10 个短字段,OBJECT ENCODING 依然返回 hashtable。如果对内存占用比较敏感,想让它恢复紧凑编码,只能删掉整个 key 或者改名后重新灌一遍数据,让 Redis 按当前配置重新编码。这个细节在官方文档里描述得很隐晦,不实际踩一次很容易忽略。

4. hash 的实际应用场景

4.1 对象数据局部更新,比整串 JSON 省心得多

回到开头的用户资料场景。对象型数据的特点是“整体是一个逻辑单位,但局部字段会频繁变化”。用 string 存 JSON,每次改一个字段都要把整串取出来、反序列化、改完再写回去,网络开销大,并发更新还容易丢数据。换成 hash 以后,每个字段独立,HSET 改一个字段不会影响其他字段,HINCRBY 可以直接给积分、余额这种数字字段加值,HDEL 可以单独删除某个属性。

商品信息、文章详情、配置中心、设备状态,这类数据都可以套这个模式。hash 天然就是“对象/字典”的映射,字段名对应属性,字段值对应属性值。读取的时候按需用 HMGET 只取某几个字段,不一定非要 HGETALL 全量拉,这样能减少不少网络传输和序列化开销。

4.2 购物车、会话和分组计数器

hash 的另一个高频场景是购物车。key 用用户 ID,字段是商品 SKU,值是数量。用户加购就HSET cart:10086 sku_12345 1,多买一件就HINCRBY cart:10086 sku_12345 1,结算时 HGETALL 扫一遍,下单成功后 HDEL 清除。这个模型和数据库里“一订单多商品”的关系结构比,省掉了大量中间操作,天然就是一个聚合视图。

Web 会话存储也常选 hash。一个 session key,字段放 uid、登录时间、token、最后活跃时间,过期时间对整个 hash 设置 EXPIRE,到期整体失效。还有一些计数器聚合需求,比如按渠道统计 PV:渠道 ID 作为字段,PV 数值作为字段值,HINCRBY pv:report channelA 1一路累加,不需要为每个渠道单独建 key。

需要记住一个硬限制:hash 不支持字段级过期。你只能对整个 key 设置 EXPIRE,没法让某个字段 X 小时后自动消失。想做字段级 TTL 的话,要么把过期时间戳存进字段值由业务逻辑判断,要么把不同过期粒度的数据拆成多个 key,按场景取舍。

4.3 序列化和分布式锁与 hash 的关系

热词里出现了“redis序列化”“redis分布式锁”,顺带把这两个和 hash 的关联讲清楚。序列化影响编码方式,是因为序列化的产物最终要作为 value 落进内存。用 Spring Data Redis 时如果配置了 JDK 序列化,每个对象会变成一大串带类型信息的字节,字段值很容易突破 64 字节阈值,紧凑编码就很难保住。换成 JSON 序列化器,或者让业务字段尽量以短字符串和整数为主,内存占用会明显下降。这背后不是玄学,就是字节长度实实在在地压着编码切换的线。

分布式锁常规实现是用 string 配合SET key value NX PX,但以 Redisson 为代表的可重入锁,在 Redis 里用的正是 hash:key 是锁名,字段是持有锁的线程 ID,值是重入次数。加锁就是 HSET,解锁会先判断字段对应关系再 HDEL,依靠一个小 hash 实现可重入控制。可见 hash 不只是用来存业务对象,很多中间件内部的巧妙设计也依赖它。理解它的编码方式和命令语义之后,遇到类似需求,你也能自己组装出合理的数据结构。

5. 常见问题与排查经验

5.1 字段不能单独设置过期时间

这是 hash 最典型的限制之一。业务上如果希望每个字段分别过期,用 hash 会很难受。比较常见的替代方案有两种:一种是把每个逻辑实体拆成独立 key,用 string 存完整快照,在 key 上设 EXPIRE,适合过期粒度比较粗的场景;另一种是在 hash 字段值里同时放“数据本身 + 过期时间戳”,读取时先比较时间戳再决定是否返回有效数据,适合过期粒度细但能接受读逻辑复杂一点的场景。

我实际遇到过一个案例:埋点数据要求每个属性分别保留 30 天,最初设计用 hash 存储,很快发现字段过期能力缺失,最后改成多 key 方案。遇到这种情况,不要和 Redis 的数据模型硬顶,调整建模方式往往更省事。

5.2 HGETALL 与大 key 问题

hash 最大的风险是变成 big key。一个几万字段的大哈希,执行 HGETALL 会一次性返回全部数据,产生超大响应包,轻则拖慢当前请求,重则阻塞 Redis 单线程,影响同一实例上的所有其他操作。排查线上问题时,我一般先用 HLEN 看字段数量,再用 MEMORY USAGE 看实际内存占用,确认是不是大 key 之后,改成 HSCAN 分批遍历字段,而不是一次全取。

如果只是需要某几个字段,HMGET 也比 HGETALL 稳得多。很多同学习惯了 HGETALL 把整个哈希取回业务层再自己挑数据,小 key 没感觉,大 key 就是灾难。按需读取是个低成本高收益的习惯,对象型数据更应该坚持“取用所需”而不是“全量搬走”。

5.3 编码切换带来的性能波动

hashtable 在扩容、缩容时会触发渐进式 rehash,字段数很大的 hash 在扩容期间会有一段时间的 CPU 消耗。如果线上突然出现某个 Redis 实例 CPU 抖动,排除了慢查询和热点 key 之后,可以检查一下是不是有大量 hash 集中在某个时间点从紧凑编码切换到了 hashtable,或者某个超大 hash 正好在扩容。

另一个与之相关的坑是配置参数被调得过大。有人为了让对象一直保持紧凑编码,把 hash-max-listpack-entries 改成几千。表面看内存是省了,可一旦真的写入那么多字段,紧凑编码下的写操作会引发大量内存移动,写入延迟反而远高于 hashtable。紧凑编码的定位是“小对象省内存”,不是“强行把大对象塞进小结构”。迁移版本的时候也要注意,Redis 7.0 之前的 ziplist 参数已经更名为 listpack 参数,直接把老配置搬到新版本是无效的。

6. 最后补充几个我长期在用的经验

有几个习惯是踩过几次坑之后才养成的,写在这里供你参考。

第一,不管什么业务场景,新建 hash 之前先估算一下字段数量和字段长度会不会逼近 128 和 64 这条线。如果会,就提前评估 hashtable 编码下的内存占用和读写性能,别等上线后突然切换,触发不必要的性能波动。

第二,排查内存问题时把 OBJECT ENCODING 当成常规手段,配合 HLEN、MEMORY USAGE、HSCAN 一起用。先用 OBJECT ENCODING 判断哪些 key 还是紧凑编码、哪些已经变成 hashtable,再用 HLEN 和 MEMORY USAGE 确认体量,用 HSCAN 分批遍历大哈希,很快就能定位问题。

第三,hash 的编码阈值不是越大越好,也不是越小越好。字段少但读取频繁的场景,紧凑编码的缓存命中率优势明显;字段多但写入频繁的场景,哈希表反而是正确选择。配置前先想清楚业务形态,比盲目调参可靠得多。

从理解 value 类型到理解编码方式,看起来只是多了一层底层知识,实际上会影响你的内存预算、读写延迟和线上排障效率。hash 作为 Redis 里最像“对象”的类型,用好了能省很多事,用错了也会带来不少麻烦。希望这篇拆解能让你在做数据建模时多想一层,少走一些弯路。

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

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

立即咨询