Linux 基础体系 · 第 54/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux Page Cache 与 Writeback:脏页、回写、fsync 和数据持久性

在 Linux 中,“程序调用 write() 成功”与“数据已经写入磁盘并且断电后仍然存在”是不同事件。中间至少隔着几层缓存、多个异步队列,以及文件系统和存储设备对故障的不同处理方式。

理解 Page Cache 和 Writeback,需要把以下概念连起来:

  1. 文件读写如何经过 Page Cache;
  2. 什么是干净页、脏页和回写页;
  3. 内核何时、为什么以及如何把脏页写回;
  4. write()close()sync()fsync()fdatasync()msync() 分别保证什么;
  5. 文件系统日志、块设备缓存和设备持久化之间的关系;
  6. 崩溃、断电和存储错误时,数据可能处于什么状态;
  7. 如何观察脏页、回写压力和同步延迟;
  8. 如何在应用层设计真正满足持久性要求的写入流程。

一、先建立正确的存储模型

1. Page Cache 是什么

Page Cache 是 Linux 为文件内容提供的内存缓存。文件内容通常按页组织,页大小在大多数 x86-64 系统上是 4 KiB,但不能把 4 KiB 当成所有系统的固定值;应通过 getconf PAGE_SIZEsysconf(_SC_PAGESIZE) 查询。

当进程执行普通的 buffered I/O 时,数据路径通常类似于:

用户缓冲区
    │ write()
    ▼
内核 Page Cache 中的文件页
    │ writeback
    ▼
文件系统映射与块层 Bio
    │
    ▼
块设备队列、设备缓存
    │
    ▼
稳定存储介质

读取时方向相反:

稳定存储介质
    ▲
块设备与文件系统
    ▲
Page Cache
    ▲
read()
    ▲
用户缓冲区

Page Cache 缓存的是文件内容页,而不是“某个进程独有的一块内存”。多个进程访问同一文件时,通常可以共享同一份缓存页。文件映射 mmap() 也通常使用同一套文件页缓存体系。

2. Page Cache 不等于“空闲内存被浪费”

Linux 会主动使用空闲内存缓存文件。free 输出中的 buff/cache 较大,并不自动表示内存泄漏,因为这些缓存通常可以在内存压力下被回收。

但缓存页可以处于不同状态:

  • clean page,干净页:内存中的内容与文件系统认为的后备存储内容一致,可以直接回收;
  • dirty page,脏页:进程修改了内存中的文件页,但修改尚未完成写回;
  • writeback page,回写页:内核已经提交该页进行 I/O,但 I/O 尚未完成;
  • unevictable 或被其他机制固定的页:不能按普通缓存页直接回收。

“缓存可以回收”主要针对干净页。脏页不能简单丢弃,否则会丢失尚未写出的修改。

3. 脏页的定义

假设文件 data.bin 的偏移 0~4095 已经被读入 Page Cache。进程执行:

write(fd, "abc", 3);

如果这个操作是普通 buffered I/O,内核通常会:

  1. 找到或创建覆盖文件偏移 0~2 的缓存页;
  2. 把用户空间的 3 个字节复制到该页;
  3. 标记该页为脏;
  4. 更新文件大小或相关元数据;
  5. write() 返回。

此时内存中的文件页已经变成:

Page Cache:abc...
稳定存储:  旧内容...

这就是脏页。脏页表示的是内存中的文件视图领先于后备存储,并不表示数据一定已经丢失,也不表示数据一定已经写入了设备。


二、普通 write() 的实际语义

1. write() 成功不代表持久化

普通文件描述符执行:

ssize_t n = write(fd, buf, len);

成功返回 n == len,通常表示:

  • 数据已经从用户缓冲区复制到内核,或者已经被底层 I/O 接受;
  • 对 buffered I/O 而言,数据通常已经进入 Page Cache;
  • 内核已经接受了这次写请求。

它通常不保证

  • 数据已经完成文件系统写回;
  • 文件系统元数据已经持久化;
  • 块设备的易失性写缓存已经刷新;
  • 断电后数据一定存在。

因此下面的代码不能把 write() 的返回值理解为“数据已经安全保存”:

int fd = open("record.dat", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
    perror("open");
    return 1;
}

const char data[] = "important record\n";

if (write(fd, data, sizeof(data) - 1) != (ssize_t)(sizeof(data) - 1)) {
    perror("write");
    return 1;
}

close(fd);  // close() 成功不等于数据已经稳定持久化

