土法炼钢兴趣小组的算法知识备份

fsync 失败的真相:当它返回错误,你的数据可能已经没了

文章导航

分类入口
linuxstorage
标签入口
#fsync#writeback#errseq#ext4#xfs#postgresql#data-integrity#kernel

源码下载

本文相关源码已整理,共 2 个文件。

打开下载目录 →

目录

很多人对 fsync() 的理解停在这一句:调用它,脏数据就落盘,返回 0 就安全了。这句话在“返回 0”时大致成立,问题出在它“返回 -1”的时候。

fsync() 报错之后该怎么办?绝大多数人的本能反应是:再试一次(retry)。这恰恰是 2018 年 PostgreSQL 社区发现的、被称作 “fsyncgate” 的事故根源——在 Linux 上,对很多文件系统而言,fsync() 失败后重试不仅救不了数据,还会让你以为数据安全了,然后亲手把它扔掉。

先把结论摆在前面:

  1. fsync() 失败时,对应的脏页会被标记为 clean。 这意味着内核认为这页“没有待写数据了”,于是它再也不会被写回,并可能随时因内存压力被丢弃。你写的新数据就这样消失了。
  2. 正因为页已经 clean,第二次 fsync() 会返回 0(成功)——但磁盘上仍是旧数据。 重试 fsync() 得到的“成功”是假的。
  3. Linux 4.13 的 errseq_t 改造修复的是“错误上报”,不是“数据恢复”。 它保证错误至少被每个打开该文件的 fd 看见一次,但脏页照样被丢,丢了的数据找不回来。
  4. 不同文件系统失败行为不一致。 ext4 ordered、ext4 data、XFS、Btrfs 在“错误何时报、页里留什么、什么情况下文件系统直接挂掉”上各不相同,POSIX 对此故意含糊。
  5. 应用层目前没有“重试 fsync”这条出路。 PostgreSQL 的应对是:fsync() 失败就 PANIC、崩溃重启、用 WAL 重放,而不是重试。

下面这篇不重复 数据完整性:从 fsync 到端到端校验 里讲过的“正常路径”(写屏障、断电、静默损坏),只盯住一件被普遍误解的事:fsync() 报错那一刻,内核和文件系统到底对你的数据做了什么。 而且不止讲结论——本文在内核 6.6 上用 device-mapper 注入一个单块写故障,把这个行为亲手复现出来。


一、fsync() 承诺了什么,又没承诺什么

write() 返回成功,只代表数据进了内核页缓存(page cache),是脏页;fsync() 的职责是把这些脏页刷到设备并等设备确认。返回 0 时,POSIX 保证数据已持久化。这部分是大家熟悉的,数据完整性那篇 已经讲透。

关键在失败语义。POSIX 对 fsync() 失败后的状态规定得极其克制,原文只说:发生错误时,未完成的 I/O 操作不保证已经完成。它没有回答几个要命的问题:

POSIX 故意留白,是为了掩盖各操作系统实现的差异。结果就是:应用开发者按“想当然”的语义写代码,而真实内核的行为和想当然差得很远。这种“规范含糊 + 实现各异”的组合,正是 fsyncgate 能潜伏二十年的土壤。


二、2018 fsyncgate:PostgreSQL 把 fsync() 用错了二十年

2018 年 3 月,PostgreSQL 开发者 Craig Ringer 在 pgsql-hackers 邮件列表发了一封标题很吓人的邮件:《PostgreSQL’s handling of fsync() errors is unsafe and risks data loss at least on XFS》。事情的链条是这样的。

PostgreSQL 用预写日志(Write-Ahead Logging,WAL)保证事务持久性:事务提交时先把变更写进 WAL 并 fsync,然后才告诉客户端成功。为了不让 WAL 无限增长,它周期性地做 checkpoint,把脏页刷进各个表文件,对这些文件 fsync,确认全部落盘后,才回收(截断)对应的 WAL 段。

问题出在 checkpoint 的 fsync 上。把表文件写回时如果设备报错,PostgreSQL 当时的处理是重试 fsync。而正如下一节会看到的,在 Linux 上失败的脏页已经被标记 clean,于是重试的 fsync 发现“没有脏页要写”,直接返回成功。PostgreSQL 信了这个“成功”,继续截断 WAL——此时新数据既没真正写进表文件,又因为 WAL 被截断而失去了重放的依据,数据就此丢失。

