Christoph Hellwig 在 2019 Linux Storage, Filesystem, and Memory-Management Summit (LSFMM)会议上,分享了他关于文件系统原子操作的思考。如果 application 在写文件的时候出现了程序崩溃,人们肯定希望文件系统里的数据要么是旧数据,要么是新数据,肯定不希望看到新旧数据混杂在一起。这样就需要对文件有 atomic write 操作的能力。他介绍了在 XFS 里实现的 atomic write,想看看其他文件系统开发者有什么想法。
目前,如果 application 希望对文件做 atomic write,会有两种方法。一种是在 user space 加锁来保护,数据库方案里面通常会这样来做;另一种是重新写一个新文件,然后做“atomic rename”。可惜,应用程序开发者通常都没有正确使用 fsync(),所以最终数据还是会有丢失。
在现代存储系统里面,哪怕存储设备硬件本身,在写操作的时候都不是立刻写入存储单元的。例如闪存设备都有一个 flash translation layer (FTL)抽象层,会把写操作分发到 flash 的各个地方去,避免某些存储单元被写入太多过早损坏(wear leveling),所以它们实际上从来不会在原地址更新数据。对 NVMe 设备来说,每次更新它的一个逻辑块地址(LBA)的数据的时候,都是能确保是 atomic 操作的,不过这个接口地位比较尴尬,估计很少人会用。SCSI 的接口更加好一些,在做 atomic write 的时候能够有错误报告,不过他也没见过哪个厂商真的实现了这个接口。
有的文件系统可以在写操作的时候写在别的地址 ,例如 XFS, Btrfs,等等。这样就能很容易在文件系统层实现 aotmic write。在 5 年前,HP Research 就有一篇有意思的论文,介绍了如何增加一个特殊的 open() flag 来专门指定要 atomic write。这篇论文还只是学术论文,没有真正处理各种 corner case,以及针对现实生活中的限制来实现,不过想法很合理。
在这个系统中,用户写入文件的时候无论写入多少数据都没用,在明确调用 commit 操作之前都不会真正生效。当 commit 操作结束后,所有的改动就真正生效了。这里比较简单的一种实现方式就是在 fsync()里面来恰当地调用 commit 操作,这样就不需要再加一个新的系统调用了。
不久之前,他开始在 XFS 里面用这种方式实现 atomic write。他向社区发布出了一组 patch,不过当时还有不少问题,因此他后来继续修改了这组 patch。目前论文的原作者一直在跟他紧密交流希望能拿到代码,然后就能跟他合作再写一篇论文出来。此外还有一些人也希望使用这个功能。
Chris Mason 问他这里的粒度是多大,是针对每个单独的 write(),还是更多?Hellwig 回答,在 commit 操作之前的所有 write,都会等 commit 的时候一次写入。文件系统会负责对最多能写多少数据来设一个上限。对 XFS 来说,会根据这些 write 所涉及到的不连续区域的数量,来决定这个上限。
不光是传统的 write()系统调用,mmap()映射出来的区域也一样适用。例如,人们现在更改 B-Tree 的多个节点的时候,很难做 atomic update,而这个功能合入后,application 可以简单的在文件 mmap 出来的内存区域做这些更改,然后简单做一次 commit 即可。如果 application 崩溃了,文件系统里存储的数据仍然保证是旧版本或者是新版本,不会混杂起来。
Ted Ts’o 提到他的 Android 领域的朋友也在提需求想要这么一个功能,不过是针对每个文件系统级别的。他们希望每次对 Android 做版本更新的时候,ext4 或者 F2FS 文件系统都可以通过一个 magic option 来加载上来并且关闭所有日志记录真正触发写入操作。等文件更新完毕然后就发一个 ioctl()来开始把所有那些日志都刷到存储设备里。这个方案有点不美观,不过能实现 90%的功能。最后,Ts’o 也认为 ext4 会需要有一个 atomic write 功能,不过每次 commit 之前究竟能更新多少数据,这里可能更加受限制一点。
Hellwig 表示了一些担忧,因为他此前也做过类似的实现方案,都是在内存里面做数据更新,不过最终发现每一批次能缓存的数据非常有限。Ts’o 介绍了 Android 的情况,这里数据块都是会写入存储设备的,而内存中缓存的只是 metadata 相关的更新,一般也就缓存几分钟,这是个非常特殊的应用场景,不具有普适性。不过这个新的实现方法替代了此前的利用 device-mapper 的机制(那个太慢了)。
Chris Mason 提到,只要 interface 设计的好,他很愿意让 Btrfs 支持这个功能。Hellwig 也说对 Btrfs 来说实现这个功能会很直接。对他来说一个比较大的阻碍是怎么支持 O_DIRECT。如果某个 application 先做了 atomic write,然后又把内容读回来,最好是能把刚刚写入的数据读回来。一般的 application 都不会这么做,不过 NFS 确实有这种行为。Linux I/O 的代码里面没有完全支持好这部分功能,所以他还需要做一些修改。
还有一些讨论是关于为什么利用 fsync()的,为什么不用一个专用的系统调用,或者其他什么接口。Hellwig 觉得用 fsync()没有什么不好,毕竟这也是它的本来含义,不应该只做一部分工作,而不做完。Amir Goldstein 问到是否有可能其他进程同时也对这个文件做 fsync()操作,相当于是某种类型的攻击。 Hellwig 说他本来是使用了一个 open()的 flag,不过后来有人提醒没有用过的 flag 可能不会在 open()里面检查,所以利用 flag 来保证数据一致性不是一个很好的主意。 在那种使用模式下,使用 fsync()的接口仅仅会对那些被用这个 flag 打开的 file descriptor 才会做 commit 操作。后来他改成了使用 inode 的 flag,这样更加合理一点,不过目前还没有处理好那些恶意的 fsync()调用的问题。