2、快照实现原理:写时复制与重定向写

快照这东西,说白了就是给文件系统拍一张「瞬间照片」。但问题来了——存储系统里的数据动辄几百TB,真要全量复制一份,时间和空间都扛不住。所以业界搞出了两种主流方案:写时复制(CoW)和重定向写(RoW)。

我最早接触快照是在一个金融项目里,客户要求每15分钟拍一次快照,数据量还特别大。当时选型时就在CoW和RoW之间纠结了很久。今天我把这两种机制掰开揉碎了讲清楚。

2.1 写时复制(Copy-on-Write, CoW)

CoW的核心思想很简单:快照创建时不复制任何数据,只有当原始数据块被修改时,才把旧数据复制到预留空间。

关键理解:CoW不是「写的时候复制一份新的」,而是「写的时候先把旧的保存下来」。这个顺序搞反了,整个逻辑就全错了。

2.1.1 工作流程

假设我们有一个数据块A,内容为"Hello"。快照创建后,流程是这样的:

  1. 快照创建时刻:元数据记录当前时间点,数据块A的引用计数+1。不复制数据。
  2. 第一次写入:应用要修改A为"World"。系统发现A被快照引用,触发CoW。
  3. 复制旧数据:将A的原始内容"Hello"复制到快照预留空间(快照存储池)。
  4. 更新指针:快照的元数据指向复制后的旧数据块;活跃卷的元数据指向新写入的"World"。
  5. 释放原块:原数据块A的引用计数减1,如果归零则回收。

我个人的习惯:在生产环境部署CoW快照时,一定要预留足够的快照存储空间。我曾经见过一个团队只给了10%的预留,结果业务高峰期快照存储池写满,整个卷被迫只读——那场面,相当酸爽。

2.1.2 元数据设计

CoW的元数据通常采用B+树或哈希表来管理指针映射。每个数据块都有一个引用计数:

struct cow_block {
    uint64_t block_id;       // 数据块ID
    uint64_t ref_count;      // 引用计数(活跃卷+所有快照)
    uint64_t data_offset;    // 物理存储偏移
    uint64_t snapshot_id;    // 所属快照ID(0表示活跃卷)
    struct cow_block *next;  // 哈希链
};

引用计数是CoW的灵魂。每次创建快照,所有数据块的ref_count加1。每次写时复制,旧块ref_count减1,新块ref_count初始为1。

注意:引用计数溢出是个真实存在的坑。32位无符号整数最大42亿,看起来够用?但如果你每秒拍一个快照,连续跑几年,某些热数据块的ref_count真可能溢出。我建议用64位计数器,或者定期合并快照来重置计数。

2.1.3 CoW的优缺点

维度 评价 说明
空间效率 高 只复制被修改的块,快照创建几乎零空间开销
写性能 有损耗 第一次写入需要复制旧数据,延迟增加
读性能 好 活跃卷直接读取,快照读需要一次指针跳转
实现复杂度 中等 引用计数管理、空间回收逻辑较复杂
空间回收 麻烦 删除快照后,需要扫描引用计数来回收空间

2.2 重定向写(Redirect-on-Write, RoW)

RoW的思路和CoW正好相反:写入新数据时,不修改原位置,而是写到新的空闲块,然后更新元数据指针。

说白了,CoW是「写之前先保存旧的」,RoW是「写的时候直接写新的,旧的原地不动」。你想想看,RoW这种方式天然就更适合SSD——避免了覆盖写导致的写放大问题。

2.2.1 工作流程

还是那个数据块A,内容"Hello"。RoW的流程是:

  1. 快照创建时刻:元数据记录当前指针映射。数据块A的物理位置不变。
  2. 第一次写入:应用要修改A为"World"。系统分配一个新的空闲块B。
  3. 写入新数据:将"World"写入块B。
  4. 更新指针:活跃卷的元数据指向块B;快照的元数据仍然指向块A。
  5. 旧块保留:块A被快照引用,不能回收。

