☰
Redis hash 编码:listpack 与 hashtable 的内存密码
2026/10/23 6:53:25 网站建设 项目流程

把用户画像、商品属性、会话状态这类结构直接塞进 Redis,你是用 String 硬扛的还是用 hash?这是我带人时基本每次都问的一道选择题。很多人一开始都答 String 更快,等真到了内存告警,才发现 hash 的小对象存储密度几乎能带来成倍收益。这篇就围绕 Redis 的 hash value 类型和它背后的编码方式展开,不仅讲清楚怎么用,也把 ziplist/listpack 和 hashtable 这两套编码的切换逻辑、配置参数、排查手段一起盘清楚,适合后端开发、DBA 和所有想把 Redis 内存吃干榨净的人。

1. hash 是 Redis 里最容易被低估的 value 类型

1.1 hash 到底解决了什么建模问题

Redis 的 value 类型里,String 太通用,List 偏队列,Set/ZSet 适合去重和排序,而 hash 对应的是“一个对象的多字段”。什么叫对象的多字段?用户资料里的昵称、年龄、城市,商品详情里的标题、价格、库存,会话状态里的登录时间、设备、token 过期时间,这些天然就是一个 key 下面挂多个 field-value 的结构。

但你真正动手存的时候会发现,大家普遍走两条路:要么直接把整个对象序列化成 JSON 塞进一个 String,要么把每个字段拆成独立 key,比如user:10001:name、user:10001:age。第一条路的问题是更新单个字段也要整体读出再写回,字段多了以后既浪费带宽又容易产生并发覆盖;第二条路的问题是 key 数量爆炸,内存碎片和 key 过期管理的开销都上来了,而且查询一个对象要拼 N 次网络往返。

hash 在这两条路之间找到了一个平衡点:对象是一个整体,但内部字段可以独立操作。HSET user:10001 age 26只更新一个字段,Redis 内部不需要把整个对象解出来;HMGET user:10001 name age city一条命令把需要的字段全取回来,也不会像 JSON 那样把没用的字段也拖回来。这是 hash 在建模层面最核心的价值。

1.2 和小 key 方案、序列化方案的实际差异

我见过最典型的一个迁移场景是这样:某项目一开始存一批设备状态,每个设备 10 个字段,开发图省事全部拆成device:<id>:field这种裸 key,跑了一个月后内存从 4G 涨到 11G,key 总数到了几千万。后来改成HSET device:<id> field value一条 records 十条字段,key 总数直接缩小了十分之一,内存也回落了差不多一半。原因不复杂:裸 key 方案每个 key 都要付出完整的键对象开销,Redis 内部每个 key 都是一个 dict entry,有指针、有引用计数、有 LRU 信息,再小的 key 也是这一套固定开销;而 hash 把这些开销摊销到一个 key 里的多个 field 上。

和 JSON 字符串方案比,hash 的主要优势是部分更新和部分读取。JSON 字符串你无论读一个字段还是读十个字段,都得把整条数据取出来反序列化;hash 可以用 HGET 只取一个字段,也可以 HMGET 取一批字段。HINCRBY 这类原子自增就更不用说了,JSON 方案要做计数器得先读出来再写回去,并发下面很容易丢更新。不过 hash 也不是银弹,如果字段之间存在嵌套层级,比如多级 JSON,硬塞进 hash 会让语义变得很别扭,这种情况要么拆类型,要么继续用序列化字符串。

2. hash 的语义细节和常用命令,比你想的更讲究

2.1 一张 Map,而不是一个大字符串

理解 hash 的第一步是把脑子里“字符串拼接”的惯性丢掉。Redis 的 hash 在语义上就是一张标准的 Map,key 叫 field,value 叫 value,field 不能重复,对同一个 field 重复 HSET 是覆盖写。value 的类型还是字符串,所以如果你想在 hash 的值里再塞一个 hash,那不行,需要先序列化,这就回到了前面说的嵌套问题。

