资讯动态

Redis hash 编码:listpack 与 hashtable 的内存密码

发布时间:2026/10/23 6:53:33 来源:尧图企业网站定制
把用户画像、商品属性、会话状态这类结构直接塞进 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 涨到 11Gkey 总数到了几千万。后来改成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 在语义上就是一张标准的 Mapkey 叫 fieldvalue 叫 valuefield 不能重复对同一个 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 与 hashtable3.1 紧凑编码小 hash 的内存杀手锏Redis 内部给 hash 准备了两套编码实现分别应对“小 hash”和“大 hash”。小 hash 用的是紧凑编码7.0 之前叫 ziplist7.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 不建议超过 1000value 不建议超过 512一旦超过这个范围紧凑编码的收益就明显下降不如老老实实转 hashtable。提示配置修改用 CONFIG SET hash-max-listpack-entries 256 可以热更新不用重启。但只影响后续创建的 hash已经转成 hashtable 的 key 不会自动转回来。5. 实操记录一个 hash 编码实验和内存观测5.1 命令行里的编码切换实验理论讲再多不如实际敲一遍。我用 Redis 7.x 起了一个实例先写入一个小 hash127.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 i1,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 hashtable200 个字段超过了 128 的 entries 阈值编码立刻变成 hashtable。这个实验里还藏着一个细节用 EVAL 批量写和用客户端循环发 200 次 HSET 的结果一样编码升级不受写入方式影响。再验证 value 长度触发的升级。向一个小 hash 里写一个超长 value127.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) 2048DEBUG 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 会一次性返回全部字段和值网络包可能几十 MBRedis 单线程处理这条命令期间整个实例都会卡住。这就是所谓的大 key 阻塞问题。排查阶段可以用 HLEN 先看字段数再用 HSCAN 增量拉取千万不要图省事直接 HGETALL。真需要全量数据的话写脚本用 HSCAN 游标循环拉每次几百个字段对实例的压力可接受得多。6.2 单字段超长导致整个 hash 编码退化前面实验里已经演示了某一个字段值超过 64 字节整个 hash 立刻从 listpack 变成 hashtable。这在实际业务里非常容易踩比如存用户备注、把日志文本塞进 hash 字段、把 Base64 图片塞进去。后果是整个 hash 的内存上涨好几倍不是你预期中的紧凑存储。排查方法是先 OBJECT ENCODING 看编码一旦看到 hashtable 且字段数不多就按字段遍历找出超过 64 字节的字段。HSCAN 可以带上 COUNT返回结果里每个字段值长度一眼就能看出来。修复方向是把大字段单独拆出去存 Stringhash 里只保留小字段让编码重新落回 listpack。6.3 hash 不支持字段级过期hash 的过期单位是整个 keyEXPIRE 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 事故非常值得你抄作业。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