100 MB 的文件改 1 MB:原地覆盖只写 1 MB,但旧版本没了;写时复制也写 1 MB,旧版本完整保留。10 个快照下,全量复制要 1100 MB,写时复制只要 110 MB——差 10 倍。
7.5 的日志是【先记账再动手】,这一节讲另一种思路:根本不动原来的东西,改写一份新的。它不只解决一致性,还顺手把【快照】变成了白菜价。
一、不改原地,改指针
写时复制(copy-on-write,COW)的做法:要改一个块,不覆盖它,而是把新内容写到【另一个空块】上,然后改【指向它的那个指针】。 因为文件在磁盘上不是一段连续的字节,而是【一棵由块指针组成的树】:inode 指若干块,其中一些块又指向更多块。所以改内容 = 改一条从根到叶的路径。 这条路径上每一层都要换成新的副本(因为老的那层还被旧版本指着)。这就是【写放大】的来源:改 1 MB 数据,可能要多写几个索引块。 但换来的好处极大:老的那个根指针还完整地指着整棵老树,于是【旧版本】天然就保存下来了。 一句话:写时复制把【覆盖】换成了【分家】。
二、快照为什么几乎不要钱
做快照 = 把当前的根指针复制一份存起来。一份根指针就是几个块,几 KB。 之后所有写都走写时复制,所以新旧两边自动分家:新的写到新块上,老块原封不动。 算一笔账:100 MB 的数据,做 10 个快照,每个快照之后改 1 MB。 如果每个快照都全量复制:11 份 × 100 MB = 1100 MB。 用写时复制:基础 100 MB + 10 次改动各 1 MB = 110 MB。 差 10 倍。而且快照个数再多也不影响【没改过的那 90 MB】——它只有一份,所有版本一起用。 这就是为什么 Btrfs、ZFS 上的快照是【日常操作】而不是【备份任务】(7.16):拍一张几秒钟,删一张也几秒钟。
三、代价:碎片、拖空间、多跳一层
代价一:碎片化。新数据要写到【当天空着的地方】,不再与原来的相邻。一个长期反复改的文件会碎成上百段,顺序读退化成随机读——第 6 章量过这两者差 4 倍。 代价二:空间会被旧快照拖着。只要还有快照指着旧块,那些块就不能回收。所以【删了一个大文件,空间没回来】在 COW 文件系统上是常见现象——因为某个快照还看得见它。 代价三:块指针层级更深。写时复制通常配 B 树,查一个块要多跳几层。这几层都在内存里缓存着,代价不大,但确实存在。 还有一条容易被忽略的:COW 文件系统在【接近满】的时候特别难受——因为改一个块需要【先有空块】。写满之后连删除都可能失败,所以官方都建议留 5%~10% 不用。
四、COW 与日志不是一回事
两者都想解决【账做了一半】,但路子完全不同。 日志:先记下要改什么,再改原地。盘上的数据位置不变,改坏了可以回滚。 写时复制:永不覆盖,只写新块、改指针。任何时刻盘上都有一棵完整的树,所以根本不需要回滚。 结果上的差别:日志之后,你还是只有一个版本;COW 之后,旧版本天然还在——这就是快照的来源。 能不能两个一起用?能,而且很常见:Btrfs 就是 COW 加日志(它把日志叫 log tree),平时靠 COW 保证一致性,关键时刻靠 log tree 加速恢复。
五、动手 Demo:写时复制——100 MB 的文件只改 1 MB,为什么要写两遍
这一节不用真的建文件,只把两种改法的【写入量】与【版本数】摆在一张表上算清楚,再算一遍【10 个快照】的空间账。
MB = 1024 * 1024
base = 100 * MB # 原始 100 MB
dirty = 1 * MB # 每次只改 1 MB
n = 10 # 10 个快照
# 全量复制:每个快照都存一份完整的 100 MB
full = (n + 1) * base / MB
# 写时复制:基础一份 + 每次改动各存一份
cow = (base + n * dirty) / MB
print("全量复制 %d 份 -> %.0f MB" % (n + 1, full))
print("COW + %d 个快照 -> %.0f MB" % (n, cow))
print("倍数 %.1f" % (full / cow))
实跑输出:
--------------------------------------------------------------------
写时复制——100 MB 的文件只改 1 MB,为什么要写两遍
--------------------------------------------------------------------
① 两种改法,写入量差多少
改法 写的字节 旧数据还在吗 版本数
-------------------------------------------------------
原地覆盖 1 MB 没了 1
写时复制 1 MB + 指针 完整保留 2
**写时复制的顺序是:把要改的那 1 MB 写到【新块】,**
**然后重写从根到叶的一串指针。**
**旧块一个字节都没动**——**所以旧的那个版本天然还在,只是没人指了。**
② 快照为什么几乎不要钱
**做快照 = 把当前的根指针复制一份存起来。**
**一份根指针就是几个块,几 KB。**
**之后所有写都走写时复制,所以新旧两边自动分家。**
做法 占多少空间
--------------------------------------
每个快照都全量复制 1100 MB
写时复制 + 10 个快照 110 MB
**1100 MB 对 110 MB——差 10.0 倍。**
**而快照的个数完全不影响到【没改过的那部分】。**
**90 MB 只有一份,10 个快照一起用。**
③ 写时复制的代价
**代价一:碎片化。**
**新的数据块要写到【空的地方】,不再与原来的相邻**,
**所以顺序读会退化成随机读。**
**代价二:空间会被【旧快照】拖着不放。**
**只要还有快照指着旧块,那些块就不能回收。**
**代价三:块指针层级更深**(写时复制通常配 B 树),**查一次要多跳几层。**
**所以【快照】不是白拿的。**
**它把【复制的时间】换成了【长期占用的空间与碎片】。**
1100 MB 对 110 MB,差 10 倍。而且快照个数越多,差距越大——全量复制是线性增长,COW 只是每次改动那一点点。 注意这里【没算写放大】:真实 COW 还要多写几个索引块,所以实际比 110 MB 稍多一些。但量级不变。 也要注意【没算碎片】:省下的空间是用【数据不再连续】换来的,读的时候会慢一些。 所以【快照免费】这句话要打个折扣:免费的是一瞬间的复制动作,不免费的是【长期占用的空间】与【碎片】。
📋 Linux 上验证:Btrfs 上 btrfs subvolume snapshot / /snap 瞬间完成;btrfs filesystem usage / 看真实占用。
30 秒实验
- 想一想:为什么 COW 文件系统上【删文件不一定释放空间】?
- 算一遍:100 GB 数据、20 个快照、每个快照后改 2 GB,COW 要多少空间?
mount | grep btrfs,看你有没有现成的 COW 文件系统可以试。
常见误区
- 【写时复制=备份】。快照与源在【同一块盘】上,盘坏了两个一起没。它是【版本】不是【备份】。
- 【COW 一定更省空间】。只有【改动的比例小】时才省;如果每次都几乎重写一遍,COW 反而多占(旧块还留着)。
- 【COW 与日志是竞品,选一个】。Btrfs 两个都用:COW 保证平时的原子性,log tree 加速崩溃后的恢复。
自测三问(点开对答案)
1. 写时复制为什么天然保留旧版本? —— **因为旧块一个字节都没动**,**旧的根指针还完整地指着整棵老树**,只是没人用它了。 2. 10 个快照为什么只多占 10%? —— **因为快照之间共享所有没改过的块**;**只有【改过的那部分】各存一份**。 3. COW 文件系统为什么会【磁盘满了还删不掉东西】? —— **因为删东西也要改元数据,而改元数据需要先有空块**;**盘满了就没有空块可用**。**所以官方建议留 5%~10% 不用满。**- ☐ 7.7 防止不了的情况
相关笔记
- 日志 — 另一种解法,对比着看
- 防止不了的情况 — 三道防线的边界
- Btrfs — 把 COW 当日常功能用
- 🐧 Linux是怎样工作的 · 索引