字段级别的操作是 hash 的灵魂。HGET key field 时间复杂度 O(1),HSET 也是 O(1),HINCRBY key field increment 可以对某个字段做原子自增,这在计数场景里太好用了。比如记录某篇文章的点击数,HINCRBY article:1001 pv 1,不用读改写,不会丢更新,也没有并发问题。HINCRBYFLOAT 可以处理浮点计数,虽然小数字段在内部存储会有精度处理,但日常统计足够。

还有一个经常被忽略的语义:当 hash 里最后一个字段被 HDEL 删掉时,整个 key 会自动被删除。这个行为意味着你不需要再手动 DEL 一个空 hash,但也意味着如果你用 Redis 做缓存,字段全被删光后 key 会立刻消失,对依赖 key 存在性的逻辑要特别注意。

2.2 常用命令组合和边界行为

把常用的命令分成三组来记会轻松很多。第一组是读写字段:HSET、HGET、HMGET、HMSET、HSETNX、HDEL。HMSET 在 Redis 4.0 之后其实已经是废弃状态,官方建议直接用 HSET 带多个 field-value 参数,所以别再被老教程带偏了。HSETNX 只在字段不存在时写入,可以用来做字段级别的占位锁,但注意它不能设置过期时间,锁的回收只能靠业务侧自己控制。

第二组是查看结构:HGETALL 返回所有字段和值,HKEYS 只返回字段名,HVALS 只返回值,HLEN 返回字段数量,HEXISTS 判断字段是否存在。这几个命令多用于调试和小规模查询,真正的大 hash 一定要小心 HGETALL,后面排查章节我会专门说。

第三组是遍历和增量:HSCAN key cursor 是遍历大 hash 的唯一安全姿势,它和 SCAN 一样是增量迭代,每次返回一部分字段和游标。升级版本里 HSCAN 还支持 COUNT、MATCH、NOVALUES 这些修饰,NOVALUES 选项在某些新版本里可以只拿字段名,省掉值的传输开销。

边界行为里有个坑是 field 或 value 为空字符串。HSET key "" hello 是可以的,空字符串照样存,Redis 不会做特殊拦截。这在小对象建模时容易埋雷,比如从前端传了个空字段过来,你还以为没写进去,结果 HGET 返回的是空串而不是 nil,逻辑判断就会出问题。建议对字段值做统一的空值策略,不要靠 Redis 的返回去猜。

3. Redis 里 hash 的两种编码方式:listpack 与 hashtable

3.1 紧凑编码:小 hash 的内存杀手锏

Redis 内部给 hash 准备了两套编码实现,分别应对“小 hash”和“大 hash”。小 hash 用的是紧凑编码,7.0 之前叫 ziplist,7.0 开始换成了 listpack。名字不用记太死,理解它的思路就行:它是一块连续内存,字段名和值按顺序紧挨着排列,中间只穿插极小的长度前缀信息。

为什么要做这种紧凑结构?因为 Redis 是内存型数据库,存储效率直接决定成本。一个只有 10 个字段、每个字段值 20 字节的小 hash,如果用标准哈希表实现,光哈希桶和 entry 的指针开销可能比数据本身还大;紧凑编码把数据全部“压扁”到连续内存里,省掉了大量指针和间接寻址,内存占用可能降到哈希表方案的几分之一。代价是读写时大概率涉及内存重新分配和数据搬移,但因为数据量小,整体 CPU 成本仍然很低。

ziplist 和 listpack 的关键区别在于对“连锁更新”的处理。旧版 ziplist 每个节点包含前一个节点的长度信息,如果中间某个节点长度变化触发后面的节点长度字段连锁变化,可能造成多次内存重分配,最坏情况是 O(N²) 的更新代价。listpack 改成了每个节点只保存自身长度和编码信息,彻底避免了这种连锁更新问题,这也是 Redis 7.0 全面切换的原因。所以如果你还在 Redis 6.x 上看到 OBJECT ENCODING 返回 ziplist,不要慌,它和 listpack 是一个设计思路,都是紧凑编码。

3.2 标准哈希表编码:大 key 的常规形态

