跳到主内容

从 2 秒卡顿到 80ms:10GB Redis bgsave fork 阻塞排查实录 ​

问题背景:bgsave 不是完全无阻塞 ​

很多团队把 Redis 的 bgsave 理解成“后台保存,不影响线上请求”。这个说法只对了一半:真正生成 RDB 文件由子进程完成,但 Redis 主线程必须先调用 fork() 创建子进程。实例内存越大、内存页表越复杂,fork() 期间需要复制的页表项越多,主线程停顿就越明显。

一次典型故障中,业务 Redis 实例 used_memory 约 10GB,监控显示每 15 分钟触发一次 RDB,P99 延迟周期性升高。慢日志没有捕获到大命令,但 latest_fork_usec 长期在 1,800,000 微秒以上,也就是接近 2 秒。业务侧表现为缓存批量超时、连接池等待数升高,数据库在同一时间出现少量突发查询。

这类问题的关键不是磁盘写得慢,而是 fork() 发生时 Redis 无法处理新请求。子进程借助操作系统的写时复制机制共享父进程物理内存,但页表本身仍需要复制;如果开启透明大页 THP,内存页从 4KB 变为 2MB,写时复制的复制粒度变大,还会进一步放大内存占用和卡顿风险。

排查路径:先确认 fork,再看磁盘与页复制 ​

不要一上来就关闭 RDB。建议按“主线程阻塞证据 → fork 耗时 → 内存与碎片 → THP → 磁盘与 COW”顺序排查。

观察 P99 周期抖动对齐 bgsave 时间查看 fork 耗时latest_fork_usec检查内存规模RSS、碎片率、键数量关闭 THP降低 COW 粒度治理碎片与大键减少页表与脏页

方案对比:接受、拆分还是降低 fork 成本 ​

常见方案有三类。第一类是直接降低 RDB 频率,甚至只用 AOF,但 AOF rewrite 同样需要 fork,不能从根本上消除停顿。第二类是按业务拆分实例,把 10GB 实例拆成多个 2GB 到 4GB 的 Redis 进程,使单次 fork 复制的页表变小;这是最稳妥的容量治理手段,但需要客户端改造或数据迁移。第三类是在现有实例上降低 fork 成本,包括关闭 THP、控制内存碎片、清理大键、避开高峰保存、确保机器留有足够内存。

对于已经上线的 10GB 实例,建议先做第三类止血,再推进第二类架构治理。若业务能容忍少量丢失,可降低 RDB 频率;若不能丢数据,应使用 AOF everysec 或结合从节点备份,但仍要关注 rewrite 触发窗口。

实操步骤:用可运行命令定位瓶颈 ​

1. 确认 Redis 持久化状态与 fork 耗时 ​

下面命令可直接在测试环境执行。latest_fork_usec 是最近一次 fork 的微秒数,rdb_last_bgsave_status 用于确认最近 RDB 是否成功。

bash
# 进入 redis-cli,查看持久化、内存、fork 相关指标
redis-cli -h 127.0.0.1 -p 6379 INFO persistence | egrep "rdb_bgsave_in_progress|rdb_last_bgsave_status|rdb_last_save_time|latest_fork_usec"

redis-cli -h 127.0.0.1 -p 6379 INFO memory | egrep "used_memory:|used_memory_rss:|mem_fragmentation_ratio|maxmemory:"

# 持续观察 fork 是否正在执行;执行期间 bgsave_in_progress 会短暂变为 1
watch -n 0.2 'redis-cli INFO persistence | egrep "rdb_bgsave_in_progress|latest_fork_usec"'

判断标准很简单:如果 P99 抖动时间与 latest_fork_usec 基本吻合,优先怀疑 fork;如果 fork 很短但 RDB 总耗时很长,再排查磁盘带宽、文件系统或网络存储。

2. 构造关键数据结构并观察内存差异 ​

大 Hash、大 List、大 Set 都会增加内存管理复杂度。下面示例演示一个大 Hash 与一个分片 Hash 的区别。前者一个键承载大量 field,后续删除、迁移和页变脏都更集中;后者按固定后缀拆成多个键,更适合大对象治理。

bash
# 生成一个包含 10000 个 field 的大 Hash
redis-cli DEL user:big
for i in $(seq 1 10000); do
  redis-cli HSET user:big "field_$i" "value_$i" >/dev/null
done
redis-cli HLEN user:big
redis-cli MEMORY USAGE user:big

# 分片为 10 个 Hash,每个约 1000 个 field
redis-cli DEL user:shard:0 user:shard:1 user:shard:2 user:shard:3 user:shard:4 user:shard:5 user:shard:6 user:shard:7 user:shard:8 user:shard:9
for i in $(seq 1 10000); do
  shard=$((i % 10))
  redis-cli HSET "user:shard:$shard" "field_$i" "value_$i" >/dev/null
done
redis-cli --bigkeys

