Topic: Ensuring Data Reaches Disk

本文翻译自这篇文章:https://lwn.net/Articles/457667/

对于操作系统来说,最理想的状态就是永不发生崩溃、断电以及硬件故障(如磁盘),这样工程师在编程时就不需要关心这些特殊情况。但软件、硬件故障是常态,理想的状态并不存在。这篇文档主要目的在于解释数据从应用到持久化存储的路径,着重关注路径上哪些地方使用了缓存,最后提出确保数据已经写入持久化存储的方案。

本文讨论过程中的例子用 C 语言书写,但对其它语言同样适用。

I/O Buffering

为了保证程序数据的完整性,软件工程师一定要理解整个操作系统的数据架构、数据流向。数据在进入持久化存储之前可能穿过许多层,如下图所示:

  1. 处于最顶端的是正在运行的应用程序,程序中存储着需要持久化的数据。这些数据存放于程序申请的若干块内存空间中。这些数据也可能被传递给一些第三方的 library 中,后者可能也会在内部维持着缓存,但不论是哪种情况,这些数据都生活在应用的寻址空间中。

  2. 数据流向的下一层就是 kernel,kernel 维护着内部版本的写回缓存 (write-back cache),写回缓存也被称为页缓存 (page cache)。一些脏页可能在页缓存中生存不定长的时间,具体的时长取决于系统的负载和 I/O 运作模式。

  3. 当脏数据最终从页缓存中清除时,将被写入到存储设备中,如磁盘。但磁盘也不一定会立即持久化数据,后者可能继续维持着自己的写回缓存,这种缓存通常是易失性存储,如果数据在缓存中尚未写入时出现断电,则系统将面临数据丢失的风险。

  4. 最终,当数据从存储设备的缓存写入存储层时,我们才能认为数据是安全的。

为了进一步解释这种多层缓存现象,我们可以以一个具体的应用为例。假设应用 A 监听某 socket 中的连接,当收到客户端的数据时,将数据写入本地持久化存储中。在断开连接时,A 服务必须保证接收到的数据已经确定写入持久化存储设备中,并向客户端发送 ack 消息。

在接收到客户端的连接后,应用需要从 socket 中读取数据到 buffer 中,下面的示例就将完成这个功能:

int
socket_read(int sockfd, FILE *outfp, size_t nrbytes) {
int ret;
size_t written = 0;
char *buf = malloc(MY_BUF_SIZE);
if (!buf)
return -1;
while (written < nrbytes) {
ret = read(sockfd, buf, MY_BUF_SIZE);
if (ret <= 0) {
if (errno == EINTR)
continue;
return ret;
}
written += ret;
ret = fwrite((void *)buf, ret, 1, outfp);
if (ret != 1)
return ferror(outfp);
}
ret = fflush(outfp);
if (ret != 0)
return -1;
ret = fsync(fileno(outfp));
if (ret < 0)
return -1;
return 0;
}
  • 第 5 行就是应用缓存的一个例子,从 socket 中读取的数据将被放入缓存 buf 中

  • 由于网络传输数据时快时慢,我们选择预先确定好即将发送的文件大小,然后使用 libc 的 stream 函数 fwrite 和 fflush 来在 lib 层面缓冲数据。第 10 - 21 行就是将数据从 socket 写入 file stream 中的过程。在 22 行处,所有数据都写入到 file stream 中。在 23 行处,file stream 刷出,进入到 kernel buffer 中。

  • 第 27 行,数据正式进入到持久化存储中,即 "Stable Storage" 抽象层。

I/O APIs

在上文中,我们强化了一些 APIs 与多层存储模型 (layering model) 之间的关系。在本小节中,我们将 I/O 进一步划分成 3 种类别:system I/O,stream I/O 以及 memory mapped (mmap) I/O。

System I/O

System I/O 指的是只有在 kernel 的地址空间中,通过 kernel 系统调用接口才能执行的操作,其中的写操作主要包括:

Operation

Function(s)

Open

open(), creat()

Write

write(), aio_write(), pwrite(), pwritev()

Sync

fsync(), sync()

Close

close()

Stream I/O

Stream I/O 主要是应用程序通过 C library 的 stream 接口触发的操作。Stream I/O 中的写操作不一定触发相应的系统调用,这意味着在执行 Stream I/O 中的操作后,数据可能仍然存在于应用缓存,即应用自己的内存空间中。其中的操作主要包括:

Operation

Function(s)

Open

fopen(), fdopen(), freopen()

Write

fwrite(), fputc(), fputs(), putc(), putchar(), puts()

