《Redis 对象编码与内存优化:listpack 与编码转型》

深入 redisObject 结构、String/List/Hash/Set/ZSet 的编码选择、listpack/quicklist/skiplist/dict 底层实现、编码转型阈值、OBJECT ENCODING 观察方法以及紧凑存储、共享整数、过期清理等内存优化策略。

一、redisObject 结构

1.1 一切皆对象

Redis 中的每个 key 和 value 都被包装成一个 redisObject,它承载类型、编码和引用计数,是内存管理的基础:

// 简化后的 redisObject 结构(Redis 6+ 用位域压缩)
typedef struct redisObject {
    unsigned type:4;       // 数据类型: string/list/hash/set/zset
    unsigned encoding:4;   // 底层编码: int/embstr/listpack/...
    unsigned lru:LRU_BITS; // 记录访问时间,用于淘汰
    int refcount;          // 引用计数,0 时释放
    void *ptr;             // 指向底层数据结构
} robj;

1.2 逻辑类型与底层编码分离

关键设计:用户看到的类型(type)与内存存储方式(encoding)是分离的。同一个 Hash 类型,数据少时用紧凑的 listpack,数据多了自动转为 hashtable:

# 逻辑类型相同,底层编码不同
TYPE myhash            # hash
OBJECT ENCODING myhash # listpack(小数据)→ hashtable(大数据)

1.3 为什么需要多编码

编码内存访问速度适用
紧凑编码(int/listpack)省略慢(需解码)小数据
哈希表/跳表费快大数据

核心思路:小数据用紧凑编码换内存,大数据用散列结构换速度。Redis 在编码转型阈值内自动切换,无需人工干预。

二、String 编码选择

2.1 三种编码

String 有三种编码:int(整数)、embstr(短字符串内联)、raw(长字符串):

SET n 100
OBJECT ENCODING n        # int      64 位内可用整数
SET s hello
OBJECT ENCODING s        # embstr   <= 44 字节短字符串
SET t "$(python3 -c 'print("x"*100)')"
OBJECT ENCODING t        # raw      超过 44 字节

2.2 编码切换规则

# int → raw:整数追加字符后编码被破坏
APPEND n abc
OBJECT ENCODING n        # raw

# embstr 只读不可变:修改后转 raw
SET s hello
APPEND s world
OBJECT ENCODING s        # raw
编码条件特点
int值在 64 位整数范围直接存数值,零字符串开销
embstr长度 ≤ 44 字节对象与字符串内存连续分配
raw长度 > 44 字节两次分配,可修改

embstr 的 44 字节来自「redisObject 头 + SDS 头 + 结尾符」在一页内存内的紧凑布局。短字符串首选 embstr,能显著降低分配次数与碎片。

三、List 编码(quicklist/listpack)

3.1 演进历史

Redis List 的底层编码经历了 ziplist → quicklist 的演进,Redis 7.0 又引入 listpack 替换 ziplist:

# 创建 List 并观察编码
RPUSH mylist a b c d
OBJECT ENCODING mylist   # listpack(Redis 7+)
版本底层结构说明
<3.2ziplist / linkedlist小用 ziplist,大用双向链表
3.2~6.xquicklist分片 ziplist 串成链表
7.0+quicklist(节点内 listpack)ziplist 彻底退役

3.2 quicklist 结构

quicklist 是「压缩块 + 链表」的折中:每个节点是一个紧凑的 listpack,多个节点用双向链表连接:

list-max-listpack-size 128        # 单个 listpack 节点最大条数
list-compress-depth 0             # 两端压缩深度,0=不压缩
# 大量元素时
RPUSH biglist $(seq 1 100000)
OBJECT ENCODING biglist   # quicklist

quicklist 的设计目的是:既保留 listpack 的紧凑内存,又避免单个 listpack 过大导致插入 O(n)。列表过长时拆成多个节点,插入在节点边界处很便宜。

四、Hash 编码(listpack/hashtable)

4.1 小 Hash 用 listpack

Hash 字段数少、值小时用 listpack 连续存储,字段与值交替排列:

HSET h f1 v1 f2 v2
OBJECT ENCODING h          # listpack
# 观察 listpack 布局(紧凑排列)
# |len|f1|v1|f2|v2|...|end|

4.2 转型阈值

# 超过任一阈值即转 hashtable
hash-max-listpack-entries 128      # 字段数阈值
hash-max-listpack-value 64         # 单字段值字节数阈值

# 突破阈值后
for i in $(seq 1 200); do HSET bighash f$i v$i; done
OBJECT ENCODING bighash            # hashtable

4.3 选择权衡

编码内存读写性能适用
listpack省 60%+字段少时够快小 Hash(对象、配置)
hashtable费O(1) 稳定大 Hash

Hash 是最典型的「小对象优化」受益者:用户画像、会话等天然小字段对象用 listpack 存储,内存可省一半以上。字段超过阈值才会升级,生产上多数 Hash 都停留在 listpack。

五、Set 编码(intset/hashtable)

5.1 intset 整数集合

当 Set 全为整数且数量少时,用 intset(有序整数数组,二分查找):

SADD myset 1 2 3 4 5
OBJECT ENCODING myset        # intset
# 插入大整数或字符串会破坏 intset
SADD myset 99999999999999999999
OBJECT ENCODING myset        # hashtable

5.2 转型条件

set-max-intset-entries 512   # 超过 512 个整数则转 hashtable
# intset 特性:全整数有序、二分查找 O(log N)、内存定长紧凑
# 插入非整数/超范围数 → 立即升级

5.3 对比

编码成员要求查找复杂度内存
intset全整数O(log N)省
hashtable任意O(1)费

整型 ID 集合(如「已购买商品」)用 intset 存储非常省内存。但删除导致数量下降不会降级——编码只会从小升大,不会反向。

六、ZSet 编码(skiplist/listpack)

6.1 双结构设计

ZSet 的经典实现是 skiplist(跳表)+ dict(字典) 的组合:跳表按 score 排序,字典按 member 定位:

ZADD z 1 a 2 b 3 c
OBJECT ENCODING z            # listpack(小数据,Redis 7+)
# 大数据时
# object encoding: skiplist
# skiplist 结构
# dict:  member -> score   (快速定位分数)
# skiplist: score 排序链表  (范围查询)

6.2 转型阈值

zset-max-listpack-entries 128    # 成员数阈值
zset-max-listpack-value 64       # member 长度阈值
# 小 ZSet 用 listpack 紧凑存储(member+score 交替)
ZADD bigz $(for i in $(seq 1 500); do echo $i member$i; done)
OBJECT ENCODING bigz             # skiplist

6.3 选择权衡

编码适用典型操作
listpack≤128 成员排行榜小集合
skiplist大集合ZRANGEBYSCORE 分页、Top-N

排行榜、延迟队列这类 ZSet,数据规模小时用 listpack 省内存;规模上来后自动转 skiplist,保证 ZRANGE、ZRANGEBYSCORE 的高效。阈值可调,但默认值已是经验平衡点。

七、编码转型阈值

7.1 阈值总览

类型配置项默认值转型方向
Hashhash-max-listpack-entries128listpack→hashtable
Hashhash-max-listpack-value64listpack→hashtable
ZSetzset-max-listpack-entries128listpack→skiplist
ZSetzset-max-listpack-value64listpack→skiplist
Setset-max-intset-entries512intset→hashtable
Listlist-max-listpack-size128listpack 节点拆分

7.2 转型不可逆

# 编码升级是单向的:小升大之后不会因数据减少而降级
SADD s 1 2 3
OBJECT ENCODING s          # intset
SADD s a                   # 转 hashtable
SREM s a
OBJECT ENCODING s          # 仍是 hashtable,不回 intset

单向转型意味着:峰值数据量大时编码会永久停留在高位。如果预期集合偶尔很大,但长期很小,调高阈值能让多数时间停留在紧凑编码。

7.3 调整阈值的影响

调整收益风险
调高 entries 阈值更多小对象用紧凑编码单次 O(N) 操作变慢
调高 value 阈值更长字符串进紧凑编码大 value 拷贝开销
调低阈值更快升级内存上升