当 hash 的规模超过紧凑编码的阈值后,Redis 会把编码切换成 hashtable,也就是它内部通用的 dict 结构。这个 dict 和 Redis 存所有 key 用的 dict 是同一套实现,核心是数组加链表解决冲突,支持渐进式 rehash,扩容阈值和缩容策略都由内部参数控制。

hashtable 编码的优点是单个字段访问的时间复杂度稳定在 O(1),字段数量多少对访问的均摊成本影响很小。缺点是内存开销明显变大:每个字段都有独立的 dict entry,包含 key 指针、value 指针、next 指针,还要算上哈希桶数组本身的占用。一个字段 20 字节数据,在 hashtable 编码下整体成本可能翻到 50 字节以上,数据量大了以后差距非常可观。

所以 Redis 的取舍逻辑是:对象小的时候用紧凑编码省内存,对象大的时候用哈希表保性能。两套编码各有自己的适用区间,没有谁绝对更好,关键看你的 hash 到底会多大、字段值会不会膨胀。

3.3 怎么知道一个 hash 当前用的哪种编码

Redis 提供了非常直观的检查命令:OBJECT ENCODING key。返回结果就三种可能,listpack、ziplist、hashtable。7.0 之后你大多会看到 listpack,老版本看到 ziplist,一旦转成 hashtable 就说明已经突破了紧凑编码的边界。

检查编码应该成为上线前和排查问题时的一个肌肉记忆。比如你预估某个 hash 字段数会稳定在 30 个以内、值都是二三十字节,那它应该一直是 listpack 编码;某天突然发现变成 hashtable 了,最先怀疑的就是某个字段值被写入了超大内容,或者字段数超过了阈值,而不是 Redis 出了什么玄学问题。这个小习惯能帮你在大 key 恶化之前就抓到苗头。

4. 编码升级的触发条件与参数权衡

4.1 默认阈值和判断逻辑

紧凑编码什么情况下会升级成 hashtable?两个条件:第一,hash 的字段数量超过 hash-max-listpack-entries;第二,任何一个字段名或者字段值的长度超过 hash-max-listpack-value。两个条件是“或”的关系,只要触发了其中一个,整个 hash 立刻转成 hashtable,没有中间态。

Redis 的默认配置是这样的:

配置项默认值含义
hash-max-listpack-entries128字段数量超过此值触发升级
hash-max-listpack-value64任一字段名或字段值超过 64 字节触发升级

注意 7.0 之前的配置名还叫 hash-max-ziplist-entries 和 hash-max-ziplist-value,含义完全一样,只是底层替换后改了名字。如果你的环境是 6.x,看配置的时候别找错了。

升级判断发生在写入路径上。每次 HSET 新增或更新字段时,Redis 会检查当前编码是否还是紧凑编码,如果是,再检查当前字段数和这次写入的值长度是否超限。一旦超限,就直接在内存里重建整个 hash 为 hashtable 编码。这个重建过程是原子性的,不会读到一半的脏数据,但如果 hash 已经很大了,第一次触发升级的瞬间会有一次明显的 CPU 尖峰,线上需要注意。

4.2 手动调整参数的经验策略

默认值能不能改?能,但要分场景。hash-max-listpack-entries 默认 128 的意思是,大多数 hash 在 128 个字段以下还能享受紧凑编码;hash-max-listpack-value 默认 64 则是给字段值画的线,超过 64 字节就算“大字段”。

实际项目里我习惯这样评估:如果字段值基本是短字符串,比如状态枚举、小数字、短文本,把 hash-max-listpack-value 从 64 调到 128 甚至 256 是安全的,因为这些值本来就不会很长,调高只是让紧凑编码的适用范围更大。但如果业务里存在可变长文本,比如备注字段、描述字段,就要非常谨慎地把上限拉高,因为一个 1KB 的超大字段就能让整个 hash 从紧凑编码变成 hashtable,内存优势瞬间消失。

