专栏文章
专栏文章
存储算法系列
1. 存储算法 #01:LSM Tree 2. 存储算法 #02:Bloom Filter 3. 存储算法 #03:B+ Tree 4. 存储算法 #04:SSTable 5. 存储算法 #05:Copy-on-Write(COW) 6. 存储算法 #06:Roaring Bitmap 7. 存储算法 #07:WAL(Write-Ahead Log) 8. 存储算法 #08:Bitcask 9. 存储算法 #09:Redo 与 Undo Log 10. 存储算法 #10:Varint 与 ZigZag 编码 11. 存储算法 #11:Delta 编码 12. 存储算法 #12:LZ4 与 Snappy 与 Zstd

存储算法 #05:Copy-on-Write(COW)

发布于 2026-06-02 02:06 👁 6 次阅读
#算法#并发#mvcc#storage#cow#copy-on-write

Copy-on-Write(写时复制)是一种延迟复制的优化策略:多个读者共享同一份数据,只有在需要修改时才创建副本。它是实现无锁读、原子更新、低代价快照的核心机制,广泛用于操作系统、数据库、文件系统和编程语言运行时。

相关文章LSM Tree · Roaring Bitmap


目录

章节 说明
核心思想 共享读,修改时复制
基本机制 引用计数 + 复制时机
COW 的三大优势 无锁读 / 原子更新 / 低代价快照
代价与局限 写放大、GC 压力、内存峰值
工程应用 Redis / OS fork / Btrfs / MVCC / Go
异常场景 大对象 COW 内存暴涨、COW 风暴

核心思想

不 COW 的做法(原地修改):
  写操作 → 直接修改原始数据
  并发读写需要加锁,读写互斥

COW 的做法:
  写操作 → 创建一个新的数据副本 → 修改副本 → 原子替换指针
  读操作 → 始终读原始数据,无需等待写完成

类比:
  图书馆借书系统:
    不 COW:借出去的书不能修改,其他人只能等
    COW:先复印一份,在复印版上修改,还书时替换原版
         其他人继续读原版,丝毫不受影响

基本机制

cow process

初始状态:
  引用计数 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)

暂无评论,来留下第一条吧。

发表评论