2187 字
约 7 分钟
12
S12-A27 · fork 与 COW:为什么"开个快照"会让主线程卡一下

Redis 配了 save 900 1,平时好好的。某天数据涨到 12 GB 后,每隔一段时间 P99 就会出现一次 200–400 ms 的尖刺,而 slowlog 里什么都看不到——尖刺发生时没有慢命令,只是"整个实例停了一下"。

答案在 bgsave:它靠 fork() 出一个子进程写快照。fork 本身要复制页表,而页表的大小与实例内存成正比;同时子进程写快照期间,主进程每次修改内存页都会触发写时复制(COW),内存用量翻倍、页分配变慢。这两件事都会让主线程"卡一下",而它们都不出现在命令耗时里。


1. 两次停顿,来源不同

阶段 发生了什么 表现为
fork() 瞬间 复制父进程页表(不是数据) 一次 10–300 ms 的尖刺
子进程写快照期间 主进程写页触发 COW,需要分配新页 周期性抖动 + 内存上涨
快照完成后 子进程退出、释放 COW 页 内存回落,可能伴随回收抖动

第一行的成本与内存大小成正比,这是很多人不知道的:12 GB 实例的页表本身就有几十 MB 到上百 MB,fork 复制它是一次不可中断的内核操作。

第二行更隐蔽:COW 期间每写一次就要申请一个新物理页,写密集时内存峰值可达 2 倍。而它的代价还取决于"改动的页有多大"——改动越分散,要复制的页越多。,写密集时内存峰值可达 2 倍。如果机器内存不够,就会触发 swap——那时就不是"卡一下",而是"卡很久"。

// 一次 12GB 实例的 bgsave 时间线(实测形状)
t=0.00  fork() 开始           → 主线程阻塞 180ms(页表复制)
t=0.18  子进程开始写 RDB      → 主线程恢复
t=0.18  used_memory 12.0G     → 峰值可到 23G(COW 页)
t=3.20  快照写完,子进程退出   → used_memory 回落
// 关键:那 180ms 里,所有客户端都在等

2. 为什么内存越大,尖刺越明显

实例内存 典型 fork 停顿 COW 峰值
2 GB 5–20 ms ~4 GB
6 GB 30–60 ms ~11 GB
12 GB 100–250 ms ~22 GB
24 GB 300–600 ms ~45 GB
// 为什么"16GB 红线"常被引用:fork 成本是线性的
2GB 实例    fork 8ms    用户感知不到
16GB 实例   fork 320ms  一次尖刺就让客户端超时
32GB 实例   fork 700ms  超时 → 重试 → 放大成雪崩
// 尖刺真正的伤害不是"慢一下",而是"引发重试风暴"

这张表解释了两件事:第一,同样的配置在小实例上"没问题",上量后才出问题;第二,Redis 官方反复强调"单实例不要超过 10–16 GB",一半原因在 fork 成本。


3. 四种缓解手段,各自的代价

手段 做法 代价
减少 fork 频率 调大 save 阈值或只用 AOF 快照间隔变长,恢复点变旧
关闭 THP echo never > /sys/kernel/mm/transparent_hugepage/enabled 页表管理变慢一点点,但 COW 粒度从 2 MB 降到 4 KB
主从分离快照 在从库上 bgsave 需要额外节点
分片 把 12 GB 拆成 3 × 4 GB 客户端要支持集群

第二行是被验证过最划算的一条:THP 会把 COW 的单位从 4 KB 放大到 2 MB,意味着"改一个字节要复制 2 MB"。关掉它,COW 的内存放大与延迟都会显著下降。

// 检查与关闭 THP(必须写进镜像初始化,重启会恢复)
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never          # 中括号在 always 上 = 开着,要关
$ echo never > /sys/kernel/mm/transparent_hugepage/enabled
// 容器里改不了宿主内核,要在宿主或 initContainer 里做

4. 从监控上怎么确认是它

slowlog 看不到,因为它不是"某条命令慢",是"所有命令一起被推迟"。要看的是另外几个指标。

