小对象压缩:listpack 编码阈值调优实践

举报
yd_227606267 发表于 2026/11/15 08:11:00 2026/11/15
【摘要】 调整 hash-max-listpack 等阈值,用编码换内存

同样是 Hash,有的实例里一个只占 100 字节,有的要占 1KB——差别在于底层编码:数据量小时 Redis 用紧凑的 listpack 顺序存储,超过阈值后转成标准哈希表。Redis 7 里 hash-max-listpack-entries、zset-max-listpack-entries 这些阈值直接决定了海量小对象的总内存,是"缓存存千万用户画像"这类场景最重要的调优开关。本文讲清编码规则、各类型阈值、调优方法与代价。

一、编码即内存策略

listpack(Redis 7 取代 ziplist 的紧凑列表)把所有元素连续存放,没有指针、没有哈希桶,只有极小的头部开销,内存效率极高;代价是查找要顺序遍历,单个操作是 O(N)。因此 Redis 的策略是:元素少、值短时用 listpack 省内存,一旦超过阈值就转成哈希表或跳表,换取 O(1)/O(log N) 的操作速度。

从结构上看,listpack 由一块连续内存构成,头部记录总字节数与元素个数,中间是逐条编码的条目,尾部是结束标记。每个条目由"编码类型 + 数据 + 长度"自描述,没有指向前后元素的指针——这是它省内存的根本原因,一个字段省下的 8 到 16 字节指针,在千万级 key 上就是几十 GB 的差距。相比之下,hashtable 编码的 Hash 有一个 dict 结构、两倍于元素数的桶数组,每个字段和值都是独立的 SDS 字符串对象,还要额外挂两个指针串成链表。链式哈希的每个节点仅 next 指针就要 8 字节。

代价也在这里:listpack 没有索引,找第 N 个元素必须从头走。字段数 20 时遍历最多 20 步,几乎无感;字段数 2000 时,一次 HGET 走到末尾就要 2000 步,CPU 消耗会明显上升。这就是阈值存在的意义——它是内存与 CPU 的交换点。

用 OBJECT ENCODING 随时查看当前编码:

HSET user:1001 name tom age 18 city hangzhou
OBJECT ENCODING user:1001     # "listpack"
MEMORY USAGE user:1001        # 128 字节左右
# 字段数超过 hash-max-listpack-entries 后:
OBJECT ENCODING user:1001     # "hashtable",内存涨数倍

量化一下差距:一个 20 字段、字段名 8 字节、字段值 10 字节的 Hash,listpack 编码整体约 500 字节;转成 hashtable 后要额外付出字典哈希表、每个字段两组字符串对象与指针的开销,实测超过 2KB,差出 4 倍。千万个这样的 key,就是几个 GB 与几十个 GB 的差别。

二、各类型阈值速查

| 配置项 | 默认值 | 含义 |

| --- | --- | --- |

| hash-max-listpack-entries / -value | 128 / 64 | 字段数或任一字段值长度超限即转 hashtable |

| list-max-listpack-size | -2 | 每个 quicklist 节点的 listpack 上限,-2 表示 8KB,-1 为 4KB,正数表示元素个数 |

| zset-max-listpack-entries / -value | 128 / 64 | 成员数或任一成员长度超限即转 skiplist |

| set-max-intset-entries | 512 | 全整数集合转 hashtable |

| set-max-listpack-entries / -value | 128 / 64 | Redis 7.2 起普通集合也支持 listpack 编码 |

| stream-node-max-bytes / -entries | 4096 / 100 | Stream 每个 listpack 节点的大小与条目上限 |

判断逻辑是"任一条件超限即转换":哪怕只有 3 个字段,只要某个字段值超过 64 字节,这个 Hash 就是 hashtable 编码。

List 的编码值得单独说明:Redis 7 的列表是 quicklist,即双向链表串起多个 listpack 节点,list-max-listpack-size -2 限制的是单个节点内的 listpack 不超过 8KB,而不是整个列表。这样兼顾了小列表的紧凑与长列表的分段遍历:LRANGE 取连续区间时,节点内是内存连续读取,代价很低。Stream 同理,radix tree 的每个节点是一个受 stream-node-max-bytes 约束的 listpack。

三、调优方法

第一步是量化现状。抽样统计业务里小对象的字段数与值长分布,再对比内存账单:

# 对比同一结构在不同阈值下的内存(测试环境)
CONFIG SET hash-max-listpack-entries 128
MEMORY USAGE user:1001
CONFIG SET hash-max-listpack-entries 512
MEMORY USAGE user:1001

