Redis 内存碎片整理 深度解析

从 jemalloc 的 slab 分配器到 active-defrag 核心机制,一篇文章带你彻底搞懂内存碎片整理。

Redis 7.x jemalloc active-defrag 内存管理

一、什么是内存碎片?

在 Redis 运行过程中,你可能会观察到一个奇怪的现象:Redis 的 used_memory 显示只用了 2GB, 但操作系统报告的 used_memory_rss 却高达 4GB。多出来的 2GB 哪去了?答案就是——内存碎片

💡 关键指标:碎片率
mem_fragmentation_ratio = used_memory_rss / used_memory
碎片率 > 1.5 时,说明碎片问题需要关注;如果接近 2.0 甚至更高,说明有大量内存被浪费。
整理前 — 碎片化严重 used_memory_rss = 3GB used_memory = 2GB active-defrag 整理后 — 紧凑连续 used_memory_rss ≈ 2.1GB

看上图:左边是整理前的内存——数据之间散布着大量"空洞"(已释放但无法被系统回收的内存页),导致 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。

jemalloc Slab 分配示意 请求大小: 10B 55B 90B 200B 实际分配: 16B 64B 128B 256B 内部浪费: 6B 浪费 9B 浪费 38B 浪费 56B 浪费 内部碎片:分配与请求的差值,在单个 slab 内部浪费的空间

2.2 外部碎片才是大问题

上面说的是内部碎片——单个分配块内部的浪费,这个通常还好。真正让人头疼的是外部碎片

外部碎片形成过程 ① 初始:连续分配 3 个大小不同的 key Key A (2KB) Key B (2KB) Key C (3KB) ② Key B 过期被删除,中间出现 2KB 空洞 Key A (2KB) 空洞 (2KB) Key C (3KB) ③ 新写入 3KB Key D → 空洞太小装不下,只能分配到末尾,浪费更多 Key A (2KB) 装不下 3KB! Key C (3KB) → Key D (3KB) 只能追加到末尾,空洞永远无法复用 → 碎片越来越多!
⚠️ 外部碎片的本质
Redis 中的数据大小千差万别——有的 key 只存 50 字节的字符串,有的存 10MB 的大对象。 当各种大小的 key 不断被创建和删除时,内存中就会散布大量大小不同的"空洞"。 这些空洞单独看不大,但加起来就是巨大的浪费。更糟糕的是,新分配的大块内存找不到合适的空洞放下, 只能追加到末尾,导致整体内存占用越来越大。

三、碎片整理如何实现?(核心机制)

Redis 4.0 引入了 Active Defrag(主动碎片整理) 功能,它是一项精巧的设计: 在 Redis 正常运行的同时,不阻塞主线程地搬运数据、合并空洞、归还内存给操作系统。

3.1 核心思路:给 key "搬家"

碎片整理的本质上就是对内存中的 value 进行 "重新分配 + 拷贝 + 释放旧位置"

  1. 扫描内存:遍历 Redis 的所有 key,找到那些可以搬迁的数据(value 存储在独立内存块中的类型)
  2. 计算碎片收益:判断搬家后能释放多少内存,收益不够就不搬(避免做无用功)
  3. 分配新内存:在新的、更紧凑的位置分配一块大小相同的内存
  4. 拷贝数据:将 value 的内容拷贝到新位置
  5. 更新指针:让 key 对象指向新的 value 地址
  6. 释放旧内存:归还旧的内存块,空洞消失!
Active Defrag — Key 搬迁过程 搬迁前 Key A (已用) Key B (旧位置) key "b" ptr 搬迁后 Key A (已用) Key B (新位置) ← 归还给 OS key "b" 阶段 ① 扫描:defrag 游标扫描键空间,检查每个 key 的 value 是否可以 "搬家" 阶段 ② 评估:计算搬家能释放的碎片大小,低于阈值就跳过(避免无意义搬动) 阶段 ③ 搬迁:jemalloc 分配新内存块 → memcpy 拷贝数据 → 更新指针 → free 旧块 阶段 ④ 节流:每轮搬迁后计算 CPU 耗时,超过限制就 sleep 一下,绝不阻塞主线程 阶段 ⑤ 继续:游标往下走,下一轮继续,形成渐进式清理的循环

3.2 渐进式设计:不阻塞服务

这是整个设计最精妙的地方。如果一口气把所有 key 都搬完,Redis 会长时间阻塞,客户端请求全部超时。 所以 Redis 采用了渐进式 + CPU 时间片的策略:

渐进式整理的时间片模型 整理 ① sleep 整理 ② sleep 整理 ③ sleep 整理 ④ 在此期间,正常客户端请求不受影响,Redis 持续对外提供服务 每轮整理时间不超过 CPU 时间片限制(默认 25%),确保请求延迟不受明显影响
🟢 时间片控制机制
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 之间做线性插值:

CPU 占用自适应调节 0 1% 25% max 110% 155% 200% 碎片率 → 1% 25% 碎片越严重 → 投入更多 CPU
# 推荐配置: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
⚠️ 几个重要注意事项
  1. BGSAVE / BGREWRITEAOF 期间自动暂停:有子进程在 fork 时,defrag 会暂时停止,避免 COW 导致内存翻倍
  2. jemalloc 是前提:active-defrag 深度依赖 jemalloc 的内存分配信息,如果编译时没开启 jemalloc,功能不会生效
  3. 可以通过 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 的数据"搬家",把散落各地的数据搬到一起,把空出来的房间还给房东(操作系统)。