数据库基础体系 · 第 119/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
Redis 对象编码与内存:SDS、Dict、Listpack、碎片和大 Key
Redis 的命令接口面向的是 String、Hash、List、Set、Sorted Set、Stream 等抽象数据类型;这些类型并不等同于底层内存结构。
同一个 Redis Key,在不同数据规模、元素类型和配置下,可能使用完全不同的内部编码。例如:
- 一个整数形式的 String 可能直接存储在对象指针中;
- 一个较短的 Hash 可能使用连续内存中的 Listpack;
- Hash 变大后可能转换为 Dict;
- List 通常由 Quicklist 组织,Quicklist 节点内部又可能使用 Listpack;
- Sorted Set 在较小时可能使用 Listpack,变大后通常使用 Dict 加跳跃表;
- 底层分配器释放内存后,进程 RSS 仍可能不下降,形成内存碎片;
- 一个逻辑上只有一个 Key 的大集合,可能导致命令阻塞、网络响应巨大、复制和持久化放大。
理解这些关系,才能正确解释 MEMORY USAGE、OBJECT ENCODING、RSS、碎片率和“大 Key”。
本文讨论 Redis 官方稳定版本公开语义下的通用机制。具体内部结构属于实现细节,可能随 Redis 版本变化;因此“某种数据类型通常采用某编码”不能当作永久 API 保证。
一、先区分四个层次:数据类型、对象、编码和分配器
一个 Redis Key 的内存占用至少可以分成四个层次。
1. 逻辑数据类型
这是客户端看到的类型:
String
Hash
List
Set
Sorted Set
Stream
例如:
HSET user:1 name Alice age 30
逻辑上,user:1 是一个 Hash,包含两个 field-value 对。
2. Redis 对象
Redis 内部会为 Key 和 Value 建立对象。对象通常包含:
- 类型信息,例如 String、Hash、List;
- 编码信息,例如
hashtable、listpack、quicklist; - 指向实际数据的指针,或直接携带某些小整数值;
- 引用计数等运行时信息。
对象本身也占用内存,因此“用户字符串长度之和”不等于 Redis 的真实内存占用。
3. 对象编码
编码是某种逻辑类型的具体表示方式。例如:
TYPE user:1
OBJECT ENCODING user:1
可能得到:
hash
listpack
TYPE 回答“它是什么逻辑类型”,OBJECT ENCODING 回答“当前采用什么底层编码”。
编码可以发生转换。比如一个小 Hash 使用 Listpack,当元素数量或 field/value 长度超过配置阈值后,Redis 会将其转换为 Dict。这个转换对普通命令的逻辑结果透明,但会产生一次额外的 CPU 和内存开销。
4. 内存分配器
Redis 向系统申请内存时,通常通过内存分配器完成。常见分配器会按 size class 管理内存块,并可能保留已经申请的页以便复用。
因此至少要区分:
- 数据结构占用:对象、SDS、Dict、Listpack 等申请的内存;
- Redis 使用内存:可通过
INFO memory中的used_memory等指标观察; - 分配器驻留内存:分配器向操作系统申请后仍保留的内存;
- 进程 RSS:操作系统看到的进程驻留物理内存;
- 内存碎片:已申请但不能高效复用,或暂时未还给操作系统的内存。
简化地说,可以把关系理解为:
而 Redis 常见的碎片率近似为:
其中:
used_memory是 Redis 认为正在使用的内存;used_memory_rss是操作系统报告的 Redis 进程 RSS。
这个比值只能用于诊断,不能理解为精确的“浪费百分比”。
二、SDS:Redis 字符串不是 C 字符串
2.1 为什么 Redis 需要 SDS
C 字符串通常以 \0 结尾,求长度需要从头扫描:
Redis 频繁执行字符串长度、追加、比较和截取操作,因此不能把所有字符串都当作普通 C 字符串处理。
SDS,即 Simple Dynamic String,是 Redis 自己使用的动态字符串结构。它通常保存:
- 当前字符串长度
len; - 已分配但尚未使用的空间
free; - 实际字符数组
buf; - 结尾的
\0,方便与部分 C API 交互。
于是求长度可以直接读取 len:
这并不表示所有字符串操作都是 O(1)。例如追加、复制和比较仍然可能与字符串长度相关。
2.2 SDS 的容量和追加
设 SDS 当前:
len = 5
free = 3
buf = "hello\0..."
追加 2 个字节时:
追加前:len=5, free=3
追加后:len=7, free=1
不需要重新分配。
追加 5 个字节时,原有空闲空间不足,需要扩容。Redis 的扩容策略会尽量预留一部分额外空间,减少连续追加导致的频繁 realloc。具体扩容策略属于实现细节,但核心目标是降低重复搬迁成本。
因此,SDS 的内存通常不只是:
还包括:
2.3 SDS 的二进制安全
SDS 通过显式长度记录字符串,因此可以包含二进制零字节:
A \0 B
长度仍然是 3,而不是被 \0 截断。这是 Redis String 能够保存任意二进制内容的重要原因。
但命令协议、客户端编码和应用层仍可能有自己的限制。例如,客户端把 JSON、压缩数据或 protobuf 放入 String 时,数据格式由客户端负责;Redis 只把它视为字节序列。
2.4 String 的三种常见表示
Redis 的 String 不是只有一种内部表示。常见情况包括:
普通字符串
普通字符串可以使用类似 SDS 的动态字符串存储。
embstr
较短的字符串通常可以使用 embstr 编码。它把 Redis 对象和字符串内容安排在一次连续分配中。
优点是:
- 分配次数较少;
- 对短字符串更节省管理开销;
- 对缓存局部性较友好。
缺点是这种对象通常不适合原地修改;修改时可能转为另一种表示。
int
如果 String 的内容可以解析为合适范围内的整数,Redis 可能直接使用整数值表示:
SET counter 123
OBJECT ENCODING counter
可能返回:
int
这并不意味着 GET counter 返回的语义发生变化;客户端仍然得到字节字符串 "123"。它只是 Redis 内部没有必要为这几个数字字符保存完整 SDS。
但是下面这些值不一定采用整数编码:
"00123"
"+123"
"123 "
"123.0"
是否能够使用整数表示取决于 Redis 的解析规则和整数范围。应用不能依赖某个具体值永久保持 int 编码。
三、Dict:Hash Table 不只是“一个数组”
3.1 Dict 的基本组成
Dict 是 Redis 中广泛使用的哈希表结构。它通常由:
- 哈希表数组;
- 每个桶中的链表或链式节点;
- 记录 Key、Value 和下一个节点指针的 Dict Entry;
- 哈希表大小、已用节点数、掩码等元数据;
组成。
插入一个键值对时,流程可以简化为:
- 对 Key 计算哈希值;
- 用哈希表大小的掩码定位桶;
- 在桶中查找是否存在同 Key;
- 新 Key 插入链表,或替换已有 Value;
- 根据负载情况判断是否扩容。
如果哈希表大小为 ,常见桶定位形式是:
理想情况下,桶内元素较少,查找接近 O(1)。发生哈希冲突时,查找成本与该桶链表长度相关。
3.2 Redis 使用渐进式 Rehash
直接把一个大型 Dict 从旧表搬到新表,会在单次命令中产生很长的停顿。因此 Redis 通常使用两个哈希表:
ht[0]:旧表
ht[1]:新表
扩容开始后,Redis 不会立即搬完全部元素,而是:
- 创建新表;
- 记录 Rehash 游标;
- 每次执行相关命令时搬运少量桶;
- 在服务器空闲时继续推进;
- 迁移完成后释放旧表。
查找期间可能需要同时检查 ht[0] 和 ht[1]。这就是渐进式 Rehash 的核心状态。
删除大量元素时,也可能触发收缩或留下分配器层面的空闲空间;逻辑删除完成不代表 RSS 必然同步下降。
3.3 Dict 的空间成本
一个 Dict 的成本不只是 field 和 value 的字节数,还包括:
- 哈希表桶数组;
- 每个 Dict Entry;
- Key 和 Value 对象;
- SDS 头部和容量;
- 指针、对齐和分配器元数据;
- 扩容期间旧表和新表可能同时存在。
因此,对许多很短的 field/value,元数据可能比有效字符本身还大。
例如,假设 Hash 有 1000 个短 field,每个 field 只有 4 字节、value 只有 1 字节。逻辑内容只有几 KB,但每一对 field-value 仍需要对象、指针、哈希节点及对齐空间。此时“把所有字段字符长度相加”会明显低估内存。
3.4 Dict 不只用于 Hash
Dict 还可能用于:
- Redis 数据库的主 Key 字典;
- Set 的哈希集合编码;
- Sorted Set 中用于按 member 定位 score 的字典;
- Stream 的部分索引结构;
- 服务器内部的其他映射。
所以一个 Redis 实例的内存增长,不一定来自用户创建的 Hash;大量顶层 Key 本身也会产生大量 Dict Entry 和 SDS。
四、Listpack:紧凑,但不是通用 O(1) 容器
4.1 Listpack 的布局
Listpack 是一种紧凑的连续内存结构,用于保存一串 entry。每个 entry 会编码:
- 字符串或整数内容;
- entry 的长度信息;
- 供反向遍历使用的必要元数据。
逻辑上可以表示为:
[entry 0][entry 1][entry 2]...[end marker]
Hash 使用 Listpack 时,通常按如下形式保存:
[field0][value0][field1][value1]...
Sorted Set 使用 Listpack 时,通常交替保存:
[member0][score0][member1][score1]...
Listpack 的优势在于:
- 连续内存;
- 指针和节点数量少;
- 对短元素的额外开销低;
- 缓存局部性较好;
- 小对象集合的内存占用通常优于通用 Dict 或节点结构。
4.2 Listpack 的代价
连续结构的核心代价是插入、删除或扩容可能移动后续内容。
如果在长度为 的 Listpack 中间插入一个 entry,最坏情况下需要搬迁接近 的字节,因此不能把 Listpack 当作哈希表或链表来理解。
对小对象而言,Listpack 节省的元数据通常值得这部分线性操作成本;对大对象而言,线性搬迁会变得不可接受,所以 Redis 会在达到阈值后转换编码。
4.3 Listpack 并不保证“永远紧凑”
Listpack 是连续内存,但:
- entry 的长度编码仍有额外字节;
- 扩容可能产生临时分配;
- 删除后可能发生搬迁;
- 分配器可能保留旧容量;
- 外层对象和顶层 Key 仍然有额外成本。
因此“使用 Listpack”不等于“内存只等于字符总长度”。
4.4 Listpack 与旧 Ziplist 的关系
较新 Redis 版本中,Listpack 已经承担了许多过去由 Ziplist 承担的角色。不能把旧资料中所有关于 Ziplist 的结论直接套用到当前 Redis。
尤其是:
- 配置项名称可能已经从
*-ziplist-*演进为*-listpack-*; - 编码名称可能显示为
listpack; - 具体内部字段布局和边界检查方式可能不同。
生产诊断时应以运行实例的 CONFIG GET 和 OBJECT ENCODING 为准,而不是只看旧版本博客。
五、各数据类型如何选择编码
编码选择通常由两类条件共同影响:
- 元素数量;
- 单个元素的长度或值范围。
5.1 Hash:Listpack 与 Dict
小 Hash 通常适合 Listpack;当以下条件之一不满足时,可能转换为 Dict:
- field 数量不超过
hash-max-listpack-entries; - field 或 value 长度不超过
hash-max-listpack-value。
可以查看实例当前配置:
CONFIG GET hash-max-listpack-entries
CONFIG GET hash-max-listpack-value
示例:
HSET profile:1 name Alice age 30
OBJECT ENCODING profile:1
如果返回:
listpack
继续写入一个超长 value:
HSET profile:1 description "<一段超过配置阈值的字符串>"
OBJECT ENCODING profile:1
可能变为:
hashtable
这里有三个重要边界:
- 阈值是触发条件,不是内存精确预算;
- 转换通常是单向扩展到更通用的编码,不会因为删除元素就自动恢复成小编码;
HSET的逻辑成功不代表没有发生较大的内部搬迁。
“把 Hash 拆成多个小 Hash”也不能简单等同于节省内存。拆分会增加顶层 Key、SDS 和数据库 Dict 的开销,但可能改善访问粒度、删除粒度和单次命令延迟。
5.2 List:Quicklist 与 Listpack
Redis List 通常使用 Quicklist。Quicklist 可以理解为:
Quicklist
├── 节点 0:一个 Listpack
├── 节点 1:一个 Listpack
└── 节点 2:一个 Listpack
它在两个目标之间折中:
- 不为每个 list 元素都单独分配一个链表节点;
- 不把整个 List 放进一个无限增长的连续块。
因此,一个很长的 List 可以分散在多个 Listpack 节点中。节点大小、压缩策略等会影响实际内存和访问成本,具体行为受 Redis 版本和配置影响。
查看配置:
CONFIG GET list-max-listpack-size
CONFIG GET list-compress-depth
Quicklist 的一个常见误解是:“List 的每个元素都是一个独立 malloc 块”。当前常见实现并非如此,Listpack 节点会把多个元素放在一起。
5.3 Set:整数集合、Listpack 与 Dict
Set 的编码取决于成员形式和规模。
对于全是可表示整数的小 Set,Redis 可能使用整数集合编码。较新的 Redis 版本也可能在小 Set 上使用 Listpack,具体取决于版本和配置。
查看相关配置:
CONFIG GET set-max-intset-entries
CONFIG GET set-max-listpack-entries
CONFIG GET set-max-listpack-value
当成员数量、成员长度或成员类型超出紧凑编码适用范围时,Set 会转换为 Dict。
例如:
SADD numbers 1 2 3 4
OBJECT ENCODING numbers
可能返回 intset 或 listpack,不能把某个具体结果当作所有稳定版本的保证。
加入非整数成员:
SADD numbers abc
OBJECT ENCODING numbers
可能触发转为 hashtable。转换后,即便删除 abc,也不应假设 Redis 会自动恢复为整数集合。
5.4 Sorted Set:Listpack、Dict 与跳跃表
Sorted Set 需要支持两种核心操作:
- 按 member 找到 score;
- 按 score 排序、范围查询和排名查询。
因此,较大的 Sorted Set 通常需要同时维护:
- Dict:
member -> score; - 跳跃表:按 score 和 member 排序。
小 Sorted Set 可以使用 Listpack,避免同时维护两套通用索引。相关配置包括:
CONFIG GET zset-max-listpack-entries
CONFIG GET zset-max-listpack-value
例如:
ZADD leaderboard 100 alice 90 bob 80 carol
OBJECT ENCODING leaderboard
ZRANGE leaderboard 0 -1 WITHSCORES
当 member 数量或 member 长度超过紧凑编码条件时,可能转换为 skiplist。在大 Sorted Set 中,逻辑上一条 member-score 关系可能对应 Dict 和跳跃表中的多份结构信息,所以不能用“member 字符串长度加 score 字节数”估算真实占用。
六、编码转换的形式化理解
设某种紧凑编码的适用条件是:
其中:
- :元素数量;
- :允许的最大元素数;
- :单个 field、member 或 value 的最大长度;
- :对应配置阈值。
只要任一条件失败:
就可能触发转换到更通用的编码。
完整算例:
假设某 Hash 当前配置为:
hash-max-listpack-entries = 512
hash-max-listpack-value = 64
初始写入:
N = 2
最长 value = 5
于是:
可以保持 Listpack。
继续写入第 513 个 field:
N = 513
此时:
即使所有 value 都只有 1 字节,也可能转换为 Dict。
另一条路径是保持:
N = 10
但写入长度为 65 的 value:
同样可能触发转换。
这个模型只能说明“为什么会触发”,不能推出转换后精确占用多少。精确占用还取决于 SDS 容量、对象头、哈希表大小、分配器对齐和当前扩容状态。
七、如何测量:不要只看一个指标
7.1 查看逻辑类型与编码
TYPE key
OBJECT ENCODING key
OBJECT REFCOUNT key
示例:
SET s 123
TYPE s
OBJECT ENCODING s
可能得到:
string
int
OBJECT ENCODING 主要用于解释内部表示,不应被业务逻辑依赖。
7.2 使用 MEMORY USAGE
MEMORY USAGE key
这个命令返回该 Key 在 Redis 内部估算的内存占用,通常包括:
- Key 对象;
- Value 对象;
- 底层编码结构;
- 相关动态分配。
它不是操作系统 RSS,也不是整个数据库的内存统计。
对聚合数据结构,可以使用采样参数:
MEMORY USAGE user:1 SAMPLES 0
SAMPLES 用于控制嵌套结构估算时采样的元素数量。采样越少,开销通常越低,但估算可能更粗略;SAMPLES 0 表示不采样而遍历全部元素,可能对巨大结构产生明显 CPU 成本。
因此,下面两条命令的用途不同:
MEMORY USAGE large:hash
INFO memory
前者回答“某一个 Key 大约占多少 Redis 内存”,后者回答“整个实例的内存状态如何”。
7.3 查看全局内存与碎片
INFO memory
重点字段包括:
used_memory
used_memory_human
used_memory_rss
used_memory_peak
mem_fragmentation_ratio
mem_allocator
maxmemory
maxmemory_policy
lazyfree_pending_objects
不同 Redis 版本可能增加字段或调整统计口径,因此诊断时应结合具体版本文档。
可以用如下近似计算:
RSS 与 used_memory 的差异
= used_memory_rss - used_memory
但差异不全部是“可以立即释放的垃圾”。它可能来自:
- 分配器保留页;
- Redis 自身未计入或难以精确归属的内存;
- 模块分配;
- 共享页;
- jemalloc 元数据;
- 碎片;
- Fork 后的页状态。
7.4 使用 redis-cli --bigkeys
在独立诊断窗口中执行:
redis-cli --bigkeys
它会扫描 Key,并按数据类型统计较大的 Key,通常通过抽样或命令级统计寻找候选对象。
需要注意:
- 它会遍历数据库;
- 对线上实例会消耗 CPU、网络和连接资源;
- 它关注“元素数量”与某些类型的最大元素,不等价于真实字节数;
- 一个包含少量超长 value 的 Hash,可能不容易仅凭元素数量识别;
- Cluster 模式下需要分别扫描各个节点,不能把单节点命令当成全局扫描。
如果要按字节识别候选对象,通常需要结合 SCAN 与 MEMORY USAGE,但对每个 Key 执行一次内存测量同样会增加负载。
八、内存碎片:删除成功,RSS 为什么不降
8.1 碎片不是一个单一问题
“碎片”至少包括两种情况。
外部碎片
已经释放的内存块分散在不同位置,新的申请可能无法直接使用它们,或者分配器暂时不把相关页还给操作系统。
内部碎片
分配器按固定大小等级分配。例如申请 33 字节,实际可能从更大的 size class 中分配,因此申请大小和实际占用不完全相等。
Redis 的 mem_fragmentation_ratio 是一个便于观察的比值,但不是严格的碎片率定义。
8.2 删除流程与 RSS
执行:
DEL large:key
逻辑流程是:
- 从数据库 Dict 中删除顶层 Key;
- 释放 Value 对象;
- 释放底层结构;
- 把释放的块交给分配器;
- 分配器决定复用、合并或归还给操作系统。
第 4 步完成后,Redis 逻辑使用内存可能下降;但第 5 步不保证立即导致 RSS 下降。
因此可能出现:
used_memory 下降
used_memory_rss 基本不变
mem_fragmentation_ratio 上升
这不一定表示 DEL 失败。
8.3 UNLINK 的区别
UNLINK large:key
会先从 Key 空间中解除引用,再把较重的释放工作安排给后台线程异步处理。
它的主要价值是减少主线程同步释放大对象造成的延迟尖峰。但要区分两个时间点:
- Key 从逻辑数据库中消失;
- 底层内存真正释放并可能影响 RSS。
后台释放尚未完成时,可以观察:
INFO memory
中的:
lazyfree_pending_objects
UNLINK 不是“零成本删除”:
- 解除 Key 与遍历、释放结构仍然需要工作;
- 后台线程处理能力有限;
- 大量异步删除可能形成待处理队列;
- 删除期间 CPU、内存和持久化行为仍需监控。
8.4 主动碎片整理
Redis 支持主动碎片整理能力,但它通常需要在构建支持的前提下,通过配置启用。应先检查:
CONFIG GET activedefrag
CONFIG GET active-defrag-*
主动整理会尝试搬迁对象,使内存页更紧凑,从而降低分配器碎片。它不是免费操作,可能消耗 CPU,并且不是所有内存都能整理。
生产上启用或调整时,应观察:
- Redis 主线程延迟;
- CPU 使用率;
- RSS;
used_memory;- 碎片率;
- 主动整理相关
INFO memory字段。
不能仅因为碎片率高就盲目重启。重启会重新装载数据、影响连接和故障恢复,还可能造成缓存冷启动或持久化恢复压力。
九、Fork、持久化与内存峰值
内存问题不能只看在线数据结构,还要看持久化路径。
9.1 Fork 的 Copy-on-Write
在 RDB 生成或 AOF 重写时,Redis 可能 Fork 子进程。Fork 后,父子进程起初共享物理页;当父进程继续修改某个页时,操作系统需要复制该页:
Fork 前:
父进程 ──> 物理页 P
Fork 后:
父进程 ──┐
├──> 物理页 P
子进程 ──┘
父进程修改 P:
父进程 ──> 新页 P'
子进程 ──> 原页 P
这就是 Copy-on-Write,简称 COW。
如果写入很平稳,额外内存可能有限;如果 Fork 期间持续修改大量页面,额外内存可能显著增长。大 Key 会放大这个问题,因为:
- 修改大 Listpack 可能触碰包含大量元素的页;
- 更新大 Hash 的结构可能修改多处页;
- 删除或过期大对象也可能产生大量页写入;
- AOF 重写或 RDB 期间内存峰值更难预测。
9.2 大 Key 与持久化的关系
一个大 Key 不只影响某条业务命令,还可能影响:
- RDB 生成的扫描和序列化;
- AOF 重写;
- 主从复制传输;
- 故障转移期间的同步;
- 节点迁移和 Cluster reshard;
- 备份恢复时间。
所以评估大 Key 时,不能只问“GET 是否慢”,还应问“它在复制、持久化和故障恢复路径上会产生什么成本”。
十、大 Key 到底是什么
大 Key 没有一个适用于所有业务的固定字节阈值。
它可以有三种不同含义:
1. 大元素
例如一个 String 本身是几十 MB:
SET document:1 "<很大的二进制内容>"
这类 Key 的问题包括:
- 单次读写响应大;
- 网络缓冲和客户端内存压力高;
GET、SET、删除或过期处理可能形成长尾;- 复制和 AOF 追加会产生大块数据。
2. 大容器
例如一个 Hash 有数百万 field,或者一个 Set 有数百万成员。
它的风险包括:
- 某些命令需要遍历大量元素;
HGETALL、SMEMBERS、LRANGE 0 -1、ZRANGE ... WITHSCORES会产生巨量响应;- 删除和编码转换成本高;
- 持久化、复制和迁移耗时增加。
3. 热大 Key
即使字节数不大,如果一个 Key 被大量客户端同时访问,也可能成为热点:
- 单线程命令执行集中在同一 Key;
- Cluster 只能把同一个 Key 放在一个槽位;
- 访问集中会使单节点或单分片成为瓶颈。
因此“大 Key”和“热 Key”是两个不同维度,可能同时出现,也可能单独出现。
十一、大 Key 的复杂度与失败表现
下面的风险来自命令复杂度和返回结果规模,而不是 Redis 抽象类型本身。
11.1 不应直接读取整个集合
以下命令在大对象上容易产生长时间阻塞或巨大响应:
HGETALL large:hash
SMEMBERS large:set
LRANGE large:list 0 -1
ZRANGE large:zset 0 -1 WITHSCORES
XRANGE large:stream - +
更安全的做法是使用分页或游标命令:
HSCAN large:hash 0 COUNT 100
SSCAN large:set 0 COUNT 100
ZSCAN large:zset 0 COUNT 100
XRANGE large:stream - + COUNT 100
以 HSCAN 为例,返回形式类似:
1) "128"
2) 1) "field-a"
2) "value-a"
3) "field-b"
4) "value-b"
第一项是下一次使用的游标。调用方必须持续扫描,直到游标返回 "0":
HSCAN large:hash 0 COUNT 100
HSCAN large:hash 128 COUNT 100
...
HSCAN large:hash 0 COUNT 100
COUNT 是提示值,不是严格的每页记录数保证。扫描期间如果集合发生变化,可能看到重复或跳过某些元素;应用需要具备幂等或去重能力。
11.2 SCAN 不是事务快照
SCAN、HSCAN、SSCAN 和 ZSCAN 不提供快照语义。
在扫描过程中:
- 新增元素可能被看到,也可能看不到;
- 删除元素可能导致它不再出现;
- 修改元素可能造成结果变化;
- 可能出现重复返回。
如果业务需要一致性快照,必须采用额外方案,例如版本标记、不可变数据、复制到临时 Key 或在应用层建立一致性协议;不能把 SCAN 当作事务遍历。
11.3 删除大 Key
同步删除:
DEL large:hash
可能在执行期间释放大量结构,造成主线程延迟尖峰。
异步删除:
UNLINK large:hash
通常更适合降低同步释放的阻塞风险,但需要继续观察:
INFO memory
如果是 Cluster,必须在持有该 Key 的节点上执行。客户端应处理:
MOVED重定向;ASK临时重定向;- 主从切换;
- 删除后业务并发重建。
如果删除后立即由多个客户端重建同一个大 Key,还可能出现“删除—重建—再次删除”的内存和负载震荡。
11.4 分批删除集合成员
对 Hash、Set、Sorted Set,可以先扫描再分批删除:
HSCAN cache:hash 0 COUNT 100
HDEL cache:hash field-a field-b field-c
SSCAN cache:set 0 COUNT 100
SREM cache:set member-a member-b member-c
ZSCAN cache:zset 0 COUNT 100
ZREM cache:zset member-a member-b member-c
这降低单次命令工作量,但不是原子操作。若删除过程必须和其他更新严格协调,需要使用事务、Lua 脚本或业务版本控制;不过 Lua 或事务把大量成员放入单次执行,也可能重新制造长命令问题。
十二、一个完整的诊断示例
以下示例针对单实例 Redis,使用默认命令语义;在 Cluster 中应针对实际持有 Key 的节点执行。
12.1 创建一个小 Hash
DEL profile:42
HSET profile:42 name Alice age 30 city Beijing
TYPE profile:42
OBJECT ENCODING profile:42
MEMORY USAGE profile:42
预期:
TYPE -> hash
OBJECT ENCODING -> 通常是 listpack
MEMORY USAGE -> 一个与具体版本、分配器和配置相关的字节数
这里“通常”很重要。Redis 官方命令语义保证它是 Hash,但不保证每个版本、每种配置都返回同一内部编码。
12.2 让它变成大 Hash
先查看阈值:
CONFIG GET hash-max-listpack-entries
CONFIG GET hash-max-listpack-value
如果当前数量阈值为 N,可以写入超过该数量的字段。为了避免在示例中硬编码版本默认值,可以使用客户端循环:
for i in $(seq 1 600); do
redis-cli HSET profile:large "field:$i" "value:$i" >/dev/null
done
redis-cli OBJECT ENCODING profile:large
redis-cli MEMORY USAGE profile:large
如果配置阈值低于 600,编码可能变为:
hashtable
然后比较读取方式:
HGET profile:large field:1
HSCAN profile:large 0 COUNT 100
HGETALL profile:large
HGET只读取一个字段;HSCAN分批遍历;HGETALL一次返回整个 Hash,不适合未经评估地用于大 Hash。
12.3 观察删除后的状态
UNLINK profile:large
INFO memory
重点观察:
lazyfree_pending_objects
used_memory
used_memory_rss
mem_fragmentation_ratio
如果 lazyfree_pending_objects 暂时大于 0,说明后台释放队列尚有工作。即使队列归零,RSS 也未必立刻回到删除前的数值,因为分配器可能保留可复用内存。
十三、常见误解与反例
误解一:Hash 一定比多个 String 节省内存
反例:
SET user:1:name Alice
SET user:1:age 30
SET user:1:city Beijing
与:
HSET user:1 name Alice age 30 city Beijing
哪一种更省,取决于:
- 顶层 Key 数量;
- Hash 是否保持 Listpack;
- field/value 长度;
- 访问模式;
- 删除和过期粒度;
- 分配器和版本。
Hash 往往减少顶层 Key 数量,但一个 Hash 不能为每个 field 单独设置 Redis 过期时间;多个 String 则可以。不能只按数据类型名称判断内存。
误解二:Listpack 一定比 Dict 快
Listpack 对小容器常常更省内存,也可能有良好的缓存局部性。但它的中间插入、删除和查找可能需要线性扫描或搬迁。
当容器足够大时,Dict 的近似 O(1) 查找通常更适合按 field/member 访问。紧凑编码的价值是“小规模时节省”,不是“所有规模都更快”。
误解三:删除 Key 后内存一定立即归还操作系统
反例:
DEL large:key
可能使 used_memory 下降,但 used_memory_rss 不变。原因可能是分配器保留页、碎片或其他仍驻留的内存。
判断删除是否生效,应同时看:
EXISTS large:key;used_memory;lazyfree_pending_objects;- RSS;
- 一段时间后的趋势;
- 是否有业务立即重建该 Key。
误解四:MEMORY USAGE 就是这个 Key 的全部物理内存
MEMORY USAGE 是 Redis 对 Key 的估算,不等于:
- 操作系统页;
- Fork COW 增量;
- 分配器无法归属的保留页;
- 客户端输出缓冲区;
- 网络发送缓冲区;
- AOF/RDB 重写所需的额外空间。
误解五:把 Hash 拆成更多 Key 就一定解决大 Key
拆分可以降低单次访问和删除粒度,例如:
user:1:profile
user:1:orders:0
user:1:orders:1
user:1:orders:2
但会增加:
- 顶层 Key 数量;
- 数据库 Dict 元数据;
- 过期键管理数量;
- 扫描和管理复杂度;
- Cluster 下的路由与跨槽问题。
拆分是否合理,要根据访问、过期、迁移和一致性边界决定。
误解六:修改配置阈值会把已有 Dict 自动变回 Listpack
通常不应这样假设。编码转换的主要目标是从紧凑结构扩展到通用结构;已转换的对象通常不会因为后来调小或调大配置就自动重编码。
如果必须验证,应在测试环境中:
- 修改配置;
- 创建新对象;
- 观察新对象的
OBJECT ENCODING; - 对已有对象执行实际操作并观察是否转换。
不能用配置文件中的阈值推断所有存量对象的当前编码。
十四、生产取舍:从“字节最少”转向“完整成本”
一个数据结构的成本可以近似拆成:
其中:
- :对象、编码、元数据和碎片;
- :主线程执行时间;
- :请求和响应字节数;
- :复制发送和重同步成本;
- :RDB、AOF 和 Fork COW 成本;
- :故障恢复、迁移和重建时间。
例如,一个紧凑 Listpack Hash 可能内存很省,但如果业务频繁删除中间字段、频繁访问不存在的字段,线性扫描和搬迁成本可能成为问题。转换为 Dict 后内存增加,却可能降低访问延迟。
反过来,把所有内容塞进一个大 String,可能减少对象数量,但会失去字段级修改、字段级过期和增量访问能力;每次更新都可能重写整个值,也会放大 AOF、复制和网络成本。
合理设计通常遵循以下推导顺序:
- 先确定访问粒度:整个对象还是单个 field/member;
- 再确定一致性和过期粒度;
- 再评估对象数量与单对象规模;
- 用
OBJECT ENCODING验证实际编码; - 用
MEMORY USAGE和INFO memory分别观察局部与全局; - 在 RDB、AOF、复制、迁移和删除场景中测试峰值;
- 最后才决定是否调整 listpack 阈值、拆分 Key 或启用主动碎片整理。
Redis 的内存优化不是单纯选择“最紧凑的编码”,而是在紧凑性、查找复杂度、修改成本、延迟、持久化和故障恢复之间选择合适的表示。理解 SDS、Dict、Listpack、Quicklist、编码转换和分配器之间的边界,才能把一次 MEMORY USAGE 结果还原成可解释的内存模型,而不是停留在经验性的“这个类型更省内存”。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:Redis 内核与事件循环:命令执行、IO 线程、阻塞点和延迟
- 下一篇:Redis Pipeline、事务与 Lua:原子性、脚本缓存和集群边界
- 延伸:Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream
- 延伸:Redis 持久化与内存:RDB、AOF、过期淘汰、Fork 和恢复
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论