一、什么是内存碎片?
在 Redis 运行过程中,你可能会观察到一个奇怪的现象:Redis 的 used_memory 显示只用了 2GB,
但操作系统报告的 used_memory_rss 却高达 4GB。多出来的 2GB 哪去了?答案就是——内存碎片。
💡 关键指标:碎片率
mem_fragmentation_ratio = used_memory_rss / used_memory
碎片率 > 1.5 时,说明碎片问题需要关注;如果接近 2.0 甚至更高,说明有大量内存被浪费。
看上图:左边是整理前的内存——数据之间散布着大量"空洞"(已释放但无法被系统回收的内存页),导致 RSS 远大于实际使用量。
右边是整理后的效果——数据被紧凑排列,空闲空间被合并成大块还给操作系统。
🔍 怎么判断自己有没有碎片问题?
# 连接 Redis 执行
redis-cli INFO memory
# 关注这几个字段:
# used_memory_human — Redis 实际存储数据使用的内存
# used_memory_rss_human — 操作系统分配给 Redis 进程的物理内存
# mem_fragmentation_ratio — 碎片率 (rss / used)
# allocator_frag_ratio — jemalloc 内部碎片率
# allocator_rss_ratio — jemalloc RSS 与实际分配的比例
# active_defrag_running — 当前是否正在执行碎片整理
判断标准:mem_fragmentation_ratio > 1.5 —— 建议开启碎片整理;
mem_fragmentation_ratio < 1.0 —— 可能开启了 swap,内存不够用了。
二、碎片为什么会发生?
要理解碎片,必须先理解 Redis 使用的内存分配器——jemalloc。
jemalloc 不是简单地 "要多少给多少",而是采用了 slab(大小类)分配机制。
2.1 jemalloc 的 slab 分配器
jemalloc 预定义了若干个 大小类(size class),比如 8B、16B、32B、64B、128B、256B……一直到几 KB。
当你申请一块内存时,jemalloc 会找到 ≥ 请求大小的最小 size class 来分配。
例如:申请 10 字节 → 分配 16 字节的 slab;申请 100 字节 → 分配 128 字节的 slab。
2.2 外部碎片才是大问题
上面说的是内部碎片——单个分配块内部的浪费,这个通常还好。真正让人头疼的是外部碎片:
⚠️ 外部碎片的本质
Redis 中的数据大小千差万别——有的 key 只存 50 字节的字符串,有的存 10MB 的大对象。
当各种大小的 key 不断被创建和删除时,内存中就会散布大量大小不同的"空洞"。
这些空洞单独看不大,但加起来就是巨大的浪费。更糟糕的是,新分配的大块内存找不到合适的空洞放下,
只能追加到末尾,导致整体内存占用越来越大。
三、碎片整理如何实现?(核心机制)
Redis 4.0 引入了 Active Defrag(主动碎片整理) 功能,它是一项精巧的设计:
在 Redis 正常运行的同时,不阻塞主线程地搬运数据、合并空洞、归还内存给操作系统。
3.1 核心思路:给 key "搬家"
碎片整理的本质上就是对内存中的 value 进行 "重新分配 + 拷贝 + 释放旧位置":
-
扫描内存:遍历 Redis 的所有 key,找到那些可以搬迁的数据(value 存储在独立内存块中的类型)
-
计算碎片收益:判断搬家后能释放多少内存,收益不够就不搬(避免做无用功)
-
分配新内存:在新的、更紧凑的位置分配一块大小相同的内存
-
拷贝数据:将 value 的内容拷贝到新位置
-
更新指针:让 key 对象指向新的 value 地址
-
释放旧内存:归还旧的内存块,空洞消失!
3.2 渐进式设计:不阻塞服务
这是整个设计最精妙的地方。如果一口气把所有 key 都搬完,Redis 会长时间阻塞,客户端请求全部超时。
所以 Redis 采用了渐进式 + CPU 时间片的策略:
🟢 时间片控制机制
Redis 在每轮碎片处理中会统计 CPU 耗时。如果本轮耗时超过了 active-defrag-cycle-max 的 25%,就 sleep 等待下一轮。
这样即使碎片严重,也能在几分钟到几十分钟内渐进式完成整理,用户完全无感知。
3.3 哪些数据支持搬?哪些不行?
| 数据类型 | 是否支持搬迁 | 原因 |
| String (SDS) | ✅ 支持 | value 是独立内存块,搬迁完全安全 |
| Hash | ✅ 支持 | ziplist/listpack 可整体搬迁;hashtable 可逐元素搬迁 |
| List | ✅ 支持 | quicklist 节点可逐个搬迁 |
| Set | ✅ 支持 | intset/listpack 可整体搬迁;hashtable 逐个搬迁 |
| Sorted Set | ✅ 支持 | skiplist + dict 结构,可逐个节点搬迁 |
| Stream | ✅ 支持 | rax 树节点可搬迁 |
| Module 类型 | ⚠️ 部分支持 | 需要模块实现 RM_Defrag 回调 |
| 共享对象 (0-9999 整数) | ❌ 不支持 | 被多处引用,搬迁需要改所有引用方,代价太高 |
3.4 defrag 游标机制
碎片整理使用一个内部游标来遍历整个键空间,这个游标类似 SCAN 命令使用的机制。
每次整理从上次停下的位置继续,一轮一轮地推进,最终覆盖所有数据库。
# Redis 源码中 activeDefragCycle() 的核心逻辑 (简化版)
void activeDefragCycle(void) {
// 计算本轮允许的 CPU 时间上限 (微秒)
start = ustime();
timelimit = 1000000 * server.active_defrag_running / 100;
do {
// 1. 扫描键空间,找到可以 defrag 的 key
while (cursor && (ustime() - start) < timelimit) {
scanCallback();
activeDefragCycle(); // 递归搬迁
cursor++;
}
// 2. 搬迁 key 的 value
if (hasActiveChildProcess()) break; // 有 BGSAVE/BGREWRITE 则暂停
// 3. 如果一整个数据库扫完了,切到下一个 db
if (!cursor) dbid = (dbid + 1) % server.dbnum;
// 4. 计算本轮耗时,决定是否继续
} while (elapsed < timelimit);
}
四、配置参数详解
| 参数 | 默认值 | 含义 |
activedefrag yes |
no |
总开关,设为 yes 激活整个碎片整理机制 |
active-defrag-ignore-bytes 100mb |
100mb |
碎片总大小低于该值就不整理,避免小题大做 |
active-defrag-threshold-lower 10 |
10 |
碎片率 > 110% 时开始整理(即碎片占 10% 以上) |
active-defrag-threshold-upper 100 |
100 |
碎片率达到 200% 时,全力整理(最大 CPU 占用) |
active-defrag-cycle-min 1 |
1 |
最少每轮耗费 CPU 1% 的时间来整理 |
active-defrag-cycle-max 25 |
25 |
最多每轮耗费 CPU 25% 的时间来整理(软上限) |
active-defrag-max-scan-fields 1000 |
1000 |
每轮最多扫描的 hash/list/set/zset 字段数 |
4.1 CPU 占用自适应调节
Redis 不会固定使用一个 CPU 占比来整理,而是根据当前碎片率在 min 和 max 之间做线性插值:
# 推荐配置:redis.conf 中这样写
activedefrag yes
# 碎片 > 100MB 且碎片率 > 110% 时才启动整理
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
# 碎片率越高,投入越多 CPU(1% ~ 25%)
active-defrag-cycle-min 1
active-defrag-cycle-max 25
# 温柔一点的配置 (适合延迟敏感场景)
# active-defrag-cycle-max 5
⚠️ 几个重要注意事项
- BGSAVE / BGREWRITEAOF 期间自动暂停:有子进程在 fork 时,defrag 会暂时停止,避免 COW 导致内存翻倍
- jemalloc 是前提:active-defrag 深度依赖 jemalloc 的内存分配信息,如果编译时没开启 jemalloc,功能不会生效
- 可以通过 CONFIG SET 在线调整:不需要重启,适合根据监控动态调整
五、实战建议与最佳实践
5.1 什么时候该开?什么时候不该开?
| 场景 | 建议 | 理由 |
| 碎片率 1.0 ~ 1.2 | ❌ 不开 | 碎片不严重,开启反而浪费 CPU |
| 碎片率 1.2 ~ 1.5 | ⚠️ 可选 | 如果内存充裕,可以先观察;内存紧张就开 |
| 碎片率 > 1.5 | ✅ 建议开 | 有显著内存浪费,整理收益明显 |
| 碎片率 > 2.0 | 🔥 必须开 | 一半内存被浪费,不开就是扔钱 |
| 高写入 / 大 key 频繁创建删除 | ✅ 建议开 | 这种场景最容易产生碎片 |
| 纯缓存 / 全部设置 TTL | ✅ 建议开 | key 过期释放产生大量空洞 |
| 延迟极度敏感 (如交易系统) | ⚠️ 谨慎 | 把 cycle-max 降到 3~5,减少对主线程的影响 |
| 内存本身就充足 | ❌ 不需要 | 碎片不造成实际问题,没必要消耗 CPU |
5.2 监控指标
# 一键查看碎片整理状态
redis-cli INFO memory | grep -E "frag|defrag"
# 输出解读:
# mem_fragmentation_ratio:2.15 ← 碎片率 215%,问题严重!
# mem_fragmentation_bytes:2415919104 ← 碎片浪费了约 2.3GB
# active_defrag_running:1 ← 正在进行碎片整理
# active_defrag_key_hits:1038421 ← 累计搬迁成功的 key 数量
# active_defrag_key_misses:50012 ← 跳过(收益不够)的 key 数量
# active_defrag_misses:182 ← 无法搬迁的操作次数
5.3 一个完整的监控脚本
#!/bin/bash
# redis_defrag_monitor.sh — 持续监控碎片情况
while true; do
redis-cli INFO memory | awk '
/used_memory_human/ { printf "实际使用: %-12s", $NF }
/used_memory_rss_human/ { printf "RSS占用: %-12s", $NF }
/mem_fragmentation_ratio/ { printf "碎片率: %-8s", $NF }
/active_defrag_running/ { printf "整理中: %-8s\n", $NF }
'
sleep 10
done
5.4 如果你不想用自动整理……
🔄 终极方案:重启
如果碎片已经到了无法忍受的地步,而 active-defrag 又因为各种原因不能开(比如 Redis 版本 < 4.0),
那重启 Redis是最简单粗暴的碎片清除方式。重启后内存从零开始分配,完全没有碎片。
但重启意味着数据从 RDB/AOF 加载,有不可用时间。如果用了 Redis Sentinel / Cluster,可以通过主从切换来实现"滚动重启"。
总结
| 🔧 问题 | Redis 运行一段时间后,used_memory_rss 远大于 used_memory,内存被碎片浪费 |
| 🎯 根因 | jemalloc slab 分配 + 大小不一的 key 反复创建删除,形成无法复用的大小各异的"空洞" |
| 🛠️ 方案 | Active Defrag:渐进式地搬迁 value 到紧凑区域,合并空洞归还给 OS |
| ⚡ 特点 | 不阻塞主线程、CPU 占用自适应、支持几乎所有数据类型 |
| 📊 监控 | mem_fragmentation_ratio > 1.5 就需要注意,> 2.0 必须行动 |
一句话记住:Active Defrag 就是给 Redis 的数据"搬家",把散落各地的数据搬到一起,把空出来的房间还给房东(操作系统)。