close() 释放文件描述符引用。对于普通文件,close() 通常不会像 fsync() 一样要求调用者等待所有文件数据和必要元数据落到稳定存储。发生写回错误时,错误可能在后续的 write()fsync(),甚至 close() 中才暴露,具体取决于内核、文件系统和错误传播路径。

2. write() 也不保证一次写完全部数据

对普通文件,阻塞式 write() 通常可以写入请求长度,但仍然必须检查返回值。磁盘满、配额耗尽、信号中断、文件系统错误或资源限制都可能导致短写或失败。

可靠的写循环至少应处理:

ssize_t write_all(int fd, const void *buf, size_t len)
{
    const char *p = buf;
    size_t done = 0;

    while (done < len) {
        ssize_t n = write(fd, p + done, len - done);

        if (n > 0) {
            done += (size_t)n;
            continue;
        }

        if (n == -1 && errno == EINTR) {
            continue;
        }

        return -1;
    }

    return 0;
}

这个函数只保证数据被 write() 接受,不保证持久化。持久化仍需显式同步。


三、脏页如何进入 Writeback

1. Writeback 是什么

Writeback 是内核把脏文件页提交到底层文件系统和块设备的过程。中文通常称为“回写”或“写回”。

写回不是单个函数调用,而是一套异步机制,涉及:

  • 脏页统计;
  • 后台回写线程;
  • 文件系统地址空间;
  • 块层 I/O;
  • 脏页限流;
  • I/O 完成和错误记录。

现代 Linux 中,回写通常由内核的 per-backing-device writeback 线程执行,线程名常见为:

[kworker/...]

或包含 flush-*writeback 相关信息的内核工作线程。不要把旧资料中的 pdflush 机制直接套用到现代内核;Linux 的回写架构已经演进。

2. 脏页产生后的典型路径

一次普通文件写入可以抽象为:

sequenceDiagram
    participant P as 进程
    participant PC as Page Cache
    participant WB as Writeback
    participant FS as 文件系统
    participant BLK as 块层
    participant DEV as 存储设备

    P->>PC: write() 复制数据并标记脏页
    P-->>P: write() 返回,进程继续运行
    WB->>PC: 扫描达到条件的脏页
    WB->>FS: 提交文件页写回
    FS->>BLK: 生成并提交 Bio
    BLK->>DEV: 请求写入设备
    DEV-->>BLK: I/O 完成
    BLK-->>FS: 完成状态
    FS-->>PC: 清除或更新脏页状态

其中有一个重要区别:

  • write() 返回:数据通常已经进入内核缓存;
  • writeback 提交:内核已经发起写 I/O;
  • I/O 完成:设备报告请求完成;
  • 稳定持久化:设备保证即使断电也不会丢失该写入。

这四个状态不能混为一谈。

3. 触发回写的主要条件

内核通常在以下情况下推动脏页回写:

脏页达到后台阈值

系统可以通过 vm.dirty_background_ratiovm.dirty_background_bytes 设置后台回写启动的阈值。达到阈值后,内核会让后台回写线程积极处理脏页。

更高版本和具体发行版可能更倾向于使用绝对字节阈值,因为基于可用内存比例的阈值在内存规模不同的机器上表现差异较大。

脏页达到进程限流阈值

当脏页继续增加并达到 vm.dirty_ratiovm.dirty_bytes 等阈值时,产生脏页的进程可能被阻塞,参与回写,直到脏页数量下降。这种机制称为 dirty throttling。

因此,大量顺序写入程序可能出现这样的延迟变化:

初始阶段:write() 很快,只是在填充 Page Cache
脏页接近阈值:后台写回开始
脏页达到限制:write() 延迟突然升高,进程被限流

这不是 write() 本身随机变慢,而是前期积累的写回工作开始由写入进程共同承担。

脏页过期

系统还会根据 vm.dirty_expire_centisecs 等参数处理存活时间较长的脏页。这个参数是内核回写策略的一部分,不应理解为“超过该时间就一定已经持久化”。

内存回收压力

当内存回收需要释放文件页,而某些页是脏页时,内核不能直接丢弃它们,往往需要先写回。于是内存压力也可能间接触发回写。

4. 回写并不等于同步写

后台回写是异步过程。进程不一定等待每一个页完成写入:

应用写入 → 形成脏页 → 继续工作
                    ↘ 内核稍后回写

