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. 自测
fork()复制的是什么?为什么它的耗时与内存大小成正比?- COW 为什么会把内存推到 2 倍?THP 在这里起了什么放大作用?
- 为什么
slowlog里看不到这类抖动?该看哪个指标? - AOF rewrite 与 bgsave 的共同点与差异分别是什么?
- "把
save阈值调小更安全"为什么是错的?正确做法是什么? - 什么场景下根本不需要为 fork 抖动做优化?
9. 一句话总结
bgsave 的抖动来自两次停顿:fork() 复制页表(与内存成正比),以及 COW 期间的内存翻倍与页分配——两者都不进 slowlog,只看得到"所有命令一起被推迟"。确认方法是 latest_fork_usec 与 P99 尖刺同秒;缓解手段里最划算的是关 THP(把 COW 粒度从 2 MB 降到 4 KB),其次是主从分离快照与分片。判据:纯缓存实例直接关 RDB,这比任何调参都彻底。