Redis 内存被打满但数据没多少:碎片率、逐出策略与 fork 开销三层排查实战
这篇文章只解决一个具体问题:服务器物理内存告警,top 里 redis-server 的 RES 高得吓人,但你登进去看 DBSIZE,键的数量并不多,业务上也没觉得存了多大的东西。这时候大部分人的第一反应是「是不是键没设过期时间」,于是开始批量 SCAN 加 TTL,折腾一晚上内存还是下不来。真正的原因通常不在键的数量上,而在三个更容易被忽略的地方:内存碎片、逐出策略选错、以及 RDB 持久化时 fork 出来的写时复制开销。
先说清楚一个前提:Redis 报告的内存和操作系统看到的 RES 不是一回事。INFO memory 里的 used_memory 是 Redis 自己分配器统计的逻辑使用量,而 used_memory_rss 才是操作系统视角的物理内存占用。这两个数字之间的比值就是碎片率,判读方式如下:
redis-cli INFO memory | grep -E "used_memory:|used_memory_rss:|used_memory_peak:|mem_fragmentation_ratio:|maxmemory:|maxmemory_policy:"
mem_fragmentation_ratio = used_memory_rss / used_memory。这个值的健康区间是 1.0 到 1.5。低于 1.0 意味着 Redis 用到了 swap,性能会断崖式下跌;高于 1.5 就意味着有大量物理内存被分配器占着却没用上,属于碎片浪费。我见过最极端的一台机器这个值是 2.7,物理内存 16G,used_memory 才 5.8G,剩下 10G 全是碎片和被 fork 占走的。
第一层:碎片率为什么会涨,怎么处理
碎片的主要来源是「大量短生命周期的小对象反复增删」和「大 key 的删除」。比如你把 Redis 当临时队列用,每天 LPUSH 几十万个元素又 LPOP 掉;或者频繁 SET 不同长度的 value。jemalloc 虽然比 glibc malloc 好很多,但跨 size class 的回收仍然会留下空洞。
处理方法有两条,先看能不能在线整理:
# 4.0+ 支持在线碎片整理,默认关闭,需要先开启
redis-cli CONFIG SET activedefrag yes
redis-cli CONFIG SET active-defrag-ignore-bytes 100mb
redis-cli CONFIG SET active-defrag-threshold-lower 10
redis-cli CONFIG SET active-defrag-threshold-upper 100
redis-cli CONFIG SET active-defrag-cycle-min 5
redis-cli CONFIG SET active-defrag-cycle-max 75
# 观察整理进度
redis-cli INFO memory | grep -E "active_defrag_running|mem_fragmentation_ratio"
这里的参数含义要理解清楚,不然容易把线上 CPU 打满:active-defrag-ignore-bytes 是最小浪费门槛,碎片浪费低于 100MB 就不折腾;threshold-lower/upper 是碎片率百分比区间,碎片率在 10% 到 100% 之间时,整理线程的工作强度会在这个区间内线性变化;cycle-min/max 是每轮 CPU 占用比例,我建议生产环境 cycle-max 不要超过 75,否则在单核机器上会明显拖慢命令延迟。同时务必确认 activedefrag yes 之后 active_defrag_running 变成 1,如果一直是 0,多半是碎片率还没超过 threshold-lower。
如果碎片率已经超过 2.0,在线整理效果有限,最稳的办法还是低峰期重启。重启前必须确认持久化状态,别搞出数据丢失:
redis-cli INFO persistence | grep -E "rdb_last_bgsave_status|aof_last_bgrewrite_status|rdb_changes_since_last_save"
# 输出必须是 ok / ok / 0 才能重启
redis-cli BGSAVE
redis-cli INFO persistence | grep rdb_bgsave_in_progress # 等待变 0
# 确认落盘后重启
systemctl restart redis
第二层:逐出策略选错,热键被干掉又反复回源
这一层比碎片更常见,也更隐蔽。很多人配 Redis 时只写了 maxmemory 4gb,忘了写 maxmemory-policy,于是用了默认值 noeviction。后果是:内存一满,所有写命令直接返回 OOM command not allowed when used memory > 'maxmemory',业务侧表现为「缓存突然全写不进去了」。而如果为了让写能继续,随手改成 allkeys-lru,又会出现另一种病症:所有键一视同仁地参与淘汰,你精心设置的长期热点数据会被那些只访问一次的临时键挤掉,于是缓存命中率跌到很惨,数据库压力反而更大。
正确的配置思路是按数据角色分开,而不是所有键混在一个实例里。下面是我在实际站点上用的配置模板:
# /etc/redis/redis.conf 关键片段
maxmemory 4gb
# 缓存场景(允许丢数据):只淘汰设置了 TTL 的键,没设过期时间的当永久数据保护
maxmemory-policy volatile-lru
# 采样精度,默认 5 偏小会导致 LRU 近似不准,10 更接近真实 LRU
maxmemory-samples 10
# 淘汰时同步删除,避免 del 大 key 阻塞主线程
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
关键在于 volatile-lru 与 allkeys-lru 的取舍。volatile-* 系列只淘汰「设置了过期时间」的键,好处是你能用「不设 TTL」来标记必须保留的数据,坏处是如果所有键都没设 TTL,内存满了照样报 OOM。我的做法是:业务缓存一律通过代码强制设 TTL,配置里用 volatile-lru,同时留一小部分不设 TTL 的键(如排行榜、配置快照)作为被保护的常驻数据。反过来,如果整个实例纯做缓存、不留任何永久数据,那用 allkeys-lru 更简单,但要把缓存键的 TTL 设得更长一些,给 LRU 足够的时间窗去形成访问频率差异。
怎么验证淘汰是否在正常工作、淘汰的是不是你想淘汰的键?看这两个指标:
redis-cli INFO stats | grep -E "evicted_keys|expired_keys|keyspace_hits|keyspace_misses"
# 命中率计算:hits / (hits + misses),生产缓存建议 > 0.9
如果 evicted_keys 在持续增长,说明内存确实长期不够,此时要么扩容,要么缩短 TTL 减小驻留量,光调策略治不了本。另外注意用 lazyfree-lazy-eviction yes,否则淘汰一个大 key(比如几十万元素的 Hash)会阻塞主线程,表现为周期性的延迟毛刺。
第三层:RDB fork 的写时复制吃掉一半内存
这一层是「内存突然翻倍」的经典元凶。Redis 做 BGSAVE 时会 fork() 一个子进程,Linux 的写时复制(Copy-On-Write)意味着父子进程共享同一份内存页,只有当某一方写入某个页时,那个页才会被复制一份。子进程负责把内存里的数据写进 RDB 文件,它基本只读,所以理论上不该复制太多页;但主进程在 fork 期间会继续处理写请求,每改一个页就复制一个页。极端情况下,如果 fork 期间写入非常频繁,最坏可能额外吃掉接近一倍的内存。
所以如果你的机器内存规划是「Redis 用 8G,那就给 8G 物理内存」,那么 RDB 一触发就可能 OOM。正确做法是给 maxmemory 留出余量,经验值是物理内存的 60% 到 70%:
# 物理 16G 的机器
maxmemory 10gb
# 检查 fork 期间实际用了多少 COW 内存
redis-cli INFO memory | grep -E "rdb_bgsave_in_progress|mem_fragmentation_ratio"
cat /proc/$(pgrep -o redis-server)/status | grep -E "VmRSS|VmHWM"
# 内核层面允许内存超配,但别让 Redis 被 OOM Killer 干掉
cat /proc/sys/vm/overcommit_memory # 建议 1
vm.overcommit_memory=1 是 Redis 官方文档明确建议的,因为 fork() 需要内核承诺足够的虚拟内存,在 overcommit_memory=0(启发式)下,当 COW 预估超出时 fork 会失败,日志里会出现 Can't save in background: fork: Cannot allocate memory,导致 RDB 默默不落盘,直到某天重启才发现数据丢了一大截。这一点务必写进 /etc/sysctl.conf 并 sysctl -p 生效。
还有一个和 fork 相关的连带问题:如果 Redis 在容器里跑,maxmemory 应该设成容器 limit 的 60% 到 70%,而不是宿主机的。容器里 free 看到的是宿主机内存,很容易误判。同时给容器设置 --oom-score-adj 或者把它排除在 OOM Killer 优先目标之外,避免 Redis 成为第一个被杀的大内存进程。
排障顺序总结
遇到「Redis 内存高但数据少」,按这个顺序走,基本三十分钟内能定位:
1. INFO memory 看 mem_fragmentation_ratio。大于 1.5 先怀疑碎片,小于 1.0 先怀疑 swap。2. 看 maxmemory_policy 是不是 noeviction,以及 evicted_keys 是否在增长,确认策略符合数据角色。3. 看 rdb_bgsave_in_progress 和持久化频率,确认 fork COW 有没有在吃内存,必要时调大 save 间隔或用 AOF everysec 替代频繁 RDB。4. 最后才去查大 key,用 redis-cli --bigkeys 或 --memkeys,注意这两个命令都会 SCAN 全库,低峰期再跑。
# 大 key 排查(低峰期执行,会遍历全库)
redis-cli --bigkeys
# 更精确的内存分布(Redis 4.0+,需要采样)
redis-cli --memkeys --memkeys-samples 0
附带排查:expires 字典与客户端连接缓冲的额外占用
还有两处占用经常被算漏,导致「明明 maxmemory 设了 4G,容量规划却总是不对」。第一处是过期键的字典本身:Redis 为每个设置了 TTL 的键在 expires 字典里维护一条记录,这是一份额外的指针开销,大约每个键几十字节。当你存了几百万个「小 value + 短 TTL」的键时,光这部分元数据就可能吃掉几百兆。这解释了为什么同样 100 万键,全设 TTL 的实例比全不设 TTL 的实例内存高出一截。判断方式是看 INFO keyspace 里 expires 计数与 keys 计数的比值:
redis-cli INFO keyspace
# db0:keys=1200000,expires=1180000,avg_ttl=86400000
# expires 接近 keys,说明绝大多数键都带 TTL,元数据开销要计入容量预算
第二处是客户端输出缓冲区。如果某个客户端订阅了频道却不消费,或者用了阻塞命令长时间挂着,它的输出缓冲区会无限增长,而这块内存是算在 used_memory 里的,且不受 maxmemory-policy 淘汰影响——淘汰只能淘汰键,淘汰不了缓冲区。这类问题的症状是「DBSIZE 没变但内存缓慢上涨」,排查命令是:
# 看所有客户端的缓冲区占用
redis-cli CLIENT LIST | awk '{for(i=1;i<=NF;i++){if($i ~ /^omem=/){split($i,a,"=");if(a[2]+0>0) print}}}'
# 或在客户端里直接看汇总
redis-cli CLIENT LIST | sed 's/ /\n/g' | grep '^omem=' | sort -t= -k2 -n | tail -10
防治手段是在配置里给缓冲区设上限,让超限的客户端被强制断开,而不是把服务器拖死:
# 普通客户端输出缓冲区上限 0 表示不限制,pubsub 单独设
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit pubsub 32mb 8mb 60
# 含义:硬上限 32mb,超过 8mb 持续 60 秒也断开
顺带说一下 CLIENT LIST 里几个有用的字段:age 是连接存活秒数,长期不关的连接要警惕连接泄漏;idle 是空闲秒数,配合 timeout 配置判断是否该回收;qbuf 是输入缓冲区待处理字节,qbuf 持续很高说明有客户端在灌大数据。
容量规划的落地算法
把上面几层合起来,给一个可以直接照着算的容量规划步骤。假设物理内存 M,要在这台机器上跑 Redis:
第一步,确定 Redis 可用的 maxmemory。如果启用了 RDB,取值不超过 0.6 * M;如果只开 AOF everysec 且不做 rewrite 时的大 fork,可以放宽到 0.7 * M;如果是主从架构、从库还要接全量同步,主库还要再降一些,因为全量同步时会 fork 并生成 RDB 通过网络推给从库,那一刻 COW 压力最大。第二步,用真实数据样本估算单键平均内存:取一万个代表性键,用 MEMORY USAGE 求和再平均,注意这个命令返回的是含元数据的实际占用,比你自己按 value 长度估要准。
# 抽样估算平均键内存(不要全库跑,按样本估)
redis-cli --scan --pattern 'cache:*' | head -10000 | while read k; do
redis-cli MEMORY USAGE "$k"
done | awk '{s+=$1;n++} END{printf "avg=%.1f bytes over %d keys\n", s/n, n}'第三步,用 maxmemory / avg_key_size 得到大致可容纳的键数,再对比业务预估的峰值键数,差多少补多少。第四步,把 maxmemory、策略、vm.overcommit_memory、以及内存告警阈值(建议在 maxmemory 的 80% 和 90% 各设一条)写进配置和监控。这样一套下来,就不会再出现「上线前算着够用,跑两周就 OOM」的情况了。