雪上加霜的是错误上报本身也不可靠:负责 fsync 的进程(checkpointer)可能根本不是当初写出脏数据、遇到错误的那个进程;在老内核上,错误甚至可能在没人“接住”的情况下被悄悄清掉。Tomas Vondra 后来在 FOSDEM 2019 上专门做了个演讲,标题就叫《PostgreSQL vs. fsync:我们是怎么把 fsync 用错二十年的》。

这件事的连锁反应不止 PostgreSQL:MySQL/InnoDB、WiredTiger/MongoDB 等也都因此审查并修改了自己的 fsync 失败处理逻辑。

那 Linux 内核到底做了什么,才让“重试 fsync”变成数据杀手?与其转述,不如亲手复现一次。


三、亲手复现:让一个数据块写失败

要触发这个行为,得制造一次精确的写故障:只让某个文件的一个数据块写失败,而让 journal、metadata 的写正常。如果粗暴地让所有写都失败,ext4 会因为 journal 写失败直接 abort、把文件系统重挂成只读——那是另一条失败路径,不是我们要看的“静默丢数据”。

device-mapper 的 error 目标可以把一段扇区映射成“读写都报错”。思路是:用 linear 把整块设备正常映射,只把目标文件那一个 4KB 数据块对应的扇区单独映射到 error

3.1 一个最小的探针

先写个程序:在已有文件上写 4KB 新数据,连续 fsync 两次,分别打印返回值和 errno

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

int main(int argc, char **argv)
{
    int fd = open(argv[1], O_RDWR);
    if (fd < 0) { perror("open"); return 1; }

    char buf[4096];
    memset(buf, 'N', sizeof(buf));            /* 新数据全是 'N' */
    if (pwrite(fd, buf, sizeof(buf), 0) != (ssize_t)sizeof(buf)) {
        perror("pwrite"); return 1;
    }

    int r1 = fsync(fd); int e1 = errno;
    printf("fsync #1: ret=%d errno=%d (%s)\n", r1, r1 ? e1 : 0,
           r1 ? strerror(e1) : "OK");

    errno = 0;
    int r2 = fsync(fd); int e2 = errno;
    printf("fsync #2: ret=%d errno=%d (%s)\n", r2, r2 ? e2 : 0,
           r2 ? strerror(e2) : "OK");

    close(fd);
    return 0;
}

3.2 搭故障注入环境

下面这段脚本:建一个 64MB 的回环设备,用 linear 映射后格式化 ext4,写入基线数据 'O'(OLD)并 sync;用 filefrag 定位 data.bin 第一个数据块的物理扇区,把这 8 个扇区(4KB)单独重映射到 error;然后跑探针;最后恢复成全 linear、丢掉页缓存、重新挂载,读回磁盘上真实的内容。

#!/usr/bin/env bash
set -u
IMG=disk.img; DM=fsyncdemo; MNT=mnt; LOOP=""
cleanup(){ mountpoint -q "$MNT" && umount "$MNT" 2>/dev/null
           dmsetup remove "$DM" 2>/dev/null
           [ -n "$LOOP" ] && losetup -d "$LOOP" 2>/dev/null; return 0; }
trap cleanup EXIT; cleanup; mkdir -p "$MNT"

dd if=/dev/zero of="$IMG" bs=1M count=64 status=none
LOOP=$(losetup --find --show "$IMG"); SZ=$(blockdev --getsz "$LOOP")
dmsetup create "$DM" --table "0 $SZ linear $LOOP 0"
DEV=/dev/mapper/$DM
mkfs.ext4 -q -F "$DEV"; mount "$DEV" "$MNT"

python3 -c "open('$MNT/data.bin','wb').write(b'O'*4096)"; sync

# 用 512B 单位拿到数据块的物理起始扇区与长度
read START LEN < <(filefrag -b512 -v "$MNT/data.bin" \
  | awk '/^[[:space:]]*0:/{s=$4;l=$6;gsub(/\.\./,"",s);gsub(/:/,"",l);print s,l}')