这能提高吞吐量,因为多个小写入可以合并、排序并批量提交。但代价是:

  • 断电时可能丢失尚未持久化的脏页;
  • 突然高并发回写可能造成延迟抖动;
  • 文件内容和相关元数据可能在不同时间点持久化;
  • 存储设备错误可能在写入很久之后才反馈给应用。

四、脏页限流与性能变化

1. 为什么 Page Cache 会提高写入吞吐

假设程序进行 1 MiB 的顺序写入:

程序 → 内存复制 → 返回

相比让程序直接等待设备完成:

程序 → 文件系统 → 块层 → 设备 → 等待完成

第一种方式可以把设备延迟隐藏在后台,程序短时间内拥有更高吞吐。

Page Cache 还可以带来:

  • 相邻写入合并;
  • 减少小 I/O;
  • 复用热文件页;
  • 将多个进程的写入交给统一的回写策略。

2. 为什么后期会突然变慢

设:

  • D(t) 表示系统中尚未完成回写的脏页字节数;
  • W(t) 表示应用产生脏数据的速率;
  • B(t) 表示实际完成稳定写回的速率。

粗略地说:

dD/dt = W(t) - B(t)

如果一段时间内:

W(t) > B(t)

那么 D(t) 会增长。当 D(t) 达到限制,内核必须减慢应用写入,使平均速率满足:

W(t) ≤ B(t)

因此,Page Cache 可以暂时把“应用写入速率”与“设备写入速率”分离,但不能长期创造设备带宽。设备速度不足时,延迟最终会反馈到应用。

3. dirty_* 参数不是持久性开关

以下参数影响的是回写时机和限流:

sysctl vm.dirty_background_ratio
sysctl vm.dirty_background_bytes
sysctl vm.dirty_ratio
sysctl vm.dirty_bytes
sysctl vm.dirty_expire_centisecs
sysctl vm.dirty_writeback_centisecs

它们不能替代 fsync(),也不能把“等待一段时间”变成持久性保证。

例如,把 vm.dirty_expire_centisecs 设置为较小值,可能让脏页更早进入回写,但仍然存在:

  • 回写排队;
  • 文件系统提交延迟;
  • 设备缓存;
  • 设备或控制器故障;
  • 文件系统元数据尚未同步。

生产环境修改这些参数还可能改变全局内存压力和所有应用的 I/O 行为,应先进行基准测试,并准备恢复原值。


五、fsync()fdatasync()sync() 的区别

1. fsync(fd)

fsync() 请求把文件描述符对应文件的已修改数据和必要元数据刷新到存储系统,并等待相关 I/O 完成。

典型用法:

if (fsync(fd) == -1) {
    perror("fsync");
    /*
     * 此时不能假设数据已经持久化。
     * 应根据业务决定重试、报告失败或终止流程。
     */
}

fsync() 的语义重点是:调用者等待该文件的同步操作完成

但还要注意几个边界:

  1. 它只针对指定文件描述符对应的文件;
  2. 它不自动同步包含该文件的目录;
  3. 它不自动同步同一进程打开的其他文件;
  4. 它不能修复已经发生的硬件故障;
  5. 它的最终持久性仍依赖文件系统、块层、设备和存储介质是否正确实现 flush/FUA 等机制。

Linux 块层通常会把同步请求转换为设备能够理解的缓存刷新或强制单元访问请求,但设备固件、RAID 控制器和虚拟化存储必须正确遵守协议。若设备谎报已完成持久化,操作系统无法从软件层面补救。

2. fdatasync(fd)

fdatasync() 主要保证文件数据以及恢复文件数据所必需的元数据同步完成,但不要求所有不影响数据读取的元数据都同步。

例如,文件访问时间等元数据通常不是读取文件内容所必需的,因此可能不必与 fdatasync() 一起刷新。

它适合某些只关心文件内容的场景:

if (fdatasync(fd) == -1) {
    perror("fdatasync");
}

但不能简单断言 fdatasync() 一定比 fsync() 快。实际差异取决于文件系统、写入模式、元数据变化和设备行为。

3. sync()

sync() 请求把系统范围内的脏缓冲区提交写回。它影响范围很大,通常不适合普通应用用来实现单个文件的持久化协议。

sync

它可能导致全系统 I/O 压力,并且“调用 sync”仍然不能脱离硬件正确性讨论。应用通常应使用针对目标文件的 fsync()fdatasync()

4. syncfs(fd)

syncfs() 针对文件描述符所在的文件系统执行同步,范围介于单文件和全系统之间。它适合某些需要同步整个挂载文件系统的管理操作,但不是普通记录写入的首选接口。