核心差异:CoW复制的是旧数据,RoW复制的是新数据。CoW的旧数据放在预留池,RoW的旧数据留在原地。这个区别决定了两种方案在空间分配和回收策略上的巨大不同。

2.2.2 元数据设计

RoW的元数据需要维护版本链或时间戳映射表:

struct row_mapping {
    uint64_t logical_block;   // 逻辑块号
    uint64_t physical_block;  // 当前物理块号
    uint64_t version;         // 版本号/时间戳
    uint64_t snapshot_id;     // 该映射所属的快照ID
    struct row_mapping *prev; // 前一个版本(用于回滚)
};

RoW的元数据比CoW更重。每个逻辑块可能有多个版本,删除快照时需要清理对应的版本链。我记得在ZFS的早期实现中,RoW的元数据管理就踩过不少坑——版本链太长时,查找性能会退化。

2.2.3 RoW的优缺点

维度 评价 说明
写性能 好 直接写入新块,没有复制开销
空间效率 中等 旧块不能回收,需要垃圾回收机制
读性能 略差 需要查版本链找到正确的物理块
SSD友好 高 避免覆盖写,减少写放大
空间回收 复杂 需要GC(垃圾回收)或后台合并

2.3 元数据与数据分离

不管是CoW还是RoW,都遵循一个核心设计原则:元数据和数据分开存储。为什么?

  • 性能隔离:元数据访问频繁但数据量小,可以放在高速存储(如NVMe或内存);数据量大但访问模式不同,放在普通存储。
  • 恢复速度:崩溃恢复时只需要扫描元数据区域,不用遍历整个数据卷。我见过一个案例,元数据没分离的系统,恢复8TB数据花了6个小时——客户差点没把电话打爆。
  • 快照管理:快照本质上是元数据的快照。数据分离后,创建快照只需要复制元数据指针,毫秒级完成。

避坑指南:我曾经在一个项目中,元数据和数据放在同一个SSD上。结果元数据写入频繁导致SSD寿命快速消耗,数据还没写坏,元数据先挂了。后来强制分离,元数据用带电容保护的NVMe,数据用普通SSD,问题解决。

2.4 CoW vs RoW 对比总结

对比维度 CoW RoW
写策略 写前复制旧数据 写新块,旧块保留
空间分配 预留快照池 动态分配新块
写放大 有(复制+写入) 无(仅写入)
快照删除 释放预留池空间 需要GC回收旧版本
典型代表 WAFL(NetApp)、LVM快照 ZFS、Btrfs
适用场景 HDD为主、写少读多 SSD为主、写频繁

嗯,这里要注意:没有银弹。选CoW还是RoW,取决于你的硬件和业务场景。我个人更倾向在混合存储场景用CoW,全闪存场景用RoW——但这只是经验之谈,具体还得看你的IO模型。

快照实现原理:CoW vs RoW 核心流程对比 写时复制(CoW) 数据块A: "Hello" 快照指针 → A 写入请求:修改A为"World" 复制旧数据 "Hello" 到快照池 活跃卷 → 新块"World" 快照 → 旧块"Hello" 结果:旧数据被复制保存,新数据写入原位置 重定向写(RoW) 数据块A: "Hello" 快照指针 → A 写入请求:修改A为"World" 分配新块B,写入"World" 活跃卷 → 新块B 快照 → 旧块A 结果:旧数据保留原地,新数据写入新位置 核心区别:CoW复制旧数据,RoW重定向新数据

这张图把两种流程并排对比,应该能看得很清楚。左边CoW是先复制旧数据再写新数据,右边RoW是直接写新块然后改指针。两种方式都能实现「瞬间快照」,但底层的IO路径完全不同。

一句话总结:CoW适合写少读多的场景,RoW适合写频繁且使用SSD的场景。元数据与数据分离是两种方案的共同基石——没有这个设计,快照的创建和恢复都不可能做到毫秒级。