echo "data block: start_sector=$START len=$LEN (device sectors=$SZ)"

# 只把这个数据块映射到 error,其余仍是 linear
dmsetup suspend "$DM"
dmsetup reload "$DM" <<EOF
0 $START linear $LOOP 0
$START $LEN error
$((START+LEN)) $((SZ-START-LEN)) linear $LOOP $((START+LEN))
EOF
dmsetup resume "$DM"
dmsetup table "$DM"

./fsync_probe "$MNT/data.bin"

# 恢复设备、丢缓存、重挂,看磁盘上到底留下了什么
dmsetup suspend "$DM"; dmsetup reload "$DM" --table "0 $SZ linear $LOOP 0"
dmsetup resume "$DM"
umount "$MNT"; echo 3 > /proc/sys/vm/drop_caches; mount "$DEV" "$MNT"
echo "on-disk first byte = $(head -c1 "$MNT/data.bin")  (O=新写已丢失; N=新数据落盘)"

3.3 实测结果

环境:WSL2,内核 6.6.87.2-microsoft-standard-WSL2;文件系统 ext4,默认挂载(data=ordered);device-mapper error 目标 v1.6.0;编译器 GCC 16.1.1。以 root 运行,输出如下(已去掉无关行):

data block: start_sector=65538 len=8 (device sectors=131072)
0 65538 linear 7:0 0
65538 8 error
65546 65526 linear 7:0 65546
fsync #1: ret=-1 errno=5 (Input/output error)
fsync #2: ret=0 errno=0 (OK)
on-disk first byte = O  (O=新写已丢失; N=新数据落盘)

三行结果,每一行都值得盯一会儿:

这就是 fsyncgate 的核心,而且它在 2026 年的 6.6 内核、默认 ext4 上依然如此。fsync() 没有骗你说写成功了——它第一次老老实实报了错。骗人的是“失败后重试”这个看似稳妥的动作。


四、内核视角:脏页 writeback 失败时发生了什么

为什么第二次 fsync 找不到要写的数据?因为第一次失败时,那个脏页已经被标记成 clean 了。

Linux writeback 错误上报:errseq_t 改造前后对比

4.1 失败的脏页被标记 clean

writeback 的核心动作发生在各文件系统的 writepages 回调里(ext4 是 ext4_writepages,XFS 是 xfs_vm_writepages,Btrfs 是 btrfs_writepages)。它们在把页提交给块层(bio)之前,会先调用 clear_page_dirty_for_io() 把页的 dirty 标志清掉;如果随后的写失败,它们并不会把页重新标记为 dirty。

USENIX ATC 2020 的论文《Can Applications Recover from fsync Failures?》专门指出:三个文件系统各自独立地实现了 writepages,却不约而同地都这么做。原因之一是要处理“U 盘被拔出”这类场景——为了能卸载文件系统、回收内存,那些永远写不出去的脏页必须被标记 clean,否则会内存泄漏。代价就是:一次普通的写故障,也会让脏页被丢弃。

页一旦 clean,对内核就等于“内容已和磁盘一致,无需写回”。于是:

所以“重试 fsync 救数据”从机制上就不成立:没有任何文件系统会重试失败的数据块写。

4.2 errseq_t 修复的是“上报”,不是“数据”

fsyncgate 之后常被提到的“Linux 4.13 修了 fsync”,指的是 Jeff Layton 那组 writeback 错误处理改造(commit 5660e13d2fd6,《fs: new infrastructure for writeback error handling and reporting》,2017 年并入 4.13)。理解它修了什么、没修什么,是分清“可靠上报”和“数据安全”的关键。

4.13 之前:错误用 mapping->flags 里的 AS_EIO / AS_ENOSPC 标志位记录。fsync 路径在等待回写时会顺手把这个标志检查并清掉。后果有两个:第一个调用 fsync 的人清掉标志、看到错误,之后再来的人就什么也看不到;如果出错时恰好没人持有这个文件、标志又被某次 sync 清掉,错误可能彻底消失。

4.13 之后:引入 errseq_t——一个 32 位值,里面打包了一个错误码、一个序号计数器和一个“已见”标志。每个 address_space 有一个 mapping->wb_err 记录错误序列;每个 struct fileopen 时采样一份游标 f_wb_errfsync 时用 errseq_check_and_advance() 比较 fd 的游标和 mapping 当前值:变了就报一次错并推进游标。这样每个独立 open 的 fd 都能至少看到一次错误,不再“先到先得清空”。

