2018年3月,PostgreSQL开发者Craig Ringer在邮件列表中披露了一个令人震惊的发现:PostgreSQL对fsync()错误处理不当可能导致数据损坏。这个后来被称为"fsyncgate"的事件揭示了一个更深层的问题——许多开发者对fsync()的语义存在根本性误解。

问题的核心在于:当fsync()返回EIO错误时,PostgreSQL会重试。但第二次fsync()竟然成功了。数据从未写入磁盘,但系统认为一切正常,继续执行checkpoint并截断WAL日志。结果?数据悄无声息地丢失。

这不是PostgreSQL独有的问题。2020年USENIX ATC会议上发表的论文《Can Applications Recover from fsync Failures?》系统研究了ext4、XFS、Btrfs三种文件系统以及PostgreSQL、SQLite、LevelDB、Redis、LMDB五种数据库后发现:没有任何一个应用能正确处理fsync失败

一个被误解的系统调用

POSIX标准对fsync()的描述看似简单明了:

“The fsync() function shall request that all data for the open file descriptor named by fildes is to be transferred to the storage device associated with the file described by fildes.”

关键问题是:如果fsync()失败了,会发生什么? POSIX没有明确说明。它只说:

“If the fsync() function fails, outstanding I/O operations are not guaranteed to have been completed.”

这正是问题的起点。

页面缓存的真相

要理解fsync()的行为,必须理解Linux内核的页面缓存机制。当应用调用write()时,数据只是从用户空间复制到内核的页面缓存——内存中的页被标记为"dirty"(脏),但磁盘上没有任何变化。fsync()的作用是强制将这些脏页写入磁盘。

问题的关键在于:当写入失败时,页面会发生什么?

威斯康星大学麦迪逊分校的研究团队通过精确的故障注入实验发现:

文件系统 fsync失败后页面状态 页面内容
ext4 (ordered) 干净 最新数据(内存中)
ext4 (data=journal) 干净 最新数据(内存中)
XFS 干净 最新数据(内存中)
Btrfs 干净 回滚到旧数据

所有测试的文件系统都会在fsync()失败后将页面标记为"干净"。这意味着:重试fsync()不会再次写入数据,因为内核认为这些页面已经同步完成

更糟糕的是,ext4和XFS的页面内容是"最新数据"——即应用写入的内容仍在内存中。当应用后续读取时,它看到的是新数据。只有当页面被逐出缓存、系统重启后,数据才会从磁盘读取——此时返回的是旧内容。这就是所谓的"时间旅行"问题。

fsyncgate:一次教科书级的事故

Craig Ringer的原始报告描述了完整的故障链条:

  1. PostgreSQL写入数据块到内核脏缓冲区
  2. 后台写入失败(存储错误)
  3. 内核将相关页面标记为AS_EIO(错误状态)
  4. PostgreSQL调用fsync(),返回EIO
  5. PostgreSQL将checkpoint标记为失败
  6. PostgreSQL重试checkpoint,再次调用fsync()
  7. 第二次fsync()成功——因为AS_EIO标志被清除了
  8. PostgreSQL认为数据已持久化,截断WAL日志
  9. 数据丢失

Linux内核开发者Jeff Layton在评论中明确指出:

“The stackoverflow writeup seems to want a scheme where pages stay dirty after a writeback failure so that we can try to fsync them again. Note that that has never been the case in Linux after hard writeback failures, AFAIK, so programs should definitely not assume that behavior.”

这种行为并非bug,而是Linux内核的设计决策。当写入失败时,内核需要决定是保持页面脏状态还是标记为干净。保持脏状态会导致内存泄漏——那些永远无法写入的页面会一直占用内存。标记为干净是更"实用"的选择,代价是应用必须意识到这一行为。

为什么错误只报告一次?

另一个令人困惑的行为是:fsync()错误通常只报告一次

在Linux 4.13之前,内核使用单个位来跟踪每个文件的写入错误。一旦错误被某个fsync()调用消费,后续调用就不会再看到这个错误。这导致多进程应用可能错过错误通知。

Linux 4.13引入了errseq_t机制来改进错误报告。每个打开的文件描述符现在可以独立地检测和报告错误。但这只解决了"谁能看到错误"的问题,并未解决"页面状态"问题。

文件系统的差异

不同文件系统对fsync()失败的响应存在显著差异:

ext4 ordered模式(默认)

这是最常见的配置。fsync()会:

  1. 写入脏数据块
  2. 将元数据写入日志
  3. 如果数据块写入失败,fsync()返回EIO
  4. 页面被标记为干净

一个微妙的问题是:即使数据块写入失败,元数据可能仍然被持久化。这导致文件可能存在"空洞"——某些块从未被实际写入。

ext4 data=journal模式

这种模式将数据也写入日志,提供了更强的保证。但研究发现一个反直觉的行为:

第一个fsync()可能成功,第二个fsync()才报告错误。

原因是数据先写入日志,然后在checkpoint时写入最终位置。如果checkpoint时失败,下一次fsync()才会发现。这被称为"失败的意图"(failed intention)问题。

XFS

XFS使用逻辑日志而非物理日志。与ext4 ordered类似,数据块失败后页面被标记为干净。一个重要区别是:XFS在日志块失败时会完全关闭文件系统,而不仅仅是重新挂载为只读。

Btrfs

作为写时复制(COW)文件系统,Btrfs表现出不同的行为:

失败后,Btrfs将页面内容回滚到之前的一致状态。

这意味着应用看到的是旧数据,而不是新数据。从"不会产生损坏"的角度,这是更安全的;但从"应用需要知道发生了什么"的角度,同样令人困惑。