指标 正常 异常(fork 导致)
latest_fork_usec 几千 十万以上
rdb_last_bgsave_status ok ok(但耗时长)
used_memory 曲线 平稳 锯齿状冲高后回落
客户端 P99 平稳 与 latest_fork_usec 峰值对齐
instantaneous_ops 平稳 尖刺期间出现零值
// 把尖刺与 fork 时刻对齐,就能定案
$ redis-cli --latency-history -i 1 | sort -t: -k2 -n | tail -5     // 尖刺时刻
$ for i in 1 2 3; do redis-cli info stats | grep latest_fork_usec; sleep 600; done
// 判据:P99 尖刺的秒数,与 latest_fork_usec 数值跳变的那一瞬重合

第一行是直接证据:INFO stats 里的 latest_fork_usec 就是最近一次 fork 的耗时(微秒)。它与 P99 尖刺在同一秒对齐,就能定案。

// 一条命令确认 fork 成本与尖刺是否同源
$ redis-cli info stats | grep latest_fork_usec
latest_fork_usec:184223          # 184ms —— 与业务 P99 尖刺吻合
$ redis-cli info memory | grep -E 'used_memory_human|mem_fragmentation_ratio'
used_memory_human:12.03G
// 判据:fork_usec 与 P99 峰值同秒出现 → 就是它,不是慢命令

5. AOF rewrite 也有同样的问题

不只 bgsave,BGREWRITEAOF 同样要 fork。而它更糟一点:rewrite 期间写入既进 AOF 缓冲又进重写缓冲,内存双份增长。

操作 fork 额外内存 持续时间
BGSAVE 是 COW 页 与数据量成正比
BGREWRITEAOF 是 COW 页 + AOF 重写缓冲 更长
SAVE(前台) 否 无 阻塞整个实例

第三行是唯一绝对不能用的:SAVE 同步阻塞,几 GB 数据能卡几十秒。生产上只允许 BGSAVE。


6. 排错:五种"周期性抖动"

现象 八成原因 处理方向
P99 周期性尖刺、slowlog 干净 fork 页表复制 看 latest_fork_usec
内存锯齿状翻倍 COW 关 THP / 减少写压力
抖动时伴随 swap 内存不够容纳 COW 峰值 扩内存或减小实例
只在主库抖、从库正常 主库承担快照 把快照挪到从库
改小 save 阈值后更频繁 快照次数变多 阈值不是越小越好

第五行是常见误区:为了"数据更安全"把 save 调成 save 60 1000,结果 fork 从每天几次变成每小时几十次,抖动频率直接上升。安全的正确做法是 AOF everysec + 少量 RDB,而不是密集 RDB。


7. 边界:什么情况不该为它优化

场景 判断 做法
纯缓存、可重建 快照价值低 直接关 RDB,抖动消失
实例 < 4 GB fork 成本可忽略 不用管
已有从库承担快照 主库不受影响 保持现状
写压力很低 COW 页少 观察即可
用的是云托管 底层已调优 看厂商的 fork_usec 指标

判断口径一句话:fork 抖动 = 页表大小 × 内存量级,而不是"配置对不对"——所以判据是"实例多大、快照多久一次、内存有没有余量容纳 COW 峰值"。而"纯缓存"场景最省事:不需要快照的实例,关掉 RDB 就没有这个问题。


8. 自测

  1. fork() 复制的是什么?为什么它的耗时与内存大小成正比?
  2. COW 为什么会把内存推到 2 倍?THP 在这里起了什么放大作用?
  3. 为什么 slowlog 里看不到这类抖动?该看哪个指标?
  4. AOF rewrite 与 bgsave 的共同点与差异分别是什么?
  5. "把 save 阈值调小更安全"为什么是错的?正确做法是什么?
  6. 什么场景下根本不需要为 fork 抖动做优化?

9. 一句话总结

bgsave 的抖动来自两次停顿:fork() 复制页表(与内存成正比),以及 COW 期间的内存翻倍与页分配——两者都不进 slowlog,只看得到"所有命令一起被推迟"。确认方法是 latest_fork_usec 与 P99 尖刺同秒;缓解手段里最划算的是关 THP(把 COW 粒度从 2 MB 降到 4 KB),其次是主从分离快照与分片。判据:纯缓存实例直接关 RDB,这比任何调参都彻底。

S12-A27 · fork 与 COW:为什么"开个快照"会让主线程卡一下
https://www.clxhxhhr.top/posts/6750/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。