但请注意 errseq_t 改的全是“谁能看到错误、看到几次”,它完全没有触碰脏页被标记 clean、数据被丢弃这件事。这正好解释了第三节的实测:在 6.6 内核上,同一个 fd 的第一次 fsync 看到 EIO(游标落后于 mapping),第二次 fsync 游标已追平、且没有脏页,于是返回 0。可靠上报 ≠ 可恢复,errseq_t 把“报一次错”做扎实了,但报完之后数据照样没了。

PostgreSQL wiki 的 Fsync_Errors 页面把不同内核区段的行为总结得很清楚,可以对应到上面两段:


五、文件系统的差异:ext4 / XFS / Btrfs

上面那篇 ATC 2020 论文用一个自制的 device-mapper 故障注入目标(dm-loki,思路和本文用的 dm-error 一致,但能按“第几次写某块”精确注入),系统地刻画了三个文件系统在 fsync 失败后的行为。挑几条对工程判断最要紧的:

维度 ext4 ordered ext4 data XFS Btrfs
哪种块写失败会让 fsync 报错 data、journal data、journal data、journal data、journal
数据块失败后脏页状态 标记 clean 标记 clean 标记 clean 标记 clean
clean 页里的内容 新数据(与磁盘不符) 新数据(与磁盘不符) 新数据(与磁盘不符) 回退到磁盘上的旧状态
错误何时上报 立即(本次 fsync) 延迟到下一次 fsync 立即 立即
journal 块写失败的后果 重挂为只读 重挂为只读 整个文件系统 shutdown 重挂为只读
metadata 块写失败 记日志、继续 记日志、继续 shutdown 重挂为只读

口径说明:以上为该论文在 Linux 5.2.11、各文件系统默认 mkfs/mount 选项下,对单个块注入一次瞬时写故障得到的结果(论文 Table 1)。几个值得展开的点:

论文进一步测了 PostgreSQL、LMDB、LevelDB、SQLite、Redis 五个应用,结论很扎心:它们用了各式各样的失败处理策略,但没有一个是充分的fsync 失败都可能导致数据丢失或损坏。其中一个反复出现的陷阱是:应用在恢复时依赖页缓存里的内容(那里可能还是“新数据”),而页缓存并不代表磁盘的持久状态,于是恢复逻辑反而把错误数据当真。


六、应用怎么自救

既然重试不行、页缓存不可信,应用还能做什么?

第一,fsync 失败就别再假装能继续。 PostgreSQL 的修法(PostgreSQL 12 提交,并回合到 11、10、9.6、9.5、9.4 各分支,作者 Thomas Munro、Andres Freund、Robert Haas、Craig Ringer)是:checkpoint 时 fsync 失败就直接 PANIC,故意崩溃。崩溃意味着不会去截断 WAL,重启后从 WAL 重放、重做整个 checkpoint,用“崩溃恢复”这条本来就经过严格测试的路径兜底。它还加了个 data_sync_retry 参数,默认关闭(即默认 PANIC)。WiredTiger/MongoDB、MySQL 也做了类似修正——核心都是不再重试 fsync

第二,恢复时只信磁盘,不信页缓存。 论文的建议很直接:恢复逻辑要从设备上真实的持久状态重建,而不是读页缓存里那份可能“看起来对、其实没落盘”的内容。否则就会出现论文里说的 FalseFailure——应用告诉用户失败了,后续却又读到了那条“失败”的数据。

第三,考虑 O_DIRECT 绕开页缓存。 论文认为,在它测试的策略里,PostgreSQL 用 Direct I/O 这条路对 fsync 失败的处理可能是最稳的:绕开内核页缓存,应用自己管缓冲,就不会被“clean 页里残留新数据”误导。代价是应用要自己承担缓存和对齐的复杂度。