第二步是按收益调阈值。千万级 key 的场景,把 hash-max-listpack-entries 从 128 提到 512、-value 从 64 提到 128,总内存常能降 30%~50%。CONFIG SET 即时生效,只影响之后的编码转换。另一个常被忽视的抓手是字段名:Hash 的字段名同样占存储,user_name 与 un 承载同样语义时内存差一倍量级。画像类业务把字段名收敛成短码,再配合调大的阈值,往往比单调阈值多省一层。

第三步是验证读放大。listpack 里 HGET 一个字段也要从头遍历,阈值调大后要盯 CPU:INFO commandstats 里 hget 的 usec_per_call 上升明显,或 LATENCY 出现 slow-command 事件,说明调过头了。

读放大的量化方法值得掌握。usec_per_call 是累计平均,对突增不敏感,更好的方式是看单条命令的实际耗时随字段数的变化规律:

redis-cli CONFIG SET slowlog-log-slower-than 0
redis-cli HSET demo f1 v1
redis-cli SLOWLOG RESET
redis-cli HGET demo f2000
redis-cli SLOWLOG GET 1
# 1) 1) (integer) 3
#    2) (integer) 1761352800
#    3) (integer) 62
#    4) 1) "HGET"
#       2) "demo"
#       3) "f2000"

把 slowlog-log-slower-than 临时设为 0 能记录所有命令,配合 SLOWLOG RESET 可以精确测量单条命令耗时。上例一次 HGET 末位字段耗时 62 微秒,看起来不大,但它只测了一次;如果这个 key 的 QPS 是 5000,仅这一条命令就要吃掉主线程 31% 的时间。评估方法应当是"单次耗时乘以该 key 的 QPS",得到的才是 CPU 占比。

反过来判断阈值是否过低,可以看内存账单:如果某个业务前缀下大量 key 的字段数只有二三十个却是 hashtable 编码,说明它们被某个超长字段值踢出了 listpack,此时提高 -value 阈值比提高 -entries 更对症。

顺手可以给存量数据做一次编码审计:用 SCAN 遍历加 OBJECT ENCODING 抽样,统计各业务前缀落在 listpack 与标准编码上的比例。如果某个前缀有六成 key 已是 hashtable 编码而字段数只有十几个,多半是字段值或字段名超长,先改数据再调参。

四、单向转换与注意事项

编码转换是单向的:超过阈值转成 hashtable 后,即使随后删字段降回阈值以下,也不会自动变回 listpack,只能删除重写或等 key 过期后自然重建。因此调参前存在的老 key 需要重建才能享受新阈值,灰度新业务时先调参再灌数据是最优顺序。还有一点容易漏:主从或 Cluster 架构下,阈值在每个节点独立生效,CONFIG SET 要对每个节点执行或滚动重启,只改主库会让主从内存水位逐渐偏差,容量评估要按未调参一侧的阈值算才稳妥。

提示:判断调优效果时用 MEMORY USAGE key 抽样统计再乘以 key 总量,不要只看单 key 数字;同时用 redis-cli --bigkeys 确认没有因阈值提高而把"准大 key"留在 listpack 里。

常见坑 / 最佳实践

  1. 阈值不是越大越好。 listpack 查找是 O(N),热点读 key 配几百个字段会把 CPU 打满;读多写少的画像类数据建议 256~512 封顶。
  2. value 长度限制独立于字段数。 一个 70 字节的字段就让整个 Hash 转 hashtable,缩短字段值(如枚举改数字编码)比调参数更有效。
  3. 转换不回退。 新阈值对存量 hashtable 编码的 key 无效,要么重建,要么接受现状。
  4. List 看的是字节数不是元素数。 list-max-listpack-size -2 下,每个 8KB 节点内部仍是 listpack,压测时注意单节点内的遍历成本。
  5. 改阈值只是手段,缩短数据才是本质。 能用短字段名、短枚举值表达的,先改数据结构,再谈调参。
  6. 字段级自增不影响编码判断。 HINCRBY 的字段在 listpack 里仍是字符串存储,自增要取出解析再写回;热点自增字段建议单独拆成 String key,既省 CPU 也享受整数编码。

小结

  • 小对象用 listpack 紧凑存储,超过 entries 或 value 阈值后单向转成标准结构。
  • Redis 7 的关键阈值是 hash/zset/set-max-listpack-*,判断逻辑是任一条件超限即转换。
  • 调优路径:抽样数据分布、CONFIG SET 调阈值、MEMORY USAGE 与 usec_per_call 双向验证。
  • 存量 key 不会自动重编码,新阈值要配合数据重建才能生效。
  • 内存与 CPU 是跷跷板,热点读场景宁要小阈值,冷数据场景大胆调大。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。