六、O_SYNCO_DSYNC 与同步写入

可以在打开文件时使用:

int fd = open("record.dat",
              O_WRONLY | O_CREAT | O_APPEND | O_DSYNC,
              0644);

1. O_SYNC

O_SYNC 使每次写操作都按同步 I/O 语义完成,通常要求数据和相关元数据同步到足够稳定的存储状态。

2. O_DSYNC

O_DSYNC 更偏向数据同步:每次写入完成时,保证读取该数据所需的数据和元数据已经同步。它通常不要求无关元数据同步。

两者都会把同步等待放入每次写入路径,可能显著提高延迟,尤其是:

许多小 write()
    → 许多同步提交
    → 设备频繁 flush
    → 延迟升高

实际性能还取决于文件系统和设备是否能够合并请求、是否支持高效 FUA,以及写入是否顺序化。

同步标志不是“永远不丢数据”的绝对保证。它仍然依赖:

  • 文件系统实现;
  • 块设备是否正确执行 flush;
  • RAID、虚拟磁盘和云存储是否提供真实持久性;
  • 应用是否正确处理返回值和错误。

七、文件系统日志不等于文件数据已经持久化

1. 日志主要解决什么问题

很多文件系统使用 journaling 来保护元数据更新。例如创建文件、修改目录项、分配数据块等操作可能写入日志。

日志的核心目标通常是保证崩溃后文件系统结构能够恢复到一致状态:

崩溃前:
目录项、inode、块分配信息存在部分更新

崩溃恢复:
重放或回滚日志,使文件系统结构保持可解释状态

这并不自动保证应用最近写入的每一字节数据都已经到达稳定介质。

“文件系统没有损坏”和“应用事务已经提交”是两个不同目标:

  • journaling 关注文件系统结构一致性;
  • fsync() 关注指定文件的写入同步;
  • 应用事务还可能要求多个文件、目录项和版本标记以固定顺序持久化。

2. data=ordered 等策略不能替代应用同步

以 ext4 等文件系统为例,不同 journaling 模式会影响数据和元数据的写入关系,但不能把具体挂载模式直接当成业务持久性协议。

即使文件系统采用一种通常先写数据、后提交元数据的策略,也不表示:

write() 返回
→ 数据已经断电安全

应用仍需在关键提交点调用同步接口。


八、真正的“持久化”至少有三个层次

可以把写入状态分成以下层次:

层次一:应用可见

写入完成后,当前进程或其他读取者可以读到新内容:

Page Cache 已更新

这通常由普通 write()mmap() 修改实现。

层次二:内核已提交写回

脏页已经被文件系统和块层处理,设备报告写请求完成:

writeback 完成

这比只进入 Page Cache 更进一步,但如果设备还有易失性写缓存,不能直接等同于断电安全。

层次三:稳定介质持久化

设备保证在符合故障模型的情况下,数据不会因为掉电而丢失:

设备缓存已刷新,或写入使用了可靠的持久化机制

fsync() 的目标是请求从层次一推进到层次三,但能否真正达到层次三,依赖整个 I/O 栈的正确实现。


九、缓存设备和断电风险

存储设备可能包含多个缓存层:

Page Cache
  → 文件系统缓存或事务日志
  → 块设备队列
  → SSD 控制器 DRAM / HDD 写缓存
  → NAND Flash 或磁盘介质

如果设备使用易失性 DRAM 写缓存,却没有电容、超级电容或正确的掉电保护,那么设备报告“写入完成”后突然断电,数据仍可能丢失。

因此,以下说法都不严谨:

  • write() 返回了,所以数据安全”;
  • close() 成功了,所以数据安全”;
  • “文件系统有日志,所以应用数据安全”;
  • “Page Cache 已经写回,所以一定断电不丢”;
  • “SSD 比硬盘快,所以不需要 fsync()”。

更准确的表述是:

fsync() 要求 Linux 将指定文件的修改同步到存储系统的持久化边界;这个边界最终由文件系统、块层、设备协议、控制器和介质共同决定。

在虚拟机、网络块设备、云盘、RAID 控制器环境中,宿主机和后端存储是否正确实现 flush 也属于持久性边界的一部分。


十、完整的安全替换写入流程

假设要把新配置完整写入 config.new,再替换旧的 config。仅仅调用 rename() 还不够。

1. 为什么直接覆盖有风险

直接执行:

open("config", O_WRONLY | O_TRUNC);
write(...);
fsync(...);

如果写入过程中进程崩溃,可能留下截断但不完整的配置文件。即使不考虑崩溃,覆盖写还可能让读者看到中间状态,除非应用自行设计并发协议。

2. 临时文件加 rename()

更常见的流程是:

1. 在目标目录创建临时文件
2. 写入完整内容
3. fsync(临时文件)
4. rename(临时文件, 正式文件名)
5. fsync(目录)

原因分别是:

  • 临时文件隔离了未完成内容;
  • fsync(临时文件) 保证文件内容和必要元数据先完成同步;
  • rename() 在同一文件系统内通常是原子的目录项替换;
  • fsync(目录) 保证目录项变化本身完成同步。

形式化地看,目标是建立以下持久化顺序:

文件内容持久化
    happens-before
目录项从旧文件切换到新文件
    happens-before
目录项同步完成

如果只执行第 4 步:

write temp
fsync temp
rename temp config
进程崩溃

目录项替换可能还没有持久化。重启后可能仍看到旧名称绑定,也可能出现与预期不同的目录状态,具体取决于文件系统和崩溃时序。

3. 可运行的 C 示例

下面的示例展示基本流程。它面向单进程或已有并发控制的场景,不包含锁、权限继承、符号链接防护等生产级安全策略。

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
    const char *dir = "/tmp";
    const char *tmp = "/tmp/config.new";
    const char *dst = "/tmp/config";

    const char content[] =
        "version=2\n"
        "enabled=true\n";

    int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
    if (fd == -1) {
        perror("open temporary file");
        return 1;
    }

    size_t off = 0;
    while (off < sizeof(content) - 1) {
        ssize_t n = write(fd, content + off,
                          (sizeof(content) - 1) - off);
        if (n > 0) {
            off += (size_t)n;
        } else if (n == -1 && errno == EINTR) {
            continue;
        } else {
            perror("write");
            close(fd);
            return 1;
        }
    }

    if (fsync(fd) == -1) {
        perror("fsync temporary file");
        close(fd);
        return 1;
    }

    if (close(fd) == -1) {
        perror("close temporary file");
        return 1;
    }

    if (rename(tmp, dst) == -1) {
        perror("rename");
        return 1;
    }

    int dfd = open(dir, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
    if (dfd == -1) {
        perror("open directory");
        return 1;
    }

    if (fsync(dfd) == -1) {
        perror("fsync directory");
        close(dfd);
        return 1;
    }

    if (close(dfd) == -1) {
        perror("close directory");
        return 1;
    }

    return 0;
}

编译和运行:

cc -std=c11 -Wall -Wextra -O2 atomic_replace.c -o atomic_replace
./atomic_replace
cat /tmp/config

预期输出:

version=2
enabled=true

这个流程的持久性意义不是“任何灾难下绝对不丢”,而是:在文件系统和存储设备正确实现同步语义、目标文件和目录位于同一文件系统、调用返回成功且没有后续硬件故障的前提下,显著降低崩溃后出现半写文件或目录项未落盘的风险。


十一、为什么 rename() 还需要同步目录

rename() 的“原子性”和“持久性”是两个维度。

原子性

同一文件系统内,观察者通常不会看到目录项处于“半个旧名字、半个新名字”的状态。它看到的是替换前或替换后的某个完整状态。

持久性

进程返回后立即断电,目录项更新是否已经到达稳定存储,则需要目录同步来确认。

因此:

rename() 原子 ≠ rename() 已持久化

类似地,创建、删除、链接和重命名目录项时,若业务要求重启后目录结构一定反映该操作,通常需要对相关目录调用 fsync()


十二、mmap() 修改文件时怎么办

mmap() 把文件映射到进程地址空间。程序直接修改映射区:

char *p = mmap(NULL, len, PROT_READ | PROT_WRITE,
               MAP_SHARED, fd, 0);

p[0] = 'X';

这次修改通常先发生在 Page Cache 对应页中,并把页标记为脏。因为没有显式调用 write(),应用仍然需要显式同步。

可以使用:

if (msync(p, len, MS_SYNC) == -1) {
    perror("msync");
}

MS_SYNC 要求等待同步完成;MS_ASYNC 只安排异步同步,不等待完成。

msync()fsync() 的职责边界需要谨慎处理:

  • msync() 针对映射范围;
  • fsync() 针对文件描述符对应文件;
  • 如果应用还要求文件大小、目录项或其他元数据持久化,仍需对相应对象同步;
  • 映射区长度、页边界和文件大小必须正确处理;
  • MAP_PRIVATE 的修改是写时复制,不会作为普通文件内容写回原文件。

