Redis RDB 的写时复制(Copy-On-Write)

写时复制是 Redis 生成 RDB 快照时的核心机制,理解它需要先了解 fork 系统调用

核心概念

1. Fork 系统调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
主进程(处理客户端请求):
├─ 内存中有 10GB 数据
├─ 需要生成 RDB 快照
└─ 调用 fork() 创建子进程

子进程(生成 RDB):
├─ 获得父进程内存的"副本"
├─ 将内存数据写入 RDB 文件
└─ 完成后退出

关键问题:
如果 fork 真的拷贝 10GB 数据,需要:
- 耗时很长
- 需要额外 10GB 内存
- 阻塞主进程

解决方案:写时复制(COW)

2. 写时复制的工作原理

1
2
3
4
5
6
7
fork() 调用后:

主进程内存页: 子进程"看到"的内存页:
[页1] [页2] [页3] → [页1] [页2] [页3]
↑ ↑
实际只有一份物理内存!
通过虚拟内存映射共享

关键机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
初始状态(fork 后):
物理内存:
[页1] [页2] [页3]

主进程和子进程都指向同一份内存

当主进程要修改页2时:
1. 操作系统拦截写操作
2. 复制页2到新位置:[页2']
3. 主进程指向页2',子进程仍指向页2
4. 主进程修改页2'

修改后:
物理内存:
[页1] [页2] [页3] [页2']
↑ ↑
子进程用 主进程用(已修改)

在 Redis 中的实际流程

完整过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1. 客户端发送 BGSAVE 命令

2. 主进程调用 fork()
- 耗时很短(只是创建页表,不复制数据)
- 子进程"共享"父进程的所有内存

3. 主进程继续工作:
- 接收客户端请求
- 执行命令
- 修改数据时触发 COW(复制被修改的页)

4. 子进程开始生成 RDB:
- 遍历共享的内存页
- 将数据写入 RDB 文件
- 看到的是 fork 时刻的数据快照

5. 子进程完成,通知主进程
- 子进程退出
- 释放复制的内存页

实际例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
场景:Redis 有 3 个 Key

初始状态:
Key1 = "A" → 内存页 1
Key2 = "B" → 内存页 2
Key3 = "C" → 内存页 3

执行 BGSAVE:
主进程 fork() → 子进程

时间线:
T1: 子进程开始读取内存页 1,2,3 写 RDB
T2: 客户端执行 SET Key2 "NEW_B"
→ 主进程要修改页 2
→ COW 触发:复制页 2 → 页 2'
→ 主进程修改页 2'(Key2 = "NEW_B")
→ 子进程仍读页 2(Key2 = "B")
T3: 子进程继续读页 2,3 写 RDB
T4: RDB 生成完成

结果:
RDB 文件中:Key1="A", Key2="B", Key3="C"(fork 时刻的快照)
主进程内存:Key1="A", Key2="NEW_B", Key3="C"(最新数据)

内存开销分析

COW 的内存消耗

1
2
3
4
5
6
7
8
9
10
11
总内存消耗 = 原始数据 + 被修改的数据

示例:
- Redis 占用 10GB 内存
- fork 期间有 2GB 数据被修改
- 实际额外内存:2GB(不是 10GB!)

极端情况:
- 如果 10GB 数据全部被修改
- 额外内存:10GB
- 总共需要 20GB

影响因素

1
2
3
4
5
6
7
8
9
10
1. 写操作的频率
- 写越多 → COW 复制越多 → 内存开销越大

2. 数据分布
- 修改集中在少数页 → 开销小
- 修改分散在所有页 → 开销大

3. RDB 生成速度
- 生成越快 → COW 时间越短 → 开销越小
- 生成越慢 → COW 累积越多 → 开销越大

性能影响

正面影响

1
2
3
✓ 主进程不阻塞(除了 fork 瞬间)
✓ 客户端请求正常处理
✓ 可以并行生成 RDB

负面影响

1
2
3
✗ 内存开销:需要额外空间存储被修改的页
✗ CPU 开销:复制页需要 CPU 时间
✗ 写放大:原本一次写操作,变成"复制+修改"两次操作

实际监控

查看 COW 信息

1
2
3
4
5
6
# Redis 6.0+ 可以查看 COW 信息
127.0.0.1:6379> INFO stats

# 相关指标:
cow_memory: 4523456 # COW 消耗的内存(字节)
rdb_last_cow_size: 4523456 # 上次 RDB 的 COW 大小

优化建议

1
2
3
4
5
6
7
8
9
10
11
12
# 1. 控制 RDB 频率
save 900 1 # 900秒内至少1个key变化
save 300 10 # 300秒内至少10个key变化
save 60 10000 # 60秒内至少10000个key变化

# 2. 使用大页内存(减少页数量)
echo always > /sys/kernel/mm/transparent_hugepage/enabled

# 3. 监控 COW 内存
INFO stats 中的 cow_memory

# 4. 避免在写入高峰期 BGSAVE

总结对比

维度 没有 COW 有 COW
Fork 耗时 长(拷贝所有数据) 短(只创建页表)
内存开销 2倍(完整副本) 取决于写入量
阻塞时间 极短(毫秒级)
实现复杂度 简单 依赖操作系统

核心理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
写时复制 = 延迟复制 + 按需复制

不用时:
- 父子进程共享同一份内存
- 零拷贝,零开销

要用时:
- 写操作触发复制
- 只复制被修改的部分
- 保证子进程看到的是 fork 时刻的快照

类比:
- 共享文档的"快照"功能
- 初始不复制内容,只记录差异
- 修改时才真正复制被改的部分

简单记忆:COW 让 fork 变得轻量,只在真正需要修改时才复制,是 Redis 能高效生成 RDB 的关键技术。