hash-max-listpack-entries 同理。如果你确定某个业务 hash 的字段数不会超过 500,把 entries 从 128 调到 512 能继续享受紧凑编码的内存收益。但调高也意味着单次操作可能涉及更大范围的内存搬移,字段很多的时候写入延迟会上升。我的经验是:entries 不建议超过 1000,value 不建议超过 512,一旦超过这个范围,紧凑编码的收益就明显下降,不如老老实实转 hashtable。

提示:配置修改用 CONFIG SET hash-max-listpack-entries 256 可以热更新,不用重启。但只影响后续创建的 hash,已经转成 hashtable 的 key 不会自动转回来。

5. 实操记录:一个 hash 编码实验和内存观测

5.1 命令行里的编码切换实验

理论讲再多不如实际敲一遍。我用 Redis 7.x 起了一个实例,先写入一个小 hash:

127.0.0.1:6379> HSET user:10001 name "tom" age 25 city "beijing" (integer) 3 127.0.0.1:6379> OBJECT ENCODING user:10001 "listpack"

字段数只有 3,值都很短,自然是 listpack。接着用一段 Lua 一次性写入 200 个字段,把字段数推到阈值之上:

127.0.0.1:6379> EVAL "for i=1,200 do redis.call('HSET', KEYS[1], 'field'..i, 'value'..i) end" 1 big:hash (nil) 127.0.0.1:6379> OBJECT ENCODING big:hash "hashtable"

200 个字段超过了 128 的 entries 阈值,编码立刻变成 hashtable。这个实验里还藏着一个细节:用 EVAL 批量写和用客户端循环发 200 次 HSET 的结果一样,编码升级不受写入方式影响。

再验证 value 长度触发的升级。向一个小 hash 里写一个超长 value:

127.0.0.1:6379> HSET special:hash short_field "short_value" (integer) 1 127.0.0.1:6379> HSET special:hash long_field "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" (integer) 1 127.0.0.1:6379> OBJECT ENCODING special:hash "hashtable"

第二个 value 长度超过了 64 字节,整个 hash 直接升级。即使第一个字段很短,也不会单独保留 compact 编码。这个现象在排查大 key 时非常常见:你总觉得 hash 没几个字段,为什么内存高得离谱,一看编码已经变成 hashtable 了,再一查发现某个字段塞了个几百字节的日志文本。

5.2 用 MEMORY USAGE 和 DEBUG OBJECT 量化内存

确认编码之后,要量化内存。MEMORY USAGE key 命令可以直接返回该 key 的大致内存占用,单位是字节。注意它返回的只是该 key 本身的内存,不包含客户端缓冲区、复制积压等外围开销,但用来横向对比已经够了。

127.0.0.1:6379> MEMORY USAGE user:10001 (integer) 128 127.0.0.1:6379> MEMORY USAGE big:hash (integer) 2048

DEBUG OBJECT key 能看到更多信息,包括编码类型和序列化长度:

127.0.0.1:6379> DEBUG OBJECT user:10001 Value at:0x7f... refcount:1 encoding:listpack serializedlength:64 lru:... lru_seconds_idle:... 127.0.0.1:6379> DEBUG OBJECT big:hash Value at:0x7f... refcount:1 encoding:hashtable serializedlength:1024 lru:... lru_seconds_idle:...

serializedlength 是 RDB 序列化后的长度估算,不完全等于内存占用,但同类型数据横向对比时有很强的参考意义。我看到很多优化案例里,开发用 MEMORY USAGE 对比 hash 和 String 两种方案,最直观的结果就是同样十来个字段的小对象,hash 的内存比 JSON 字符串少 30% 到 50%。

5.3 一个真实项目里的迁移收益记录

之前帮某团队优化过一个标签存储模块。原方案是每个用户每个标签一个 String key,总共存了 800 万用户、平均每人 12 个标签,key 数量接近一亿,内存峰值到了 9GB。改造后改用 hash,每个用户的标签收敛到一个 key 下,用户 id 做 key,标签名做 field,标签权重做 value。上线后 key 数量降到了 800 万,内存降到了 4GB 出头,单次批量读取从十几条命令变成一条 HGETALL,延迟也明显下降。

