Copy-on-Write(写时复制)是一种延迟复制的优化策略:多个读者共享同一份数据,只有在需要修改时才创建副本。它是实现无锁读、原子更新、低代价快照的核心机制,广泛用于操作系统、数据库、文件系统和编程语言运行时。
相关文章:LSM Tree · Roaring Bitmap
目录
| 章节 | 说明 |
|---|---|
| 核心思想 | 共享读,修改时复制 |
| 基本机制 | 引用计数 + 复制时机 |
| COW 的三大优势 | 无锁读 / 原子更新 / 低代价快照 |
| 代价与局限 | 写放大、GC 压力、内存峰值 |
| 工程应用 | Redis / OS fork / Btrfs / MVCC / Go |
| 异常场景 | 大对象 COW 内存暴涨、COW 风暴 |
核心思想
不 COW 的做法(原地修改):
写操作 → 直接修改原始数据
并发读写需要加锁,读写互斥
COW 的做法:
写操作 → 创建一个新的数据副本 → 修改副本 → 原子替换指针
读操作 → 始终读原始数据,无需等待写完成
类比:
图书馆借书系统:
不 COW:借出去的书不能修改,其他人只能等
COW:先复印一份,在复印版上修改,还书时替换原版
其他人继续读原版,丝毫不受影响
基本机制
初始状态:
引用计数 ref=2
指针 P1 → 数据 D(读者 A 和 B 共享)
指针 P2 → 数据 D
写操作(修改者 A 要修改 D):
步骤1:if ref > 1,创建数据 D 的副本 D'
步骤2:在 D' 上执行修改(此时 D 不受影响)
步骤3:原子地将 P1 指向 D'
ref(D) -= 1(B 仍指向 D)
步骤4:写操作完成
读操作(读者 B):
全程指向原始 D,读到的是修改前的一致快照 ✅
不需要加任何锁 ✅
当 B 也不需要 D 时:
ref(D) = 0 → 释放 D 的内存
COW 的三大优势
优势一:无锁读(Lock-free Read)
传统读写锁:
写操作持有写锁 → 所有读操作阻塞
高并发写入时,读延迟飙升
COW:
写操作修改的是副本,读操作读的是原版
读写完全不冲突,读延迟恒定 ✅
适合:读多写少(读 99%,写 1%)的场景
优势二:原子更新(Atomic Update)
传统方式:
更新多个字段需要多步操作
中间状态对其他线程可见(需加锁保护)
COW:
在副本上完成所有修改
最后一步:原子替换指针(CAS 操作,单条指令)
其他线程要么看到旧版本,要么看到完整新版本
永远不会看到中间状态 ✅
优势三:低代价快照(Cheap Snapshot)
传统快照(深拷贝):
复制整个数据集 → O(N) 时间和空间
COW 快照:
只需复制根指针(或增加引用计数)→ O(1)
只有被修改的部分才会实际复制
Redis RDB 快照:
fork() 系统调用 → 子进程获得父进程的内存快照
fork 本身 O(1)(只复制页表,不复制内存页)
子进程写磁盘期间,父进程继续服务
父进程修改某页 → 该页 COW 复制一份
→ 子进程始终看到 fork 时刻的一致快照 ✅
代价与局限
写放大(Write Amplification):
每次写操作需要复制整个对象(或路径上的节点)
对象越大,复制代价越高
→ COW 适合小对象或树形结构(只复制路径,而非全树)
GC/内存管理压力:
旧版本数据需等所有读者释放后才能回收
高并发场景下可能积累大量"僵尸版本"
→ 需要引用计数或垃圾收集机制
内存峰值:
同时存在新旧两个版本 → 内存使用量瞬间翻倍
Redis fork() 写 RDB 时:
如果父进程写入密集(大量 COW 复制)
子进程持有快照 → 内存可能接近 2×
工程应用
Redis RDB 快照
bgSave 触发流程:
主进程 fork() 子进程
子进程:遍历内存数据,写 RDB 文件
主进程:继续服务读写请求
COW 的作用:
fork() 时只复制页表(O(1))
主进程修改某个 key → 操作系统触发 COW → 仅复制该内存页(4KB)
子进程始终看到 fork 时刻的完整快照
风险:
主进程写入量大时,大量 COW 导致内存急剧增长
配置建议:为 Redis 预留 2x 内存空间防止 OOM
MVCC(多版本并发控制)
数据库的 COW 变体:
每次写操作 = 创建数据的新版本(而非原地修改)
读操作 = 按事务开始时间读对应版本
MySQL InnoDB MVCC:
UPDATE x=1 → x=2:
不删除原记录,而是创建新版本 x=2,旧版本 x=1 保留
Undo Log 链接旧版本
SELECT 按 ReadView(事务快照)选择可见版本
Purge 线程定期清理无人引用的旧版本
COW 的体现:写操作不修改原数据,而是追加新版本
Btrfs / ZFS 文件系统
COW B-Tree(路径复制):
修改一个叶节点 → 复制该叶节点及其到根的所有父节点
旧根指针 → 旧版本树(给快照使用)
新根指针 → 新版本树(当前版本)
好处:
天然支持快照(只需保存旧根指针,O(1))
崩溃安全:写未完成时旧树不受影响,无需 WAL
Btrfs 快照:
cp -r 方式:O(N)
btrfs subvolume snapshot:O(1),只复制根指针
Go 语言中的 COW(Slice append)
// Go slice 的 COW 行为
a := []int{1, 2, 3} // len=3, cap=3
b := a[:] // b 和 a 共享底层数组
// 对 b 进行 append(超出容量)
b = append(b, 4) // 触发 COW:分配新数组,复制元素
// 此时 a 和 b 指向不同数组,互不影响
异常场景
大对象 COW 导致内存暴涨
场景:Redis 内存 10GB,主进程在 fork 后密集写入大 hash 对象
每次 hash 修改 → 操作系统 COW 复制该内存页
大量写入 → 大量内存页被复制
峰值:10GB × 2 = 20GB → OOM Killer 触发 → Redis 进程被杀
预防:
1. 避免在 bgsave/bgrewriteaof 期间进行大批量写入
2. Redis 配置 maxmemory 时预留 50% 空间给 COW
3. 使用小对象(hash-max-listpack-entries 限制 hash 大小)
长事务导致旧版本堆积(MVCC 场景)
场景:一个事务 T1 运行了 1 小时(未提交)
期间产生了大量 UPDATE → 大量新版本
问题:Purge 线程无法清理 T1 开始前的旧版本
版本链越来越长,查询需要遍历长链 → 性能下降
解决:
MySQL:监控 information_schema.innodb_trx 中的长事务
设置 innodb_lock_wait_timeout 限制事务时长
业务层:避免在一个事务中做大量写入后长时间不提交
参考资料
- 《Designing Data-Intensive Applications》Ch.7 — MVCC
- Redis RDB 与 COW:https://redis.io/docs/management/persistence/
- Btrfs COW 机制:https://btrfs.wiki.kernel.org/index.php/SysadminGuide
评论 (0)
发表评论