本文翻译自 https://lwn.net/Articles/457667/
原文标题: Ensuring data reaches disk
理想情况下,不会发生操作系统的崩溃和断电,磁盘也不会发生故障。但不幸的是,故障比预期更为常见。本文描述了从应用程序到数据存储的路径。重点介绍数据缓冲部分,提供最佳实践,以确保数据被提交到稳定的持久化存储中,避免发生问题时中途丢失。
本文关注的是 C 语言,但系统调用应该是可以扩展到大多数语言的。
I/O 缓冲
为了实现数据完整性,了解整个系统很重要。数据到达稳定存储之前,经过多个层,如图所示:
最顶层:应用程序。数据位于应用程序的内存块或者缓冲区中。
数据经过的下一层为内核,它保留自己的缓存版本,称为页面缓存 (Page Cache)。脏页(Dirty Page)可以在 Page Cache 中停留不确定的时间,具体取决于系统负载和 I/O 模式。
当脏数据最终从缓存中清除时,会被写入存储设备。存储设备可能也有自己的易失性缓存,如果断电,数据会丢失。最后一层是非易失存储。一旦数据到达这一层,认为是 “安全的”。
考虑一个应用,在网络 socket 上侦听连接,并将数据写入文件。在关闭连接前,这个应用需要确保收到的数据已经写入稳定存储,并向 client 发送确认信息。
0 int
1 sock_read(int sockfd, FILE *outfp, size_t nrbytes)
2 {
3 int ret;
4 size_t written = 0;
5 char *buf = malloc(MY_BUF_SIZE);
6
7 if (!buf)
8 return -1;
9
10 while (written < nrbytes) {
11 ret = read(sockfd, buf, MY_BUF_SIZE);
12 if (ret =< 0) {
13 if (errno == EINTR)
14 continue;
15 return ret;
16 }
17 written += ret;
18 ret = fwrite((void *)buf, ret, 1, outfp);
19 if (ret != 1)
20 return ferror(outfp);
21 }
22
23 ret = fflush(outfp);
24 if (ret != 0)
25 return -1;
26
27 ret = fsync(fileno(outfp));
28 if (ret < 0)
29 return -1;
30 return 0;
31 }
第 5 行是应用程序缓冲区的示例。把 socket 读到的数据放入这个 buffer。由于传输的数据量已知,考虑到网络通信可能是突发、缓慢的,我们使用 libc 的 stream 函数 (fwrite()
和 fflush()
) 进一步缓冲数据。
10-21 行负责将 socket 读取的数据写入文件流。22 行,所有的数据都写入了文件流。23 行,刷新文件流,数据进入 “内核缓冲区 kernel buffer” 层。27 行,确保数据保存到上图中的 “稳定存储 stable storage” 层
I/O API
将 I/O 分为 3 个不同的类别: System I/O,Stream I/O、内存映射(mmap) I/O。
System I/O:通过系统接口将数据写入仅内核空间可访问的存储层操作。下面的表格是 System 接口的一部分。(表格不完全,重点介绍写入操作)
Operation | Function(s) |
---|---|
Open | open(), creat() |
Write | write(), aio_write(), pwrite(), pwritev() |
Sync | fsync(), sync() |
Close | close() |
Stream I/O 定义:使用 C library 的 stream 接口。使用这些接口可能不会导致系统调用。这意味着调用这些函数后,数据可能仍然存活在用户空间中。下面是 stream 接口列举(不完全):
Operation | Function(s) |
---|---|
Open | fopen(), fdopen(), freopen() |
Write | fwrite(), fputc(), fputs(), putc(), putchar(), puts() |
Sync | fflush(), followed by fsync() or sync() |
Close | fclose() |
内存映射 I/O:与上面 System I/O 类似,仍然使用相同的接口打开、关闭。但对文件数据访问。通过将数据映射到进程的地址空间进行。然后就可以对数据缓冲区一样,执行内存读写操作,从而操作文件。
Operation | Function(s) |
---|---|
Open | open(), creat() |
Map | mmap() |
Write | memcpy(), memmove(), read(), or any other routine that writes to application memory |
Sync | msync() |
Unmap | munmap() |
Close | close() |
打开文件时候可以通过 2 个 flag 设置缓存行为 O_SYNC
(以及相关的 O_DSYNC
) 和 O_DIRECT
。
使用 O_DIRECT
打开文件执行 I/O 操作,会绕过内核的页面缓存,直接写入存储。回想一下,存储层本身可能将数据写入缓存中,因此,使用 O_DIRECT
打开的文件,仍然需要 fsync()
才能将数据保存到稳定存储中。O_DIRECT
仅与 System I/O API 相关。
原始设备(/dev/raw/rawN
)是一个 O_DIRECT
的特例。这些设备可以不指定 O_DIRECT
打开,仍然提供 direct I/O 的语义。
SYNC I/O:使用 O_SYNC
或 O_DSYNC
打开的文件描述符,执行任何 I/O (包括带 O_DIRECT
或者不带的 system I/O)。
以下是 POSIX 定义的 SYNC 模式
标志 | 描述 |
---|---|
O_SYNC | 文件数据和所有文件元数据同步写入磁盘。 |
O_DSYNC | 仅将文件数据和访问文件数据所需的元数据同步写入磁盘。 |
O_RSYNC | 未实现。 |
对这些文件描述符的写入,数据和相关元数据会立即写入稳定存储。注意:检索文件数据不需要的元数据可能不会立即写入,比如访问时间、创建时间、修改时间。
值得指出的是,使用 O_SYNC
和 O_SYNC
与 libc 文件流关联的微妙。请记住,文件指针的 fwrite()
由 lib c 缓冲。直到调用 fflush()
后,才能确定数据写入硬盘。本质上,将文件流与 SYNC 关联,意味着 fflush()
之后,不需要对文件描述符进行 fsync()
,但 fflush()
仍然是必要的。
什么时候进行 fsync?
有简单的规则用于确定是否需要调用 fsync()
。
首先,您必须回答这个问题:立即将数据保存到稳定存储是否必要?如果是临时数据,您可能不需要 fsync()
。反之,如果您需要保存事务结果或者用户配置文件,请使用 fsync()
。
更微妙的是处理和创建新的文件、覆盖现有文件。
新创建的文件可能不仅需要对文件进行 fsync()
, 也需要对其目录进行 fsync()
(这是因为目录是文件系统查找文件的位置)。这个行为取决于操作系统和挂载选项。您可以针对每个文件系统单独编码,也可以对目录执行 fsync()
来确保可移植性。
同样地,如果覆盖文件时候遇到系统故障(例如断电、空间不足ENOSPC
、I/O 错误),可能导致现有数据丢失,通常的做法,也是建议的做法,是将更新的数据写入临时文件,确保稳定存储安全,然后将临时文件重命名为原始文件名,从而替换内容。这样可以确保文件是原子更新的,以便其他读取能够获得数据,或者另一份数据。此类更新需要进行以下步骤:
- 在同一个文件系统创建一个新的临时文件
- 写数据到临时文件
fsync()
临时文件- 重命名临时文件为正确的名字
fsync()
目录
检查错误
当使用库或者内核缓冲写 I/O 时,在 write()
和 fflush()
调用可能不会报告错误。因为数据可能进写入 Page Cache。写入错误通常在调用 fsync()
, msync()
, close()
时报告。因此检查这些调用的返回值非常重要。
回写缓存(write-back cache)
本节提供了有关磁盘缓存以及操作系统对其控制的常规信息。本节的事项不会影响应用程序的构建方式,因此本节仅供参考。
存储设备上的会写缓存有多种类型。一种是易失的,本文章中一直是假设这种类型。但是,大多数设备可以配置在无缓存或者直写(write-through)模式。在请求到达稳定存储状态前,两者都不会返回成功。外部存储通常由非易失性、或者由电池供电的写缓存,即使断电也会保存数据。但在程序员角度,这些是无法看到的,最好假设磁盘的缓存是易失的,并谨慎编程。在保存数据时,操作系统将执行任何可能的优化,以保持尽可能搞的性能。
某些文件系统提供挂载选项控制缓存刷新行为。对于内核版本 2.6.35 中的 ext3、ext4、xfs 和 btrfs,挂载选项 -o barrier
打开缓存刷新屏障,或者 -o nobarrier
关闭屏障。更老的内核版本可能需要不同的选项(-o barrier=0,1
),这取决于文件系统。同样,应用程序编写者不需要考虑这些选项。当文件系统的屏障被禁用时,这意味着 fsync 调用不会导致磁盘缓存刷新。希望管理员在指定此挂载选项之前,知道自己确实是不需要缓存刷新的。
附录:一些例子
本节提供了程序员经常需要执行的示例代码
- Synchronizing I/O to a file stream
- Synchronizing I/O using file descriptors (system I/O) This is actually a subset of the first example and is independent of the O_DIRECT open flag (so will work whether or not that flag was specified).
- Replacing an existing file (overwrite).
- sync-samples.h (needed by the above examples).
# 一些讨论
## fsync() 与 sync()
-
fsync
作用于单个文件,确保特定文件的数据被写入。-
sync
作用于所有已打开的文件,确保整个系统的文件一致性。## fflush() 与 fsync()
-
fflush
只影响缓冲区,确保数据从用户空间(缓冲区)写入到内核空间(文件系统缓存)。-
fsync
确保数据从内核空间写入到物理存储设备。- **先调用**
fflush
:确保所有待写入的数据都已经被写入到内核的文件系统缓存。- **再调用**
fsync
:确保这些数据被写入到物理存储设备。## 为什么 stream 函数都在 open、close 前加 f,f 代表什么
-
f
表示“file”,用于标识与文件相关的标准 I/O 函数。- 这些函数提供了更高层次的文件操作接口,利用了缓冲机制,提高了 I/O 操作的效率和便利性。
## 什么场景使用 mmap 更方便
-
mmap
提供了高效的内存访问方式,特别适合处理大文件和需要频繁读写的场景,能够显著提高性能并简化代码。