阈值调整前用 OBJECT ENCODING 统计现有对象的编码分布,再结合内存与 CPU 权衡。默认值对绝大多数业务已是最优,盲目调大可能让写操作变慢。

八、OBJECT ENCODING 观察

8.1 常用命令

# 对象信息命令
OBJECT ENCODING key        # 底层编码
OBJECT REFCOUNT key        # 引用计数
OBJECT IDLETIME key        # 空闲秒数(配合 LRU)
OBJECT FREQ key            # 访问频率(LFU)

# 批量观察
redis-cli --scan --pattern "user:*" | \
  while read k; do echo -n "$k "; redis-cli object encoding "$k"; done

8.2 诊断内存分布

# 用 redis-cli --bigkeys 找出大 key 与编码分布
redis-cli --bigkeys
# 输出各类型最大 key、其编码、元素数量
诊断项关注点
大 key 的编码是否已升级为散列结构
小 key 却用大编码曾达到转型阈值后未降级
元素总量判断是否超阈值

8.3 编码观察实践

# 生产上定期抽检
# 1. 统计各类型编码分布
# 2. 定位异常(如 3 字段的 hash 竟用 hashtable)
# 3. 结合内存优化手段处理

OBJECT ENCODING 是内存优化的「听诊器」。发现「本应紧凑却用散列」的对象,通常是历史峰值导致的不可逆升级,可用重写(如 HGETALL 后重建)恢复紧凑编码。

九、内存优化策略

9.1 紧凑存储

策略做法效果
小对象聚合字段合并进一个 Hash省大量 key 元数据
整数复用用整数代替字符串触发 int 编码
短 key 名控制 key 长度减少元数据
大 key 拆分拆分超大集合避免单结构膨胀
# 共享整数:0~9999 的整数对象全局共享
SET a 100
OBJECT REFCOUNT a       # 引用计数很高(共享)
# 大整数/字符串则不共享

9.2 过期与清理

# 惰性删除(访问时查)+ 定期删除(每 100ms 抽样)+ 主动碎片整理
CONFIG SET activedefrag yes

# 删除大 key 时避免阻塞主线程
UNLINK bigkey            # 异步释放内存

9.3 优化实战

# 案例:1 亿条 user:xxx:field 散列字符串 → 聚合为 Hash
# 优化前:1 亿个 key,元数据约 100MB+
# 优化后:2500 万 Hash,每个 4 字段,内存降 40%+
手段内存收益注意
listpack 编码小对象省 50%+阈值别盲目调大
int 编码数值省 90%需整数语义
共享整数0~9999 免重复分配只对 int 生效
过期清理释放失效数据惰性+定期配合
碎片整理回收 jemalloc 碎片CPU 开销

内存优化三原则:先看 OBJECT ENCODING 诊断,再选紧凑结构,最后配合过期与清理。不要为了「看着省」破坏读写性能,一切以压测数据为准。

结语

  1. redisObject 将逻辑类型与底层编码分离,同一个逻辑类型可对应多种内存编码,这是内存优化的基础。
  2. String 有 int/embstr/raw 三种编码,44 字节以内短字符串用 embstr,整数用 int 零字符串开销。
  3. List 用 quicklist(内含 listpack 节点)折中内存与插入性能,ziplist 在 Redis 7.0 后退役。
  4. Hash 小对象用 listpack,超过 entries/value 阈值转 hashtable,是「小对象优化」的最大受益者。
  5. Set 全整数且数量少时用 intset 二分查找,插入非整数或超阈值即升级为 hashtable。
  6. ZSet 小数据用 listpack,大数据用 skiplist+dict 组合,支撑 O(log N) 的范围查询。
  7. 编码转型单向不可逆,峰值数据量大时会永久停留高位,可用重写恢复紧凑编码。
  8. 用 OBJECT ENCODING 诊断编码分布,配合紧凑存储、共享整数、过期清理与碎片整理实施内存优化。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. 《Redis 容灾与备份恢复:RDB/AOF 备份、复制与演练》
  2. 《Redis 管道、事务与批量优化:从 N 次 RTT 到一次》
  3. 《Redis 客户端缓存与 RESP3:降低往返延迟》