这是《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[] 实际字符
}
优点:
- O(1) 获取长度;
- 二进制安全,可存
\0; - 预分配与惰性释放,减少频繁 realloc;
- 多种头部长度(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:
- field 数量超过 512;
- 任意 field/value 长度超过 64 字节。
listpack 省内存,但查找是顺序扫描;因此 entry 上限不能过大。
hashtable
OBJECT ENCODING big-hash
# hashtable
O(1) 查找,支持渐进式 rehash。成本是:
- 指针开销;
- 桶数组与渐进迁移状态;
- 大量 field 内存远高于 listpack。
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
list-max-listpack-size:每个节点大小,负数表示按字节限制;list-compress-depth:两端保留不压缩的节点数,中间可用 LZF 压缩。
quicklist 兼顾:
- 两端 O(1) 推入弹出;
- 中间节点可控大小;
- 冷数据可压缩。
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 同时支持:
- O(1)
ZSCORE(dict); - O(logN)
ZADD/ZRANK(skiplist)。
配置:
zset-max-listpack-entries 128
zset-max-listpack-value 64
跳跃表节点包含多层前进指针和 backward 指针,内存高于普通 Set。排行榜保存百万成员时必须测算内存。
13.8 跳跃表为什么不用红黑树
Redis 作者给出的工程理由大致是:
- 实现更简单,易调试;
- 范围查询按排序链遍历自然高效;
- 与字典组合后已满足 O(1) score 查询;
- 性能可控,内存可通过概率层数控制。
跳表查找/插入/删除平均 O(logN),最坏概率退化,实际工程参数稳定;红黑树最坏 O(logN),但实现和范围操作复杂度更高。
13.9 内存估算
查看真实占用:
MEMORY USAGE user:1001
MEMORY USAGE rank:activity SAMPLES 5
粗略理解:
实际内存 =
key 字符串开销
+ redisObject 头
+ value 具体编码开销
+ 字典/跳表索引
+ 内存分配器碎片
+ 复制缓冲/客户端缓冲等运行时开销
例如一个小 Hash 的 5 个字段理论上不大,但也要计入:
- key SDS;
- redisObject;
- listpack entry 元数据;
- jemalloc size class 向上取整;
- 键空间字典入口。
因此“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 编码调优实践
- 小对象优先保持 listpack/intset:
hash-max-listpack-entries 128
hash-max-listpack-value 32
zset-max-listpack-entries 128
调小阈值会降低单个 listpack 查找成本、增加转 hashtable 概率;调大省内存但可能提升小结构查询成本。
- 拆分大 Hash/ZSet:
rank:activity:2026 -> 按 10000 userId 分片
rank:activity:2026:0
rank:activity:2026:1
- 控制 key/value 长度:
- key 保持语义且短;
- 缓存 DTO 只放必要字段;
- 大文本放对象存储,Redis 存引用。
- 冷热分离:
- 热点缓存留 Redis;
- 历史数据落 MySQL/ClickHouse/对象存储。
13.12 大 key 的判断与治理
参考阈值:
| 类型 | 建议关注 |
|---|---|
| String | value > 10KB 谨慎,> 1MB 重点治理 |
| Hash | field 数 > 5000 或内存 > 10MB |
| Set/ZSet | 元素 > 5000 或内存 > 10MB |
| List/Stream | 长度大或单 entry 大 |
危害:
- 读写耗时高,阻塞单线程;
- 网络带宽放大;
- 删除/过期释放慢;
- 集群槽倾斜;
- 复制缓冲暴涨。
治理:
- 按业务维度拆分;
HSCAN/SSCAN/ZSCAN分页访问;- 删除用
UNLINK; - 设置 TTL 与最大长度;
- 大对象外部存储 + 引用;
- 上线前扫描评估。
本章小结
- type 是用户视角,encoding 是实现视角,可通过
OBJECT ENCODING查看; - String 分 int/embstr/raw,底层 SDS 支持二进制安全和 O(1) 长度;
- 小 Hash/ZSet 常用 listpack,超出阈值转 hashtable/skiplist;
- List 由 quicklist 组织多个紧凑节点;
- ZSet 是 dict + skiplist,内存明显高于普通 Set;
- 实际内存包含对象头、索引、分配器碎片和运行时缓冲,不能只看业务数据大小。
思考题
- 为什么把 Hash 的某个 field 改成 100KB 可能导致编码从 listpack 转 hashtable?
- ZSet 为什么同时保存 dict 和 skiplist?
- 同样保存 100 万成员,Set 和 ZSet 哪个更省内存?为什么?