第四,把故障注入纳入测试。 这件事最大的教训不是某个 API 用错,而是几乎没人测过 fsync 失败这条路径。论文给出的方法论是用块级别的故障注入(dm-errordm-flakey、它们的 dm-loki,或本文这种单块 error 重映射)真实地让底层写失败,观察应用恢复后数据对不对——而不是仅仅 mock 系统调用返回值或模拟一次干净的崩溃重启。本文第三节那套脚本,本质就是一个最小可用的故障注入夹具。


七、结论与边界

把版本和适用范围收一下,避免把结论用过头:

落到工程上,几条可以直接用:

  1. 任何对持久性较真的代码,遇到 fsync() 返回错误,不要重试当作没事——要么像 PostgreSQL 那样崩溃并走重放恢复,要么明确进入一个“数据可能已损”的降级状态。
  2. 别拿 ext4 data=journal 当“更安全”的护身符,它在失败上报上反而更绕。
  3. 恢复逻辑只读磁盘持久状态,不要依赖页缓存。
  4. 如果数据安全是你的卖点,把块级故障注入写进测试,否则你永远不知道恢复路径是不是真的能恢复。

fsync() 从没承诺过“失败之后还能补救”。它在第一次就告诉了你真相,只是这个真相不太符合直觉:报错的那一刻,数据往往已经没了;之后那次返回 0 的 fsync,只是在替丢失的数据开一张假收据。


关键概念回顾

常见误解

参考

规范:

  1. POSIX fsync 规范,The Open Group Base Specifications。 – 失败语义被有意写得含糊。
  2. fsync(2)fdatasync(2) Linux man-pages。

源码与文档:

  1. Jeff Layton, fs: new infrastructure for writeback error handling and reporting,Linux commit 5660e13d2fd6af1903d4b0b98020af95ca2d638a,并入内核 4.13(2017)。
  2. The errseq_t datatype – 内核文档,errseq_check_and_advance 语义。
  3. LWN.net, fs: enhanced writeback error reporting with errseq_t(patch set 讨论,2017)。
  4. Fsync Errors – PostgreSQL wiki,按内核版本区段总结 fsync 行为,并记录 PostgreSQL 改为 PANIC 的提交(PostgreSQL 12 并回合到 11/10/9.6/9.5/9.4)。
  5. Craig Ringer, PostgreSQL’s handling of fsync() errors is unsafe and risks data loss at least on XFS,pgsql-hackers 邮件列表,2018(即 “fsyncgate 2018”)。

论文:

  1. Anthony Rebello, Yuvraj Patel, Ramnatthan Alagappan, Andrea C. Arpaci-Dusseau, Remzi H. Arpaci-Dusseau, Can Applications Recover from fsync Failures?, USENIX ATC 2020, pp. 753–767, University of Wisconsin–Madison. – 文件系统与应用在 fsync 失败下的系统性刻画,配套故障注入工具 CuttleFS。

实验与工具:

  1. Device Mapper: dm-flakeydm-error 目标 – 本文用单块 error 重映射注入写故障,环境为 Linux 6.6.87.2-microsoft-standard-WSL2、ext4 默认挂载。

如果你想先把“正常路径”补齐——写屏障、断电、校验和与端到端保护,读 数据完整性:从 fsync 到端到端校验;想看预写日志为什么把 fsync 当命根子,读 WAL 与 ARIESPostgreSQL WAL 内核实现

同样是“看似稳妥、实则有坑”的系统真相,还可以读 Zero Copy 的肮脏真相内核内存屏障:一个让我调了三天的 bug大多数“无锁”代码其实不是无锁的

返回 Linux 内核与 eBPF 工程索引

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-08-21 · storage

【存储工程】数据完整性:从 fsync 到端到端校验

数据丢失最令人恐惧的形式不是磁盘报错——而是数据悄无声息地变了,没有任何告警,没有任何日志,直到几个月后你从备份里恢复出一堆损坏的文件,才发现"完整性"这个词从来就不是理所当然的。

2025-08-27 · storage

【存储工程】文件系统选型与基准测试

在生产环境中,文件系统(Filesystem)的选择直接影响存储栈的性能上限、数据安全边界和运维复杂度。本文将从设计目标、元数据性能、数据吞吐、典型业务场景、基准测试方法论等多个维度,对 ext4、XFS、Btrfs(B-tree Filesystem)、ZFS(Zettabyte File System)四种主流文件…


By .