标签: 内存淘汰

  • 深入 Redis 陷阱排查:RDB bgsave 触发内存淘汰风暴与 Gossip 协议假死引发的集群雪崩实战

    结论先行:Redis 集群在执行 RDB bgsave 时若伴随高并发写入,极易引发严重 Copy-on-Write(CoW)导致内存飙升。一旦触及 maxmemory 阈值并触发同步淘汰(Eviction)风暴,将长时间阻塞主线程。这不仅会导致业务请求响应耗时(99线)飙升至秒级,更会引发 Cluster Gossip 协议的 Ping/Pong 响应超时,最终触发集群误判节点下线与无意义的主从切换(Failover),造成全局雪崩。

    案发现场:诡异的 P99 尖刺与主从频繁切换

    某次排查过程中,监控大盘发出严重告警。核心 Redis 集群(版本 6.2.6,3主3从架构)的 API 99线从平时的 2ms 瞬间飙升至 4500ms。与此同时,DBA 团队收到多个节点的主从切换告警通知。

    登录其中一台发生切换的原主节点,查看 Redis 日志,发现大量如下报错:

    7892:M 15:32:11.102 * Asynchronous AOF fsync is taking too long (disk is busy?). Writing the AOF buffer without waiting for fsync to complete, this may slow down Redis.
    7892:M 15:32:16.455 # Connection with replica 10.x.x.5:6379 lost.
    7892:M 15:32:20.123 # Cluster state changed: fail
    7892:M 15:32:25.881 # Marking node 9b3d... as failing (quorum reached).
    

    直觉判断是磁盘 IO 瓶颈或是网络抖动,但排查底层系统指标发现:

    1. iostat -x 1 显示磁盘 util 虽然达到了 70%,但并未完全打死,await 也在合理范围内。

    2. 节点间的网络 ping 延迟极低,无丢包。

    进一步通过 redis-cli 提取案发时间段的内核和内存指标:

    redis-cli -p 6379 info stats | grep evicted
    # 结果显示 evicted_keys 在几秒钟内增加了近 40万。
    
    redis-cli -p 6379 info persistence | grep -E "rdb_last_bgsave|latest_fork_usec"
    # rdb_last_bgsave_status:ok
    # rdb_last_bgsave_time_sec: 18
    # latest_fork_usec: 24500
    

    至此,线索闭环:这是一起典型的由 RDB 快照引发的内存暴涨,继而触发淘汰机制阻塞主线程,最终击穿 Gossip 协议导致集群脑裂的惨案。

    为什么 RDB Fork 会触发内存淘汰风暴并导致 Gossip 假死?

    很多研发认为 Redis 是单线程的,且 RDB 是通过 bgsave 在后台子进程完成的,不会影响主进程。这是一个极其危险的误区。

    1. Copy-on-Write (CoW) 带来的内存刺客 当 Redis 执行 bgsave 时,主进程会调用 Linux 的 fork() 系统调用创建子进程。利用操作系统的 CoW 机制,父子进程初始共享同一块物理内存。但如果此时业务端有大量的写请求(SET/HSET 等),主进程在修改数据前,必须先将原有内存页(通常是 4KB,如果开启了 THP 则是 2MB)复制一份。 此时,Redis 实例的实际物理内存占用量 = 现有数据大小 + CoW 复制的页大小。如果写入极为频繁,内存占用会在短时间内急速飙升,直接撞上 maxmemory 限制。

    2. 同步淘汰(Eviction)风暴阻塞主线程 当内存触及 maxmemory,Redis 会根据配置的 maxmemory-policy(如 allkeys-lru)开始清理内存。 在 Redis 6.2 默认配置下,如果没有开启懒释放(lazyfree-lazy-eviction yes),内存淘汰操作是在主线程中同步执行的。如果 CoW 导致的内存超发极大,Redis 需要在一个事件循环周期内强制淘汰数十万个 Key。寻找 LRU 目标、解除哈希表映射、释放内存,这一整套动作将主线程完全卡死。

    3. Gossip 协议假死与雪崩 Redis Cluster 维持高可用依赖于 Gossip 协议。每个节点通过主线程的 clusterCron() 函数(默认每 100ms 运行一次)向其他节点发送 Ping,并处理 Pong。 当主线程被淘汰风暴卡死长达数秒时,clusterCron() 根本无法获得执行机会:

    • 节点无法响应其他节点的 Ping 报文。

    • 其他节点在超过 cluster-node-timeout(默认 15000ms,部分激进配置可能设为 5000ms)未收到响应后,会将该节点标记为 PFAIL(疑似下线)。

    • 随后通过 Gossip 传播,集群半数以上主节点确认该节点失联,状态升级为 FAIL,强制触发 Replica 提主流程(Failover)。

    由于主从切换,客户端连接断开重连,引发缓存短暂不可用,流量直接打穿到 DB,最终演变为全局雪崩。

    防御性加固与最佳实践

    不要指望业务侧降低并发来适应底层,运维架构的底线是通过系统性配置兜底。针对此陷阱,需实施以下加固:

    1. 预留足够的内存 Buffer (绝对铁律) 严禁将 maxmemory 设置为机器物理内存的极限。标准做法是:maxmemory 绝不能超过系统可用内存的 70%。如果实例承载重度写入,甚至需要降至 50%-60%,专门为 RDB 的 CoW 留出 Buffer,避免触发淘汰。

    2. 强制开启 Lazyfree 异步淘汰 从 Redis 4.0 开始引入了异步释放,但在 6.x 版本中淘汰策略默认仍是阻塞的。必须在 redis.conf 中明确开启:

    # 开启异步内存淘汰,避免阻塞主线程
    lazyfree-lazy-eviction yes
    # 对于大 Key 的 DEL 操作也建议走异步 (UNLINK 代替 DEL)
    lazyfree-lazy-user-del yes
    

    3. 审视系统内核参数 THP Transparent Huge Pages (THP) 是内存杀手。开启 THP 后,内存页大小从 4KB 变为 2MB。这意味着即使只修改了 10 个字节的数据,CoW 也要复制整个 2MB 的内存页,导致内存碎片和消耗速度剧增 500 倍。 强制关闭:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    

    4. 调整 Cluster 容忍度 不要把 cluster-node-timeout 设置得过小。如果网络环境非极度苛刻,保持默认的 15000ms 即可。过小(如 3000ms)会导致极易因为一次大 Key 的删除或短暂的 IO 抖动引发误切换。

    cluster-node-timeout 15000
    

    常见问题 (FAQ)

    Q1:为什么我们在监控上看到系统的总剩余内存(Free)还有很多,但 Redis 依然触发了 Eviction? 因为 Redis 触发淘汰只看内部配置的 maxmemory 阈值,与宿主机的剩余物理内存无关。即使机器有 128G 内存,如果 maxmemory 设置为 10G,一旦 Redis 自己计算的内存使用量(包含数据、客户端缓冲区等,但不包括 CoW 子进程消耗)超过 10G,就会开始无情淘汰。

    Q2:如何准确监控 RDB 执行期间 Copy-on-Write 消耗的内存大小? 可以通过解析 Redis 日志获取,每次 bgsave 结束后,Redis 会打印一行日志: Background saving terminated with success 同时在 INFO STATS 中的 latest_fork_usec 可以看到 fork 耗时。但最精准的监控方式是在 bgsave 期间查看 /proc//smaps 中的 Private_Dirty 字段增长量,或者在 bgsave 结束后直接查看 Redis 日志中输出的 RDB: XX MB of memory used by copy-on-write 核心提示。

    Q3:为了避免这种问题,是否可以在集群模式下彻底关闭 RDB,只用 AOF? 不建议彻底关闭 RDB。虽然全量同步可以通过无盘复制(diskless replication)缓解,但新节点加入或严重断网后的重同步依然依赖 RDB 快照机制生成。更好的做法是控制快照生成的频率(取消过于频繁的 save m n 自动触发条件),将备份操作通过定时任务强制调度到业务低峰期执行,并确保 lazyfree 和足够的内存水位。

  • Redis 生产环境 P99 飙升:RDB COW 触发内存淘汰与 Cluster Gossip 故障转移雪崩排查实战

    生产环境 Redis Cluster (6.2.7) 突发 P99 延迟飙升至 2000ms,并伴随频繁的主从切换。核心原因是 BGSAVE 触发 Copy-On-Write 导致内存触碰 maxmemory,引发主线程大规模 LRU 淘汰阻塞。主线程卡顿导致 Gossip 协议心跳超时,误判节点下线并触发级联故障转移。解决方式:预留 30% 内存给 COW,开启 lazyfree-lazy-eviction,并调大 cluster-node-timeout。

    故障现场与指标断崖式下跌

    近期某集群告警,监控面板上呈现出典型的“雪崩”特征:

    1. QPS 骤降:单节点 QPS 从 80k 瞬间跌至 5k 以下。

    2. P99 剧烈抖动:平时稳定在 2ms 以内的 P99 突增至 2000ms+。

    3. 连接风暴:客户端因超时大量重连,引发短连接风暴。

    立刻登机拉取 Redis 日志,发现大量内存淘汰警告,紧接着是集群节点的 FAIL 状态广播:

    29302:M 10:23:14.123 * 10000 keys evicted, 153MB freed.
    29302:M 10:23:14.891 * 10000 keys evicted, 142MB freed.
    ...
    29302:M 10:23:19.456 * Marking node 3a8b... as failing (quorum reached).
    29302:M 10:23:19.458 # Cluster state changed: fail
    

    查看当时的内存指标和核心状态:

    $ redis-cli -p 6379 info memory | grep -E "used_memory_human|maxmemory_human|latest_fork_usec"
    used_memory_human:24.1G
    maxmemory_human:24.0G
    latest_fork_usec:89450
    

    现象很明确:由于触发了内存淘汰,主线程被长时间占用,导致集群内的 Gossip 节点心跳无响应,最终引发整个集群拓扑结构的重新计算和主从切换。

    为什么一次 BGSAVE 会引发集群雪崩?

    很多人在配置 Redis 时,习惯把 maxmemory 设为物理内存的 90% 甚至更大,认为这样“不浪费”。这在没有高频写入和持久化的场景下勉强能跑,但一旦触发 BGSAVE (RDB 持久化),就是灾难的开始。

    Redis 执行 BGSAVE 时会 fork() 一个子进程。现代操作系统利用 Copy-On-Write (COW) 机制,父子进程初始共享物理内存页。然而,如果此时集群正处于高频写入状态(特别是大 Key 的更新),父进程在修改数据时,操作系统必须为这些被修改的内存页分配新的物理空间。

    排查过程中的现场数据显示,该节点在 BGSAVE 期间产生了高达 4GB 的 COW 内存:

    # cat /proc/$(pidof redis-server)/smaps | grep -i private_dirty | awk '{sum+=$2} END {print sum/1024 " MB"}'
    3952 MB
    

    雪崩的传导链条如下:

    1. 内存触顶:COW 导致 Redis 实际占用内存 + 自身分配的内存超过了 maxmemory(24GB)。

    2. 同步淘汰阻塞:Redis 触发 maxmemory-policy(当时配的是 allkeys-lru)。在 Redis 6.2 中,如果未开启异步淘汰,主线程必须同步寻找并释放内存。大规模的 Key 淘汰(且包含 Hash/Set 大 Key)死死卡住了主线程。

    3. Gossip 协议“假死”:Redis 集群的节点保活依赖 Gossip 协议,而处理 Gossip 消息的 clusterCron() 是在主线程的事件循环中执行的。主线程被 Eviction 阻塞了 5 秒,导致无法回复其他节点的 PING。

    4. 脑裂与故障转移:其他节点超过 cluster-node-timeout(当时配的是激进的 5000ms)未收到 PONG,将其标记为 PFAIL,进而升级为 FAIL,触发 Replica 强制上位。

    5. 全量同步加剧雪崩:旧 Master 恢复后变为 Slave,向新 Master 发起 SYNC,再次触发新 Master 的 BGSAVE。死循环形成。

    防御性配置与底层调优实战

    为了彻底根除这种由于持久化抖动引发的集群雪崩,必须从内存预留、异步淘汰和集群容忍度三个维度进行改造。

    1. 严格的内存水位控制 (COW 预留)

    永远不要把 maxmemory 贴着物理内存上限配置。标准做法是预留 30% – 40% 的内存给 COW、主从复制的 repl-backlog 以及客户端缓冲区。

    # 假设实例物理内存 32GB
    maxmemory 20gb
    # 淘汰策略根据业务改为 volatile-lru 或 volatile-ttl,避免全盘扫描
    maxmemory-policy volatile-lru
    

    2. 开启 Lazyfree 机制

    Redis 4.0 引入了 lazyfree,6.0+ 版本进一步完善。针对内存淘汰引发的阻塞,必须开启惰性删除,将释放内存的动作交给后台线程 (bio 线程池) 执行,保命主线程。

    # 开启惰性内存淘汰
    lazyfree-lazy-eviction yes
    # 开启惰性键过期
    lazyfree-lazy-expire yes
    # 隐式 DEL 转化为 UNLINK
    lazyfree-lazy-user-del yes
    

    3. 调校 Cluster Gossip 参数

    cluster-node-timeout 决定了集群对网络抖动和主线程阻塞的容忍度。千万别为了追求极端的“故障恢复速度”将其设为 3-5 秒。主线程偶然卡顿是常态,误判导致的 Failover 成本极高。

    # 推荐值为 15000 (15秒),足够覆盖绝大多数 RDB fork 和淘汰耗时
    cluster-node-timeout 15000
    

    配合调大复制积压缓冲区,防止主从切换或短连后触发全量重传:

    repl-backlog-size 512mb
    

    4. 彻底接管内核 THP (Transparent Huge Pages)

    在排查中发现,操作系统的 THP 是开启的。THP 会将默认的 4KB 内存页放大为 2MB。在 COW 发生时,即使 Redis 只修改了 10 字节的数据,内核也必须拷贝完整的 2MB 内存页。这直接导致了 BGSAVE 期间内存飙升速度放大了数百倍。

    必须在所有 Redis 宿主机上硬性关闭 THP:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    # 固化到 rc.local 或 grub 中
    

    常见问题

    Q1:除了 BGSAVE,AOF 重写 (BGREWRITEAOF) 会引发完全一样的问题吗? 会。BGREWRITEAOF 同样依赖 fork() 子进程进行机制。只要有 Fork 操作,在海量写入时就会产生大量 COW 内存。防御策略完全一致。

    Q2:如何快速确认集群是否正因为主线程卡顿而处于 Gossip 瘫痪边缘? 观察 info stats 中的 latest_fork_usec 耗时,以及慢查询日志。如果 latest_fork_usec 超过 100ms,说明 fork 本身就极其耗时(通常由于系统页表太大引起)。同时可以监控 redis_cluster_messages_ping_sent 和 pong_received 的差值斜率。

    Q3:开启了 lazyfree-lazy-eviction,内存就一定不会爆吗? 并非绝对。Lazyfree 只是把内存释放动作交给了后台线程。如果业务的写入速度远大于后台线程释放内存的速度,Redis 的总内存依然会持续上涨,最终触发操作系统的 OOM Killer 直接干掉 Redis 进程。因此,合理的 maxmemory 预留和限流依然是底线。

    Q4:Redis 7.0 的 Multi-part AOF 能解决这个问题吗? Redis 7.0 的 Multi-part AOF 优化了 AOF 重写期间的增量数据追加机制,大幅降低了重写带来的内存开销和 CPU 负担。但对于纯 RDB 的 BGSAVE COW 物理内存翻倍问题,底层机制并没有变,依然受限于内核的页表和 COW 行为。