数据库的应对策略

PostgreSQL的解决方案

fsyncgate事件后,PostgreSQL在2019年2月的11.2版本中改变了策略:在fsync()返回EIO时直接PANIC

// PostgreSQL 11.2之前的处理
if (fsync(fd) < 0) {
    // 记录错误,重试
    // 问题:重试可能"成功"
}

// PostgreSQL 11.2之后
if (fsync(fd) < 0 && errno == EIO) {
    // 直接崩溃
    elog(PANIC, "fsync failed: %m");
}

这看起来极端,但却是正确的选择。崩溃后,PostgreSQL可以从WAL恢复,重新执行失败的写入。唯一的代价是恢复时间。

SQLite的处理

SQLite在WAL模式下表现相对较好。但研究发现它仍然存在问题:

  • 在Rollback Journal模式下,如果日志文件损坏,SQLite可能从损坏的日志中读取数据进行回滚
  • 恢复逻辑依赖页面缓存,可能读取到与磁盘不一致的内容

Redis的问题

Redis的处理方式可能是最令人担忧的:

Redis在AOF模式下根本不检查fsync()的返回值。

研究团队发现,Redis会向客户端返回"成功",无论fsync()是否失败。只有在重启并尝试重建内存状态时,问题才会暴露。

MySQL InnoDB的双写缓冲

MySQL的InnoDB引擎采用了不同的策略——双写缓冲(Doublewrite Buffer):

[双写缓冲区] → [实际数据页位置]
   ↓ fsync
   ↓ 
[先写入128页] → [再写入实际位置]

每个脏页首先写入双写缓冲区,然后再写入实际位置。如果系统在写入过程中崩溃,InnoDB可以从双写缓冲区恢复部分写入的页面。但这解决的是"部分写入"问题,而非fsync()错误处理问题。

开发者应该做什么?

威斯康星的研究团队给出了几条建议:

1. 不要重试fsync()

这是最重要的一点。fsync()失败后,页面已被标记为干净,重试不会做任何事情。

2. 考虑直接I/O

使用O_DIRECT绕过页面缓存,应用程序自己管理缓冲。这避免了页面缓存与磁盘不一致的问题,但增加了复杂性。

int fd = open(path, O_RDWR | O_DIRECT | O_DSYNC);
// 每次write()都会直接到达磁盘

3. 在恢复时不要信任页面缓存

许多数据库从WAL恢复时依赖页面缓存来加速。但如果页面缓存的内容与磁盘不一致,恢复会产生错误结果。正确做法是强制从磁盘读取。

4. 崩溃是最安全的恢复策略

如果fsync()失败,最安全的做法是立即终止进程,从已知的持久化状态恢复。PostgreSQL的选择是正确的。

深层原因:抽象的代价

fsyncgate揭示了一个更广泛的工程问题:抽象层的契约模糊

操作系统提供了"文件系统"抽象,承诺将数据持久化。但这个承诺的边界在哪里?

  • POSIX定义了fsync()的"成功"语义,但对"失败"语义含糊其辞
  • 文件系统实现者基于"实用"考虑做出选择,但未充分文档化
  • 应用开发者基于直觉假设(“fsync失败意味着需要重试”)编写代码
  • 三者的期望发生冲突时,数据丢失发生

这不是某个组件的bug,而是系统设计中的结构性问题。正如论文作者所言:

“Applications expect file systems on an OS platform to behave similarly, and yet file systems exhibit nuanced and important differences.”

2025年的现状

截至2025年,情况有所改善但不乐观:

  • PostgreSQL、MySQL、MongoDB等主流数据库已采纳"崩溃而非重试"策略
  • Linux内核改进了错误报告机制,但页面状态行为未变
  • 新的sync_file_range()系统调用提供了更细粒度的控制,但被标记为"极其危险"
  • io_uring提供了异步fsync能力,但未改变语义

对于应用开发者,最实际的建议是:

  1. 理解你的文件系统行为
  2. 不要假设fsync()失败是可重试的临时错误
  3. 设计恢复机制时考虑最坏情况
  4. 对关键数据使用复制而非仅依赖本地持久化

正如Dan Luu在总结fsyncgate时所说:当你认为数据已经安全存储时,可能只是存储在了某个不稳定的缓存中。对持久化的信任,需要建立在对系统行为的深刻理解之上,而非模糊的假设。


参考文献

  1. Rebello, A., Patel, Y., Alagappan, R., Arpaci-Dusseau, A. C., & Arpaci-Dusseau, R. H. (2020). Can Applications Recover from fsync Failures? USENIX ATC 2020.

  2. Ringer, C. (2018). PostgreSQL’s handling of fsync() errors is unsafe and risks data loss. PostgreSQL mailing list.

  3. PostgreSQL Wiki. Fsync Errors. https://wiki.postgresql.org/wiki/Fsync_Errors

  4. LWN. (2009). ext4 and data loss. https://lwn.net/Articles/322823/

  5. Jones, E. (2020). Durability: Linux File APIs. https://www.evanjones.ca/durability-filesystem.html

  6. Luu, D. (2018). Fsyncgate: errors on fsync are unrecoverable. https://danluu.com/fsyncgate/

  7. LWN. (2018). PostgreSQL’s fsync() surprise. https://lwn.net/Articles/752063/

  8. LWN. (2009). fsync() and disk flushes. https://lwn.net/Articles/326992/

  9. Ts’o, T. (2009). Delayed allocation and the zero-length file problem.

  10. MySQL Reference Manual. InnoDB Doublewrite Buffer.