线上不要直接对超大 Hash 使用 HGETALL,也不要用 DEL 删除巨型键。应使用 HSCAN 分批处理,或使用 UNLINK 异步释放。

3. 检查并关闭透明大页 THP ​

Redis 官方通常建议在部署 Redis 的机器上关闭 THP,因为 THP 可能让写时复制以 2MB 粒度复制内存。

bash
# 查看当前 THP 策略,[never] 表示已关闭
cat /sys/kernel/mm/transparent_hugepage/enabled

# 立即生效;需要 root 权限
echo never > /sys/kernel/mm/transparent_hugepage/enabled

# 再次确认
cat /sys/kernel/mm/transparent_hugepage/enabled

如果机器重启后配置丢失,应通过 systemd-tmpfiles、启动脚本或配置管理工具固化。注意:关闭 THP 后,已经使用大页映射的进程不一定完全切换到理想状态,低峰重启或主从切换后收益更稳定。

4. 调整保存策略并预留内存 ​

下面配置展示更保守的 RDB 策略:减少高峰触发频率,设置更温和的 rewrite 阈值。具体值要按数据安全性要求决定,不能机械照抄。

bash
# 查看当前配置
redis-cli CONFIG GET save
redis-cli CONFIG GET "appendonly"
redis-cli CONFIG GET "auto-aof-rewrite-percentage"
redis-cli CONFIG GET "auto-aof-rewrite-min-size"

# 临时调整:15 分钟内至少 100 次写才保存,1 小时内至少 1000 次写才保存
redis-cli CONFIG SET save "900 100 3600 1000"

# AOF rewrite 阈值从默认较敏感的 100% 调整为 50%,并设置最小 512MB
redis-cli CONFIG SET auto-aof-rewrite-percentage 50
redis-cli CONFIG SET auto-aof-rewrite-min-size 536870912

持久化期间要给操作系统留出足够内存。经验上,Redis 实例所在节点至少要预留实例内存的一部分给 COW、缓冲区和系统页缓存;写入越频繁、大键修改越集中,预留应越多。容器环境尤其要看 memory limit,而不是只看宿主机总内存。

5. 主动触发一次对照测试 ​

在低峰期可以手动执行 BGSAVE,记录执行前后的 fork 耗时、RSS 和系统内存。

bash
# 记录测试前指标
redis-cli INFO persistence | grep latest_fork_usec
redis-cli INFO memory | egrep "used_memory_rss|mem_fragmentation_ratio"
free -h

# 手动触发后台保存
redis-cli BGSAVE

# 等待成功后再读取结果
redis-cli INFO persistence | egrep "rdb_last_bgsave_status|latest_fork_usec"

在上述案例中,关闭 THP、清理两个数百 MB 的大 Hash、把碎片率从 1.7 降到约 1.15,并将实例从 10GB 拆分为两个 5GB 后,单次 fork 从约 2 秒下降到 80ms 到 150ms,周期尖刺基本消失。

避坑总结:这些误判会让治理反复 ​

  1. 只看磁盘 I/O,不看 latest_fork_usec。 常见现象是 RDB 文件写入很快,但客户端仍出现固定秒级超时。修正方法是先对齐卡顿时间和 fork 指标,因为 fork() 阻塞发生在写 RDB 之前,磁盘 util 可能完全不高。

  2. 以为 AOF 可以替代 fork。 很多人关闭 RDB 后发现 AOF rewrite 时仍卡顿。AOF 只是日志写入方式不同,重写时依旧需要子进程。应同时治理 auto-aof-rewrite-percentage、实例大小、THP 和碎片率。

  3. 在容器里只看宿主机内存。 现象是宿主机还有很多空闲内存,Redis 或 sidecar 却被 OOMKilled。原因是 cgroup 限额内还要容纳写时复制、复制缓冲区和系统开销。应按容器限额评估容量,并把大实例拆分或放到独立节点。

  4. 高峰期执行主动碎片整理或大键删除。 activedefrag、大键清理、RDB 都可能带来额外 CPU 与内存压力。建议先用 MEMORY USAGE、--bigkeys 定位,再通过低峰分批迁移、UNLINK、从节点切换等方式处理。

  5. 把 save "" 当成性能优化万能药。 关闭持久化确实能减少 fork,但进程崩溃或机器故障会丢失数据,缓存重建还可能打爆数据库。正确做法是明确恢复目标:能丢数据才降低 RDB 频率;不能丢数据就使用 AOF、从节点备份和多副本高可用。

结语 ​

Redis bgsave 的卡顿排查,本质上是在衡量一次 fork() 要复制多少内存页表,以及持久化期间有多少页发生写时复制。对 10GB 级别实例,关闭 THP、治理碎片和大键、预留内存能快速止血;长期来看,控制单实例内存规模、让备份在从节点执行、把持久化窗口与业务高峰错开,才是稳定规避 2 秒级阻塞的关键。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。