MAP_SHARED 映射,常见的稳妥流程是:

修改映射
→ msync(..., MS_SYNC)
→ 必要时 fsync(fd)

具体是否需要两者取决于内核和文件系统语义、应用对范围和元数据的要求。若持久性是核心目标,不能仅凭“映射已经修改”推断设备已完成稳定写入。


十三、O_DIRECT 不是自动持久化

O_DIRECT 的主要目标是绕过或减少 Page Cache 参与,使用户缓冲区和文件系统之间进行更直接的 I/O。它主要解决缓存复制、缓存污染或特定数据库 I/O 管理问题。

它不等于:

O_DIRECT = 已经持久化

使用 O_DIRECT 仍可能遇到:

  • 用户缓冲区、文件偏移和长度的对齐要求;
  • 文件系统对 direct I/O 的特殊限制;
  • I/O 完成与设备稳定介质写入之间的差异;
  • 仍需使用 fsync() 或打开时的同步标志;
  • buffered I/O 与 direct I/O 混用时的缓存一致性和覆盖范围问题。

例如:

int fd = open("data.bin", O_WRONLY | O_CREAT | O_DIRECT, 0644);

并不能保证这次 write() 返回时断电不丢。它只改变数据经过 Page Cache 的方式,不取消文件系统和设备的持久性问题。


十四、sync_file_range() 的边界

sync_file_range() 可以对文件的某个范围安排写出或等待部分写出,常用于控制大文件写入节奏。

但它不应被当作 fsync() 的替代品。其语义和标志组合比较细,某些模式只启动写回,不等待完成;而且它不等价于对文件元数据和设备持久化边界执行完整同步。

如果需求是:

这一批数据提交后,崩溃或断电时必须保留

优先使用经过明确设计的 fsync()fdatasync() 或同步打开标志,而不是仅依赖 sync_file_range()


十五、删除 Page Cache 与 drop_caches

可以查看并手动要求系统回收部分缓存:

sync
echo 3 | sudo tee /proc/sys/vm/drop_caches

常见值为:

  • 1:尝试回收 page cache;
  • 2:尝试回收 slab;
  • 3:两者都尝试。

sync 放在前面是为了先提交脏数据,避免把仍有修改的页误解为可以直接丢弃。但必须明确:

  • drop_caches 不是持久化机制;
  • 它不是生产环境通用性能优化;
  • 频繁执行会破坏缓存热度,导致后续 I/O 变慢;
  • 需要权限;
  • 它只影响当前内核实例的缓存回收,不会消除设备缓存和远端存储缓存问题。

测试冷缓存读取时可以使用它,但测试过程应记录系统负载、存储状态和缓存命中率,否则结果很容易失真。


十六、如何观察 Page Cache、脏页和回写

1. 查看系统级统计

grep -E '^(Cached|Buffers|Dirty|Writeback|WritebackTmp|MemAvailable):' \
    /proc/meminfo

典型字段含义:

  • Cached:主要是可回收的文件缓存统计,但具体组成不能简单等同于所有 Page Cache;
  • Dirty:当前被标记为脏的内存页;
  • Writeback:正在进行回写的内存页;
  • MemAvailable:内核估算的可用内存;
  • Buffers:块设备等相关的内核缓冲统计,现代系统中通常比 Cached 小,但不能据此推导完整 I/O 状态。

这些统计是系统级快照,不足以说明某个具体进程造成了多少脏页。

2. 使用 vmstat

vmstat 1

重点观察:

  • bi:块设备输入;
  • bo:块设备输出;
  • siso:Swap 输入输出;
  • wa:CPU 等待 I/O 的比例;
  • freebuffcache:内存概况。

vmstat 中的 bo 上升表示存在块设备输出,但不等价于某个进程已经完成持久化。它也不能单独区分普通回写、日志写入和其他块 I/O。

3. 查看进程 I/O

cat /proc/$$/io

常见字段包括:

  • write():进程发起的写系统调用相关计数;
  • syscw:写系统调用次数;
  • write_bytes:实际提交到存储层的写入字节数统计,具体含义受内核和文件系统影响。

如果程序大量执行 write(),但 write_bytes 增长滞后,可能说明数据暂时停留在 Page Cache;不过这些字段不能代替 fsync() 的成功判断。