Sync

fflush() followed by fsync() or sync()

Close

fclose()

Memory Mapped I/O

Mmap I/O 与 System I/O 类似。文件仍然用相同的接口开启和关闭,但对文件数据的访问则通过将数据直接映射到应用程序的地址空间来实现,绕过 kernel buffers。

Operation

Functions

Open

open(), creat()

Map

mmap()

Write

memcpy(), memove(), read(), or any other routine that writes to application memory

Sync

msync()

Unmap

munmap()

Close

close()

Caching Behavior

在 linux 系统中,打开一个文件的同时可以指定它的缓存行为,O_SYNC (O_DSYNC) 以及 O_DIRECT。如果开启 O_DIRECT,数据将直接绕过 kernel 的 page cache,直接写入到存储设备中。但存储设备可能会先将数据存入写回缓存中,因此如果要确保数据已经完成持久化,你仍然需要调用 fsync。O_DIRECT 选项只与 system I/O API 有关。

原始设备 (raw devices, /dev/raw/rawN) 是 O_DIRECT I/O 的一种特殊情况,这些设备默认实现了 O_DIRECT 的语义。

我们称开启 O_SYNC (O_DSYNC) 的 I/O,包括 system I/O 和 stream I/O,为 Synchronous I/O。在 POSIX 中,同步 (synchronous) 的模式有以下几种:

  • O_SYNC:文件数据及元数据都同步地写入持久化存储

  • O_DSYNC:只有访问文件数据所需的数据及元数据被同步地写入持久化存储

  • O_RSYNC:未实现

需要注意这里的表达,并不是所有的元数据和数据都会被同步写入,如 access time,creation time 以及 modification time 这种不影响访问文件的数据不需要被同步写入。

值得注意的是,当我们以 O_SYNC 或 O_DSYNC 模式打开文件,并将其交给 Stream I/O API 时,调用 fwrite 时写入的数据会进入到 C library 的缓存中,当我们调用 fflush 时,数据才会被写出到持久化存储设备,有了 O_SYNC/O_DSYNC 时,在 fflush 之后,我们不需要再调用 fsync,因为数据会被同步写入到持久化存储设备中。

When Should You fsync?

判断是否需要 fsync,最重要的就是问自己:这些数据是否需要立即持久化?

  • 无需立即持久化

    • 临时数据

    • 可被重新生成的数据

  • 需要立即持久化

    • 事务数据

    • 更新用户的配置信息

Creating New Files

一个微妙的场景是,当你创建新的文件,你不仅需要 fsync 文件本身,还需要 fsync 文件夹,它的默认行为由文件系统来决定,你也可以在代码中确保这些 fsync 操作按自己的预期发生,同时提高代码的可移植性。

Overwriting Existing Files

另一个微妙的场景是,当你在覆盖某个文件时,系统发生故障,可能导致已有数据的丢失。避免这种问题的常见做法是:先将数据写入到一个临时文件中,保证它已经安全地持久化,然后将该临时文件重命名成原文件。这就保证了文件的原子更新操作,这样其它读者只可能读到旧文件或新文件,而不会读到一个中间状态的文件。

整个过程具体如下:

  1. create a new temp file (on the same file system!)

  2. write data to temp file

  3. fsync() the temp file

  4. rename the temp file to the appropriate name

  5. fsync() the containing directory

Checking For Errors

当执行 I/O 写操作时,数据会在应用地址空间和 kernel 地址空间中缓存,因此类似 write(), fflush() 调用只会被写入到缓存中,通常不会抛错。错误通常会在写入持久化设备时抛错,如 fsync(), msync() 以及 close() 等。因此,在这些调用的返回处检查错误是很有必要的。

Write-Back Caches

本小节介绍一些磁盘缓存的一般信息,以及操作系统对这些缓存的控制。这些内容不影响一般程序的构建,因此本小节的内容仅仅是为了让读者对这个话题有一个更深入的了解。

在持久化设备中的写回缓存 (write-back cache) 有很多种实现。我们在上面全文中的讨论都是以 volatile write-back cache 为基础,一旦发生断电,缓存数据全部丢失。然而,大部分存储设备可以被配置为 cache-less 或 write-through 模式,在这两种模式写,数据在未被安全持久化之前不会返回。一些外部存储设备阵列通常会支持 non-volatile 或 battery-backed write-cache,即使发生断电,缓存区的数据也不会丢失。

但这些在程序员看来都是透明的,对于程序员来说,最好直接假设存储设备中仅支持 volatile cache,防御地去编程。

参考