RedisNotes

第 13 章:内存与对象编码

zjc 于 2026-01-13 发布

这是《Redis 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 Redis 的性能优势来自数据结构,内存成本也来自数据结构。理解对象编码,才能解释“为什么几个字段也占几十 KB”“为什么 Hash 突然变慢”“为什么 ZSet 内存这么大”。

13.1 Redis 对象模型概览

每个 value 在逻辑上都是一个 redisObject,包含:

redisObject
  type       数据类型:String/Hash/List/Set/ZSet...
  encoding   底层编码:int/embstr/listpack/hashtable/skiplist...
  refcount   引用计数(含共享对象优化)
  lru/LFU    空闲或访问信息,用于淘汰
  ptr        指向具体实现

查看类型与编码:

TYPE user:1001
OBJECT ENCODING user:1001
OBJECT FREQ user:1001
OBJECT IDLETIME user:1001
MEMORY USAGE user:1001

type 是用户视角,encoding 是实现视角。 同一个 Hash,小数据可用 listpack 省内存,大数据切换 hashtable 提升操作稳定性。

13.2 String 的编码

int

value 是整数且在范围内:

SET counter 100
OBJECT ENCODING counter
# int

优点:省内存、计数快。执行 APPEND 或改成非整数后,会转换成 embstr/raw。

embstr

短字符串使用 embstr:redisObject 与 SDS 内存连续分配,一次 malloc。

阈值(典型版本):

len <= 44 bytes -> embstr
len > 44 bytes -> raw

不同版本实现可能略有差异。embstr 只读,修改时会转为 raw 并重新分配。

raw

长字符串分成两块内存:redisObject 与 SDS 分开分配。

SET article:1001 <很长文本>
OBJECT ENCODING article:1001
# raw

13.3 SDS:简单动态字符串

Redis 自定义 SDS,而不是直接 C 字符串:

struct sdshdr {
    len      已使用长度
    alloc    总分配长度
    flags    类型标记
    buf[]    实际字符
}

优点:

  1. O(1) 获取长度;
  2. 二进制安全,可存 \0
  3. 预分配与惰性释放,减少频繁 realloc;
  4. 多种头部长度(sdshdr8/16/32/64),小字符串省内存。

空间预分配简化规则:

修改后 len < 1MB -> 额外分配 len
修改后 len >= 1MB -> 额外分配 1MB

因此频繁追加大字符串可能造成内存放大。

13.4 Hash 编码:listpack 与 hashtable

listpack

小 Hash 使用紧凑连续内存,每个 entry 顺序保存 field/value。

HSET user:1001 name Tom age 28
OBJECT ENCODING user:1001
# listpack

转换阈值由配置控制:

hash-max-listpack-entries 512
hash-max-listpack-value 64

任一条件超出即转 hashtable:

listpack 省内存,但查找是顺序扫描;因此 entry 上限不能过大。

hashtable

OBJECT ENCODING big-hash
# hashtable

O(1) 查找,支持渐进式 rehash。成本是:

13.5 List 编码:quicklist

现代 Redis 的 List 由 quicklist 实现:多个 listpack 节点组成双端链表。

RPUSH queue:task a b c
OBJECT ENCODING queue:task
# quicklist

配置:

list-max-listpack-size -2
list-compress-depth 0

quicklist 兼顾:

13.6 Set 编码:intset 与 listpack/hashtable

整数小集合可用 intset:

SADD nums 1 2 3
OBJECT ENCODING nums
# intset

配置:

set-max-intset-entries 512
set-max-listpack-entries 128
set-max-listpack-value 64

编码路径:

全整数且少 -> intset
小集合短元素 -> listpack
超出限制或包含非整数 -> hashtable

hashtable Set 只关心 key,不存 value,但字典本身仍有指针和桶开销。

13.7 ZSet 编码:listpack 与 skiplist

listpack

小 ZSet 可用 listpack,按 score/member 排序存储。

skiplist

较大 ZSet 使用:

dict: member -> score
skiplist: 按 score/member 排序

因此 ZSet 同时支持:

配置:

zset-max-listpack-entries 128
zset-max-listpack-value 64

跳跃表节点包含多层前进指针和 backward 指针,内存高于普通 Set。排行榜保存百万成员时必须测算内存。

13.8 跳跃表为什么不用红黑树

Redis 作者给出的工程理由大致是:

  1. 实现更简单,易调试;
  2. 范围查询按排序链遍历自然高效;
  3. 与字典组合后已满足 O(1) score 查询;
  4. 性能可控,内存可通过概率层数控制。

跳表查找/插入/删除平均 O(logN),最坏概率退化,实际工程参数稳定;红黑树最坏 O(logN),但实现和范围操作复杂度更高。

13.9 内存估算

查看真实占用:

MEMORY USAGE user:1001
MEMORY USAGE rank:activity SAMPLES 5

粗略理解:

实际内存 =
  key 字符串开销
  + redisObject 头
  + value 具体编码开销
  + 字典/跳表索引
  + 内存分配器碎片
  + 复制缓冲/客户端缓冲等运行时开销

例如一个小 Hash 的 5 个字段理论上不大,但也要计入:

因此“1KB JSON”存 Redis 后,占用的不只是 1KB。

13.10 jemalloc 与内存碎片

Redis 常用 jemalloc,按 size class 分配。释放大量小对象后,可能出现:

used_memory < 当前 RSS
mem_fragmentation_ratio > 1.5

查看:

INFO memory
MEMORY STATS
MEMORY DOCTOR

重点字段:

字段 含义
used_memory Redis 分配器分配的数据内存
used_memory_rss 操作系统视角进程驻留内存
mem_fragmentation_ratio RSS / used_memory
mem_allocator 分配器
allocator_frag_ratio 分配器碎片率

碎片常见于大量不同大小 key 的创建删除。活跃碎片整理配置:

activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
active-defrag-threshold-upper 100

开启前要评估 CPU 影响,通常只在高碎片、高可用内存场景使用。

13.11 编码调优实践

  1. 小对象优先保持 listpack/intset:
hash-max-listpack-entries 128
hash-max-listpack-value 32
zset-max-listpack-entries 128

调小阈值会降低单个 listpack 查找成本、增加转 hashtable 概率;调大省内存但可能提升小结构查询成本。

  1. 拆分大 Hash/ZSet:
rank:activity:2026 -> 按 10000 userId 分片
rank:activity:2026:0
rank:activity:2026:1
  1. 控制 key/value 长度:
  1. 冷热分离:

13.12 大 key 的判断与治理

参考阈值:

类型 建议关注
String value > 10KB 谨慎,> 1MB 重点治理
Hash field 数 > 5000 或内存 > 10MB
Set/ZSet 元素 > 5000 或内存 > 10MB
List/Stream 长度大或单 entry 大

危害:

治理:

  1. 按业务维度拆分;
  2. HSCAN/SSCAN/ZSCAN 分页访问;
  3. 删除用 UNLINK
  4. 设置 TTL 与最大长度;
  5. 大对象外部存储 + 引用;
  6. 上线前扫描评估。

本章小结

思考题

  1. 为什么把 Hash 的某个 field 改成 100KB 可能导致编码从 listpack 转 hashtable?
  2. ZSet 为什么同时保存 dict 和 skiplist?
  3. 同样保存 100 万成员,Set 和 ZSet 哪个更省内存?为什么?