4. 用 strace 观察同步点

strace -T -e trace=openat,write,fdatasync,fsync,close \
    ./your_program

-T 会显示系统调用耗时。例如:

write(3, "important record\n", 17) = 17 <0.000021>
fsync(3)                              = 0 <0.012345>
close(3)                              = 0 <0.000018>

这个结果可以说明 fsync() 在该次运行中等待了约 12 ms,但不能把这一次延迟当成固定性能指标。实际耗时会受到设备队列、文件系统日志、并发 I/O、缓存状态和故障恢复路径影响。


十七、如何区分“写回慢”和“设备持久化慢”

一个简单实验可以比较普通写入与同步写入,但实验结果只能用于理解机制,不能代表生产系统保证。

time dd if=/dev/zero of=/tmp/page-cache-test.bin \
    bs=1M count=1024 status=progress

time dd if=/dev/zero of=/tmp/fsync-test.bin \
    bs=1M count=1024 conv=fsync status=progress

在 GNU dd 中,conv=fsync 会在结束前同步输出数据和元数据。通常可能观察到:

  • 第一条命令较快返回,因为大量数据先进入 Page Cache;
  • 第二条命令结束前会等待同步,因此耗时更接近设备和文件系统的提交成本;
  • 第一条命令结束后,后台仍可能存在脏页回写。

可以在执行过程中另开终端观察:

watch -n 0.5 'grep -E "^(Dirty|Writeback):" /proc/meminfo'

但要注意:

  • /tmp 可能是 tmpfs,此时实验并不经过普通块设备;
  • 容器可能没有独立的存储语义;
  • 虚拟机、云盘和 RAID 会改变结果;
  • 文件可能被已有缓存影响;
  • dd 的输出路径和挂载选项必须先确认。

检查 /tmp 类型:

findmnt -T /tmp -o TARGET,FSTYPE,OPTIONS

如果是 tmpfs,写入主要消耗内存,并不适合测试普通磁盘 Page Cache 与块设备回写。


十八、错误处理:同步失败不能被忽略

同步调用可能失败,例如:

  • 文件系统空间耗尽;
  • 用户配额耗尽;
  • 底层设备 I/O 错误;
  • 文件系统检测到错误;
  • 网络文件系统连接异常;
  • 设备被移除或发生介质故障。

错误可能不是在最初的 write() 处立即返回。现代 Linux 会记录异步写回错误,并尝试在后续相关系统调用中报告。应用必须检查:

if (write_all(fd, data, len) == -1) {
    perror("write_all");
    return -1;
}

if (fdatasync(fd) == -1) {
    perror("fdatasync");
    return -1;
}

if (close(fd) == -1) {
    perror("close");
    return -1;
}

如果 fsync()fdatasync() 失败,不应继续把这条记录标记为“已提交”。正确处理方式取决于业务:重试、切换设备、进入只读状态、报告事务失败或停止服务都可能合理,但忽略错误通常是不合理的。


十九、崩溃路径中的典型反例

反例一:只调用 write()

write()
进程返回成功
机器断电

可能结果:

  • 新数据仍未离开 Page Cache;
  • 文件只保留部分写入;
  • 文件大小或内容与预期不一致;
  • 文件系统结构仍然可恢复,但应用记录丢失。

反例二:调用 close(),不调用 fsync()

write()
close()
机器断电

close() 释放描述符,不应被当作持久化提交点。写回可能已经完成,也可能仍在队列中,不能由代码逻辑保证。

反例三:写临时文件后直接 rename()

write(temp)
fsync(temp)
rename(temp, target)
机器断电

临时文件内容可能已经安全,但目录项替换不一定已经持久化。重启后目标名字可能仍指向旧文件。

反例四:使用 O_DIRECT 后省略同步

open(..., O_DIRECT)
write()
机器断电

绕过 Page Cache 不等于设备已执行持久化屏障,仍可能丢失设备缓存中的数据。

反例五:认为 journaling 保护业务事务

更新 account 文件
更新 ledger 文件
进程崩溃

即使文件系统恢复后结构完全一致,也可能只有其中一个文件的更新已经持久化。跨文件事务需要应用层日志、数据库事务或明确的提交协议。


二十、持久化协议的设计顺序

应用真正需要的通常不是“尽快写”,而是定义一个可恢复的提交顺序。

例如,追加一条日志记录:

1. 写入记录内容
2. fdatasync(日志文件)
3. 在内存中标记记录已提交

