写文件的时候不希望写入一半的时候掉电丢失

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()调用的问题。

android 设备写入文件,立即断电重启后,文件丢失,数据没有保存

在 android 开发的过程中碰到写入文件后,立即断电重启,发现写入的文件丢失了

写入时检查了,写入是没有失败的,经过查找资料可能是如下问题引起:

Linux/Unix 系统中,在文件或数据处理过程中一般先放到内存缓冲区中,等到适当的时候再写入磁盘,以提高系统的运行效率。

可能是因为断电时,文件没有写入的物理介质中导致,解决办法如下:

   在write/fwrite写入后,添加fsync(), 这样可以将缓存中的内容强制写入到磁盘中

关于 write/fwrite 和 fsync 的关系如下:
read/write/fsync:

  1. Linux 底层操作;

  2. 内核调用, 涉及到进程上下文的切换,即用户态到核心态的转换,这是个比较消耗性能的操作。

fread/fwrite/fflush:

  1. C 语言标准规定的 io 流操作,建立在 read/write/fsync 之上

  2. 在用户层, 又增加了一层缓冲机制,用于减少内核调用次数,但是增加了一次内存拷贝。

两者之间的关系,见下图:

补充:

  1. 对于输入设备,调用 fsync/fflush 将清空相应的缓冲区,其内数据将被丢弃;
  2. 对于输出设备或磁盘文件,fflush 只能保证数据到达内核缓冲区,并不能保证数据到达物理设备, 因此应该在调用 fflush 后,调用 fsync(fileno(stream)),确保数据存入磁盘。
  3. 在 android java 层中,需要调用 FileDescriptor 的 sync()方法来确保数据存入磁盘。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/* Java 示例代码 */

FileOutputStream fos = null;

byte[] buf = {1,2,3,4};

try {

fos = new FileOutputStream("/sdcard/test.txt");

//将buf中的数据写入fos

fos.write(buf);

//将fos的数据保存到内核缓冲区

//不能确保数据保存到物理存储设备上,如突然断点可能导致文件未保存

fos.flush();

//将数据同步到达物理存储设备

FileDescriptor fd = fos.getFD();

fd.sync();

} catch(Exception e) {

e.printStackTrace();

} finally {

if(fos!=null)

fos.close();

}