delphij's Chaos

选择chaos这个词是因为~~实在很难找到一个更合适的词来形容这儿了……

26 Dec 2023

这次的 ZFS 数据损坏问题

12月1日,FreeBSD 发布了 FreeBSD-EN-23:16.openzfs, 用于修正近期发现的 ZFS 数据损坏问题。 这个问题是由 Rob Norris 最终修正的,这里记一笔。

一些基础概念

同步与异步I/O操作

与内存不同,外存的速度通常比内存要慢若干量级。 普通的应用程序在写数据时通常有两种选择:其一是以「同步」方式进行操作, 即在操作返回时所有的写操作皆已反映到可靠的存储介质上, 其二则是以「异步」方式进行操作,即发起一个写操作, 然后应用程序可以干别的,随后查询状态, 或者提交一个同步操作来把之前的数据保存到盘上。 有了「此调用返回时,此调用之前的(元)数据均已保存到可靠的介质上」 的保证,就可以在其上搭出事务支持了: 例如,数据库的 COMMIT 操作就可以通过等待与之对应的日志数据的同步操作来实现, 如果系统在同步操作完成之后发生断电或崩溃,数据库依然可以通过重放日志数据来恢复。

为了尽量有效地利用系统资源,现时的操作系统内核在实现读写操作时, 均采取异步方式,即发起操作时内核在发起(或者不发起,而是等操作攒的足够多的时候才开始发起) 写操作之后并不等待其完成,而是转去做其他事,并在之后等待来自设备或时钟的中断, 并在收到该中断后再次向硬件质询操作是否完成,并据此作出相应的处理。

由于内存的容量相比外存来说要少若干量级,因此操作系统必须有效地将内存用于不同的目的。 以输入输出缓冲区为例,显而易见,从磁盘中读出的、未经改变的数据在必要时可以再从磁盘中读出, 因而这类缓冲区可以随时丢弃并用作其他用途;而修改过的、还没有写盘的数据则不能随意丢弃。 为了区分缓冲区的这两种状态,内核通常会将缓冲区标记为「脏的 (dirty)」, 表示其中包含未落入可靠存储的数据,或是「干净的 (clean)」, 表示其中的全部数据已经保存到了可靠的存储中。对文件系统来说, 这两种状态还包括与之相关的文件系统元数据,例如文件引用了哪些数据块、 这些块是否已经分配给某个特定文件,等等,在这些状态之间也有一些依赖关系, 例如,文件数据写盘之后,只有在与这些数据相关的元数据也完成了写盘之后, 才应认为这些数据已经保存到了可靠的存储中,等等。

dnode

ZFS 中, dnode 是一个长度为 512 字节倍数的可变长度数据结构,用来表达 ZFS 中的对象。 与 UFS 的 inode 类似,dnode 也用来表达文件或目录,除此之外,它也可以表达 ZVOL 卷以及其它一些内部元数据。 盘上的 dnode 是存放在 struct dnode_phys 中的, 而内存中则是 struct dnode,这两个数据结构的命名方式 (带 _phys 后缀表示存储到盘上的结构,而不带后缀表示内存中的结构) 与 ZFS 中其他一些数据结构的规则类似。

dnode 中包含了一系列用于表示文件所属的缓冲区是否已经完成写盘的描述信息, 其中包括 dn_dirty_link (dnode 本身是否在顶层 objset 对象中的未写盘列表中) 和 dn_dirty_records (包含未写盘数据的 dbufs)。

稀疏文件和「洞」

许多现代文件系统中都有「稀疏文件(sparse file)」的概念,这类文件中存在大量全部为 \0 (NUL) 的区域, 如果文件系统支持的话,这类区域可以采取在元数据中标记,而不是真的写入完整的全 0 块的方式来表达。 这样做的主要好处有两个:首先是读出数据时,操作系统不需要真的进行 I/O,因而有助于提高访问效率; 其次,它也节省了存储。在符合 POSIX 的操作系统中,稀疏文件对应用程序来说是透明的, 应用程序可以使用 truncate(2) 来扩大文件, 然后在其中 lseek(2) 在不连续的区域分别写入, 而不是在连续的位置持续写入数据来制造稀疏文件。

除此之外, lseek(2) 还可以定位文件中这类没有写盘的全 0 区域 (SEEK_HOLE; 需要说明的是,取决于文件系统的具体实现,这些区域可能比之前写入数据时 lseek(2) 跳过的间隔要略小, 因为绝大多数情况下文件系统在存放此类空白区域时是按照完整的数据块尺寸进行的, 如果跳过的区域不在数据块的整倍数边界上,则从当前数据区域到下一个数据块之间的部分仍然需要真的写0), 或是从这类全 0 区域为起点定位到下一个包含数据的区域 (SEEK_DATA)。

与此类似,在 ZFS 中,如果一个数据块的内容是全 0,则其数据对应的 块指针 (Block Pointer) 会做特殊标记, 称之为「洞」(hole)。 ZFS 的其他组件中使用 BP_IS_HOLE 宏来测试这类块指针, 这项优化使得对于这类数据块无需真的在盘上写 0,也无需真的从盘上读出数据, 其好处与稀疏文件中对全 0 块的处理是类似的。需要注意的是,对于「洞」的处理是在 I/O 层 (zio) 的时候进行的,因此只有在写操作做完时,我们才能知道一组数据块最终是不是会变成「洞」。

问题