如果还需要让目录中的新文件名在重启后存在:

1. 写入新文件
2. fsync(新文件)
3. rename()
4. fsync(父目录)
5. 对外宣布操作完成

“对外宣布完成”必须放在同步成功之后。否则系统可能向客户端返回成功,但随后断电时数据并未达到承诺的持久化边界。

对于高吞吐系统,可以批量提交:

多个请求写入 Page Cache
        ↓
一次 fdatasync()
        ↓
这一批请求共同提交

这会降低每条记录的同步次数,但改变了语义:在 fdatasync() 返回前,这一批记录都不能分别声称已经持久化。批量大小应由业务允许的丢失窗口和设备延迟共同决定,而不是只按吞吐量优化。


二十一、与虚拟内存、Swap 和 OOM 的关系

Page Cache 属于文件缓存,匿名页则通常来自进程堆、栈和匿名 mmap()。两者在内存压力下的处理方式不同:

  • 干净文件页可以丢弃,需要时重新从文件读取;
  • 脏文件页需要先回写;
  • 匿名脏页没有文件后备存储,可能需要写入 Swap;
  • 当回收、Swap 和回写仍无法释放足够资源时,可能触发 OOM。

因此,“系统有很多缓存”与“系统即将 OOM”不能直接画等号;而“脏页很多”也不等于“匿名内存泄漏”。应结合:

free -h
grep -E '^(MemAvailable|Cached|Dirty|Writeback|SwapFree|SwapTotal):' \
    /proc/meminfo
vmstat 1

分析当前是:

  • 文件缓存占用较多但可回收;
  • 写入速率超过设备回写速率;
  • Swap 压力;
  • 进程匿名内存增长;
  • 还是文件系统或设备发生 I/O 阻塞。

二十二、与块 I/O 栈的关系

Page Cache 之下,文件系统会把文件偏移映射成块设备位置,并生成块 I/O 请求。现代 Linux 中通常以 Bio 作为块层请求描述的一部分,之后经过:

文件系统
→ Bio
→ 块设备队列
→ I/O 调度器
→ 驱动
→ 设备

Writeback 关注的是“哪些文件页需要写回以及何时写回”;块层则关注“这些块请求如何排队、合并和发送”。

这解释了两个现象:

  1. Page Cache 层面写入很快,不代表设备队列没有积压;
  2. fsync() 可能等待文件系统提交、日志事务、块队列和设备 flush,因此延迟远高于一次内存复制。

Direct I/O 主要改变 Page Cache 参与方式;同步标志主要改变完成条件。两者是不同维度,不能互相替代。


二十三、生产环境中的取舍

吞吐优先

普通 buffered I/O 加后台回写通常能获得较高吞吐,但断电时存在尚未同步数据丢失窗口。适合可重建缓存、临时结果或允许丢失的批处理输出。

延迟和持久性优先

在业务提交点使用 fdatasync()fsync(),必要时使用 O_DSYNC。这会把设备延迟暴露给请求路径,必须通过批量提交、顺序日志和合适的存储设备降低成本。

需要原子替换

使用临时文件、同步文件、rename()、同步父目录的流程。还要处理临时文件清理、权限、并发访问和磁盘空间。

需要跨文件事务

不要依赖多个独立 fsync() 就声称具备事务性。应使用数据库事务、应用日志、双版本文件、提交标记或其他可恢复协议,并明确恢复时如何判断一批更新是否完整。

需要绕过 Page Cache

只有在确实需要自行管理缓存、避免缓存污染或配合数据库缓冲池时才考虑 O_DIRECT。它会增加对齐、并发和错误处理复杂度,不会自动解决持久性问题。


二十四、核心结论

Page Cache 解决的是文件访问的缓存和吞吐问题;Writeback 负责把脏页异步提交到文件系统和块设备;fsync()fdatasync() 和同步打开标志用于在明确的提交点等待写入同步完成。

必须区分以下事实:

write() 成功
    ≠ 数据已写回
    ≠ 文件系统事务已提交
    ≠ 设备缓存已刷新
    ≠ 断电后一定存在

一个需要可靠持久化的应用,应明确回答三个问题:

  1. 哪些数据算“已提交”;
  2. 提交时需要同步文件、目录,还是多个对象;
  3. 存储设备和虚拟化环境提供的持久性边界是什么。

只有把这些问题落实为正确的调用顺序、错误处理和恢复协议,Page Cache 带来的性能优势才不会与数据持久性要求发生误解。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。