这个例子不是说要无脑用 hash,而是说明 hash 在“一对多的小字段集合”这种场景下确实有结构性优势。标签值短、数量不过百,正好踩在 listpack 编码的舒适区里,内存优势能完全发挥出来。

6. 常见问题与排查经验,都是真实踩过的坑

6.1 大 hash 千万别直接 HGETALL

字段数超过几千甚至几万的 hash,执行 HGETALL 会一次性返回全部字段和值,网络包可能几十 MB,Redis 单线程处理这条命令期间,整个实例都会卡住。这就是所谓的大 key 阻塞问题。排查阶段可以用 HLEN 先看字段数,再用 HSCAN 增量拉取,千万不要图省事直接 HGETALL。真需要全量数据的话,写脚本用 HSCAN 游标循环拉,每次几百个字段,对实例的压力可接受得多。

6.2 单字段超长导致整个 hash 编码退化

前面实验里已经演示了:某一个字段值超过 64 字节,整个 hash 立刻从 listpack 变成 hashtable。这在实际业务里非常容易踩,比如存用户备注、把日志文本塞进 hash 字段、把 Base64 图片塞进去。后果是整个 hash 的内存上涨好几倍,不是你预期中的紧凑存储。

排查方法是先 OBJECT ENCODING 看编码,一旦看到 hashtable 且字段数不多,就按字段遍历找出超过 64 字节的字段。HSCAN 可以带上 COUNT,返回结果里每个字段值长度一眼就能看出来。修复方向是把大字段单独拆出去存 String,hash 里只保留小字段,让编码重新落回 listpack。

6.3 hash 不支持字段级过期

hash 的过期单位是整个 key,EXPIRE myhash 3600会让整个 hash 在一小时后消失,字段级过期并没有原生能力。业务上如果遇到“这张表里某些字段要过期,另一些不要”,只能自己把过期时间作为时间戳写进字段值,再用定时任务判断,或者把过期字段拆成独立 key 设置过期。这块不要试图用 TTL 去实现,换来的全是混乱。

6.4 删除字段后内存不会立刻归还

HDEL 删除 hash 里的字段,内存不一定立刻归还给操作系统。hashtable 编码下,dict 的缩容有自己的策略,字段删到一定比例才会触发 rehash 缩容;listpack 编码下,删除字段可能触发内部内存整理,但也不一定缩到最小。所以不要用“删了字段内存立刻降下来”这个预期来衡量结果,大规模清理后可以用 MEMORY PURGE 主动整理内存碎片,但性能影响较大,建议在维护窗口执行。

6.5 编码升级瞬间的 CPU 尖峰

前面提到过,紧凑编码升级为 hashtable 是一次内存重建,数据越大,重建时间越长。一个本来 100 个字段的 hash 突然写入一个超大字段触发升级,这个瞬间 Redis 可能卡几十毫秒。如果线上有大量 hash 同时达到升级条件,叠加起来就是一次可见的毛刺。我的经验是:对字段值长度做业务侧校验,超过 64 字节的字段直接分流到 String 类型,既保住 hash 的紧凑编码,也避免升级瞬间的延迟抖动。

关于 hash 的最后一句话

我在实际项目里的体会是,hash 是 Redis 类型系统里性价比最高的一个,但前提是你得搞清楚它到底在什么编码下运行。上线前花三十秒用 OBJECT ENCODING 确认一下,配置参数结合业务字段长度定好阈值,大字段拆出去、小字段留在 hash 里,这套组合拳打下来,内存和延迟都会有明显改善。

最后再分享一个小技巧:我在排查内存问题时,习惯写一个简单的巡检脚本,批量对线上所有大 key 做 OBJECT ENCODING 检查,只要发现编码和预期不符,立刻按字段长度排序定位问题字段。这个习惯帮我提前抓过好几次潜在的大 key 事故,非常值得你抄作业。

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

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

立即咨询