简而言之,这次的数据损坏问题是有时 ZFS 会在进行 SEEK_DATA 时,不正确地跳过含有数据的部分。 由于新的 FreeBSD 和 Linux 的 cp(1) 均以不同的方式使用了这一功能来跳过全 0 的数据块, 因此会导致复制出来的数据中原本不应该出现全 0 的部分出现全 0 的现象。除此之外, 其他依赖 SEEK_DATA 的应用程序也可能受到影响。尽管如此, ZFS 最终写入的原始数据依然是正确的, 加上这个问题需要符合一系列比较苛刻的条件才能触发,因此普通用户可能不太容易碰到它。

文件系统对于存储的访问是独占的。读取数据时,很自然的想法是找到盘上存储数据的位置,并发起一个 I/O 操作把数据加载到缓冲区中,然后把该缓冲区通过内核接口交给应用程序。但在实践上,实际发生的操作要复杂得多: 盘上的数据可能之前已经读过并且仍然在位于主存的缓冲区中,此时显然直接将这些数据交给应用程序可以省掉一次 I/O; 对于刚刚修改过的数据,文件系统更是必须从内存中的副本来取得数据,因为内存中的这份「脏」的数据才是最新的那份。 因此,文件系统的实现中就必须对缓冲区的状态进行完整的记账,才能确保其交给应用程序的数据的正确性。

对于 ZFS 来说,这一部分更为复杂。 ZFS 是一个写时复制 (Copy-on-Write) 的文件系统,它在写数据时, 并不会覆盖掉已经存在的数据块,而需要将原数据块中不应修改的部分(如果存在的话)读出, 然后写入一个处于存储上新位置的全数据块。

ZFS 的事务组 (txg) 包含三个状态: Open (初始状态,允许新的写操作进入。一旦积累够足够多的操作,或是达到了 vfs.zfs.txg.timeout, 则进入下一个状态)、Quiescing (允许上一状态中还未做完的操作做完,同时开启一个新的 Open txg) 和 Syncing (将 Quiesced 的 txg 写入可靠的存储)。

在考虑数据是否已经写盘时,需要同时考虑这三个事务组中的状态。考虑在一个已经存在的文件中先后写入两个数据块 a、b 的情况,这可能潜在地会形成两个新的 dnode 版本,这两个版本的 dnode 以及数据块 a、b 可能出现在三个不同状态的事务组中, 其 dirty 状态会随着写盘而逐渐被清除。

Quiescing 和 Syncing 状态的事务组中的数据状态未必反映应用程序认为的数据最新状态, 但前面提到,「洞」是在写操作做完时才确定的。 这意味着 SEEK_DATA 如果遇到了一段标记为「dirty」 的区域,则只有写入完成之后才能够可靠地判断它是不是「洞」。 然而我们知道 I/O 操作相对于内存操作来说是要慢很多的, 一个 txg 可能相当大,假如每次 SEEK_DATA 的时候都等待数据写完, 很明显是不经济的。那怎么办呢?考虑 SEEK_DATA 的语意, 一组全 0 的数据也可以认为是数据(相反,如果是一组非全 0 的数据被跳过则会导致问题),因此,我们可以判断 dnode 中是否包含了未写盘数据, 并针对这些数据一律返回「有数据」,而不是真的等待写入操作做完。 由于这样一来一些本应被认为是空洞部分的区域会被认为存在数据, 但如此这类操作便不必等待 txg 完全写入, 因此会改善一些应用程序的性能。

系统默认的设置 (vfs.zfs.dmu_offset_next_sync=1),则是在进行 SEEK_DATA 操作时等待之前的事务组完成写入。 然而我们注意到,如此设置时,在测试中问题似乎更容易被触发,这又是为什么呢?

在写入 dnode 的过程中,有一个短暂的时间段, dnode 本身的 dirty 状态被清除。 但与之相关的文件数据还没有完成写盘操作。这个状态只会发生在 Syncing 阶段。 在问题得到修补之前, dnode_is_dirty() 在这种状态下会不正确地返回 B_FALSE

前面提到,在 SEEK_DATA 时,如果 dnode 包含未写盘的数据则需要进行特殊处理。 对于 vfs.zfs.dmu_offset_next_sync=0 的情形,此时应直接告知应用程序此区域有数据, 而对于默认情形,则应等待 txg 做完,而 txg 操作需要先把 dnode 的 dn_struct_rwlock 锁打开,如此 dnode 的状态变有可能在这段时间内发生变化,因此必须从头再做一次检查。

无论 dmu_offset_next_sync 的值是什么,最终如果 dnode_is_dirty() 不正确地返回了 B_FALSE 的话,我们都可能告诉应用一个本应被认为有数据的位置是空洞。因此,设置 vfs.zfs.dmu_offset_next_sync=0 并不能真的彻底避免问题,因为问题依旧可能发生,但 vfs.zfs.dmu_offset_next_sync=1 时,由于在等待 txg 做完之前和之后各做一次 dnode_is_dirty() 检查, 因此碰到这个边界条件的机会反而增加了。

解决

知道了问题的原因,只要让 dnode_is_dirty() 能返回正确结果便可以修正问题。 在修正问题之前,它是如此判断 dnode 是否包含未写盘数据的:

	for (int i = 0; i < TXG_SIZE; i++) {
		if (multilist_link_active(&dn->dn_dirty_link[i])) {
			mutex_exit(&dn->dn_mtx);
			return (B_TRUE);
		}
    }

若 Syncing 阶段的 txg 在不恰当的时机清除了 dnode 的 dirty 状态, 则无论其是否包含 dn_dirty_records 均会导致返回 B_FALSE。 因此解法便是增加对 dn_dirty_records 的检查:

	for (int i = 0; i < TXG_SIZE; i++) {
		if (multilist_link_active(&dn->dn_dirty_link[i]) ||
		    !list_is_empty(&dn->dn_dirty_records[i])) {
			mutex_exit(&dn->dn_mtx);
			return (B_TRUE);
		}
	}