RRedis 工程现场
持久化不是“后台无成本”
BGSAVE · BGREWRITEAOF · COW

Memory pressure lab / 14

fork 没有复制 16 GB,内存为什么还是爆了?

Redis 创建持久化子进程时,父子进程先共享同一批物理页。真正把内存顶上去的,是后台任务尚未结束时,父进程持续改写数据,内核不得不为这些页保存两份版本。

先记住结论:fork 瞬间不会完整复制数据集;只有 fork 之后被父进程修改的页,才会触发写时复制(COW)。

写时复制压力模拟器

模型用于容量预判,不替代真实机器上的 INFO、LATENCY 与压测数据。

阶段 0 · fork 前
redis-server父进程 · 继续接收写请求
BGSAVE子进程 · 读取 fork 时刻视图
物理内存页 96 / 96 页由父子进程共享
父子共享 已触发 COW,出现双份 尚未进入快照窗口
0%
fork持续写入子进程完成

fork 前只有父进程持有数据页。

01 / 误解发生在哪

“父子各有一份数据”不等于“fork 当场复制一份数据”

逻辑地址空间看起来各自独立,物理页却可以暂时共享。COW 正是用“先共享、写时再分开”换取快速创建子进程。

执行 BGSAVE 或 BGREWRITEAOF 时,Redis 主进程调用 fork()。内核会为子进程建立地址空间和页表,让父子进程的虚拟地址都指向原来的物理页,并把相关页标记为写时复制。子进程看到的是 fork 那一刻的数据视图,可以一边读取并生成 RDB 或新 AOF,父进程则继续响应客户端。

当父进程随后修改某个共享页,内核不能直接覆盖它,因为子进程还要读取旧版本。于是内核分配新物理页、复制原页内容,再让父进程写入新页。这个动作才是 COW 的真正成本。修改越分散,触发复制的页越多;哪怕只改一个很小的对象,也可能复制它所在的整个 4 KiB 页面。

后台任务时长 ≈ 数据集 ÷ 0.55 GB/s
写入可触达数据量 ≈ 写入强度 × 后台任务时长
COW 额外内存 ≈ min(数据集 × 脏页比例,写入可触达数据量)
峰值 RSS ≈ 数据集 × 1.10 + COW 额外内存

上面的模型故意保持保守和透明。真实 COW 还会受对象分布、jemalloc 内存布局、Transparent Huge Pages、子进程完成速度、内核回收以及写放大影响。它更适合回答“这个量级是否危险”,不适合假装给出精确到 MB 的生产结论。

02 / 事故是怎样形成的

真正危险的是:慢子进程撞上高写入

持久化时间越长,父进程制造脏页的窗口越长。内存余量原本够用,也可能在几分钟的重写过程中被一点点吃完。

  1. fork 发生,主线程短暂停顿

    不会复制整个数据集,但页表建立成本会随数据集和页表规模增长。机器繁忙或数据集很大时,客户端会感到尾延迟抖动。

  2. 子进程开始顺序读取旧视图

    它把 RDB 写到临时文件,或生成新的 AOF。磁盘吞吐不足会延长整个后台窗口。

  3. 父进程继续处理写请求

    每次首次改写共享页,都可能让该页出现父、子两个物理版本。写请求越密集、修改越分散,COW 增长越快。

  4. 可用内存被 COW 吃尽

    系统开始回收、交换甚至触发 OOM Killer。此时问题看起来像“持久化导致 Redis 被杀”,根因却是没有为写时复制留容量。

03 / 上线前的边界

把“能不能 fork”变成可观测、可预算的问题

不要只看日常 used_memory。持久化发生时的写入速率、子进程耗时与 COW 峰值才决定是否安全。

容量规划时,至少把峰值写入窗口代入一次。生产环境还要观察 latest_fork_usec、rdb_last_cow_size、aof_last_cow_size、current_cow_size,并把它们与写入 QPS、磁盘时延和可用内存放到同一条时间线上。Linux 的 vm.overcommit_memory 会影响 fork 能否被允许,但把它改成 1 并不会创造真实物理内存,更不会消除 COW。

内存余量按高峰写入下的历史 COW 上界预留,而不是按平时均值。
任务窗口改善磁盘吞吐、避免多个重任务重叠,缩短子进程存活时间。
延迟保护大实例关注 fork 尾延迟;必要时拆分实例,降低单进程数据集规模。