2009年3月,Ubuntu用户论坛上出现了一连串关于"ext4数据丢失"的报告。用户们描述了一个令人不安的现象:系统正常关机后重启,发现最近编辑的文件变成了零字节。这不是磁盘故障,不是恶意软件,而是Linux内核中一个精心设计的性能优化机制——延迟分配(delayed allocation)——在特定场景下的"副作用"。

这次事件揭示了一个更深层的问题:在现代操作系统中,当write()系统调用返回成功时,数据可能还停留在CPU寄存器、内核缓冲区、磁盘控制器缓存,甚至SSD的DRAM缓冲区中。理解数据在这层层缓存中的旅程,是构建可靠存储系统的第一步。

从write()到磁盘:数据的多层中转站

当应用程序调用write()向文件写入数据时,数据并不会立即写入磁盘。它需要穿越一个复杂的存储栈,每一层都有自己的缓存机制。

第一层:用户态缓冲区

C标准库的fwrite()会在用户态维护一个缓冲区(通常8KB)。只有当缓冲区满、调用fflush()或程序正常退出时,数据才会通过write()系统调用进入内核。这是应用程序可控的第一层缓存。

// 用户态缓冲示例
FILE *fp = fopen("data.txt", "w");
fwrite(buffer, 1, 1024, fp);  // 数据仍在用户态缓冲区
fflush(fp);                    // 数据进入内核page cache

第二层:Page Cache

进入内核后,数据首先到达Page Cache——Linux内核维护的磁盘缓存。每个内存页面(通常4KB)对应磁盘上的数据块。写入操作只是修改内存中的页面内容,并将其标记为"脏页"(dirty page)。

Page Cache的存在使得write()系统调用可以在微秒级返回,而不用等待毫秒级的磁盘写入。但代价是:此时数据仅存在于易失性内存中,断电即丢失。

第三层:块层I/O调度

当内核决定将脏页写入磁盘时,数据进入块层。I/O调度器(如CFQ、deadline、noop)会对请求进行重排序和合并,以优化磁盘寻道和吞吐量。对于机械硬盘,这意味着请求可能被延迟数十毫秒;对于SSD,延迟相对较短但仍然存在。

第四层:存储设备缓存

现代存储设备几乎都有自己的缓存:

  • 机械硬盘:通常8MB-256MB的DRAM缓存
  • SATA SSD:通常256MB-2GB的DRAM缓存
  • NVMe SSD:可能有板载DRAM,也可能使用HMB(Host Memory Buffer)技术借用主机内存

这个缓存对操作系统是透明的。当内核认为数据已经写入磁盘时,数据可能仍停留在设备的易失性缓存中。

脏页回写:内核的后台清洁工

Page Cache中的脏页不会永远留在内存中。内核有专门的机制定期将脏页写入磁盘,这个过程称为"回写"(writeback)。

回写触发条件

Linux内核在以下情况下触发脏页回写:

触发条件 说明 默认值
dirty_background_ratio 脏页占比达到此阈值,启动后台回写 10%
dirty_ratio 脏页占比达到此阈值,阻塞写入进程强制回写 20%
dirty_writeback_centisecs 周期性回写间隔 500(5秒)
dirty_expire_centisecs 脏页超时时间,超过此时间的脏页必须回写 3000(30秒)
内存压力 系统需要回收内存时 动态触发
sync()/fsync() 应用程序显式请求 立即

Flusher线程的十年演进

脏页回写的实现机制在过去十五年中经历了重大变革。

bdflush/kupdated时代(2.4内核及之前):内核使用bdflush守护进程监控脏页。这是一个单一的用户态进程,存在严重的性能瓶颈和可靠性问题。

pdflush时代(2.6内核):内核引入了pdflush(page dirty flush)线程池,线程数量根据I/O负载动态调整(2-8个)。但pdflush线程是全局的,所有存储设备共享,导致多设备系统上的性能问题。

per-BDI writeback时代(2.6.32,2009年12月):Jens Axboe设计的per-BDI(Backing Device Info)writeback机制彻底改变了这一局面。每个存储设备拥有独立的flusher线程(命名为flush-MAJOR:MINOR),避免了多设备之间的相互干扰。基准测试显示,在多设备环境下,XFS性能提升40%,Btrfs提升26%。

timeline
    title Linux脏页回写机制演进
    section 2.4及之前
        bdflush/kupdated : 单一守护进程<br/>可靠性问题
    section 2.6早期
        pdflush : 线程池模式<br/>全局竞争
    section 2.6.32
        per-BDI writeback : 每设备独立线程<br/>性能大幅提升
    section 现代内核
        workqueue : 工作队列机制<br/>更灵活的调度

现代Linux内核(4.x之后)进一步演进:flusher任务由workqueue机制处理,不再需要常驻的内核线程,而是由[kworker]工作线程池按需处理。

回写的代价:I/O风暴

脏页回写并非无代价。当系统积累了大量脏页后一次性回写,会产生"I/O风暴"——磁盘带宽被回写操作完全占用,前台应用响应缓慢。

这正是dirty_background_ratio存在的意义:在脏页占比还不太高时就开始后台回写,避免积累到dirty_ratio时产生剧烈的阻塞。但对于数据库这类频繁调用fsync()的应用,每次fsync()都可能触发大量回写,造成性能抖动。

fsync:强制落盘的艺术

当应用程序需要确保数据持久化时,fsync()是最后的武器。但正确使用fsync()远比想象中复杂。

fsync做了什么

以ext4为例,fsync()的执行过程包含三个关键步骤:

  1. 写入脏页:调用file_write_and_wait_range()将文件的所有脏页写入存储设备。此时数据可能仍停留在设备的易失性缓存中。

  2. 写入元数据:如果文件大小发生变化,需要将inode元数据写入日志(journal)。

  3. 发送flush命令:如果文件系统启用了barrier(ext4默认启用),调用blkdev_issue_flush()向存储设备发送REQ_PREFLUSH命令,强制设备将缓存中的数据写入持久存储介质。

fsync vs fdatasync

fdatasync()fsync()的轻量级版本,它只保证文件数据写入磁盘,不保证元数据(如修改时间)的持久化。当文件大小不变时,fdatasync()可以避免一次元数据写入,性能优势明显。

// 使用fdatasync减少开销
int fd = open("database.wal", O_WRONLY | O_APPEND);
write(fd, log_entry, entry_size);
fdatasync(fd);  // 仅同步数据,不同步元数据

PostgreSQL在WAL(Write-Ahead Log)写入时使用fdatasync(),因为WAL文件只追加写入,文件大小变化很少,使用fdatasync()可以显著减少fsync开销。

O_SYNC与O_DSYNC

另一种确保持久化的方式是在打开文件时指定O_SYNCO_DSYNC标志:

int fd = open("important.dat", O_WRONLY | O_CREAT | O_SYNC, 0644);
write(fd, data, size);  // write()返回时数据已在磁盘

这等同于每次write()后自动执行fsync()。性能代价极高,通常只在极端可靠性场景使用。

Write Barrier与FUA:存储层的顺序保证

即使内核正确执行了fsync(),存储设备仍可能重新排序写入请求,这对于日志文件系统是致命的。

为什么写入顺序很重要

考虑日志文件系统的一次事务提交:

  1. 将数据块写入日志
  2. 写入提交记录(commit record)
  3. 将数据写入最终位置

如果在步骤1完成之前,步骤2的提交记录先被写入磁盘,系统崩溃后日志重放会看到一个"不完整"的事务——数据块不存在,但提交记录存在。这会破坏文件系统一致性。

Barrier请求的诞生

为了解决这个问题,Linux内核引入了"barrier"请求。barrier请求保证:在barrier之前的所有写入请求必须完成,才能开始barrier之后的请求。

ext3/ext4文件系统默认启用barrier(-o barrier挂载选项)。当fsync()执行时,文件系统会发送一个带barrier的写入请求,确保日志提交记录在所有数据块之后写入。

从Barrier到Flush/FUA

2010年,Linux内核2.6.37彻底移除了barrier请求机制,取而代之的是更精细的REQ_PREFLUSHREQ_FUA标志:

  • REQ_PREFLUSH:在执行当前请求之前,刷新设备的易失性缓存
  • REQ_FUA(Force Unit Access):当前请求的数据必须写入持久存储介质才能返回
sequenceDiagram
    participant App as 应用程序
    participant FS as 文件系统
    participant Block as 块层
    participant Dev as 存储设备
    
    App->>FS: write(数据块)
    FS->>Block: 提交写入请求
    Block->>Dev: 写入设备缓存
    
    App->>FS: fsync()
    FS->>Block: 提交提交记录(PREFLUSH|FUA)
    Block->>Dev: FLUSH缓存
    Dev-->>Block: 缓存已刷新
    Block->>Dev: 写入提交记录(FUA)
    Dev-->>Block: 数据已持久化
    Block-->>FS: 完成
    FS-->>App: fsync()返回

这种设计比旧的barrier更灵活:文件系统可以精确控制哪些请求需要刷新缓存,哪些需要强制写入,而不是一刀切地停止所有I/O。

ext4断电数据丢失事件:一次深刻的教训

2009年的ext4数据丢失事件,是理解文件系统行为与用户期望之间差距的经典案例。

问题的根源

ext4引入了**延迟分配(delayed allocation)**技术:写入数据时不立即分配磁盘块,而是延迟到回写时再分配。这带来了显著的性能优势——可以合并小写入、优化块分配、减少碎片。

但延迟分配有一个副作用:在data=ordered模式下,ext4不再像ext3那样在每次日志提交时强制写出数据块。因为块还没有分配,根本不存在"数据块"可以写出。

零字节文件之谜

用户报告的问题通常是这样的:

int fd = open("config.txt", O_TRUNC | O_WRONLY);
write(fd, new_config, config_size);
close(fd);
// 假设此时断电

在ext3的data=ordered模式下,close()时会触发日志提交,数据块会随元数据一起写入。在ext4的延迟分配下,close()只是将脏页留在Page Cache中,元数据(包括文件大小)可能先于数据写入。

断电后重启:文件存在,大小正确,但内容全是零——因为数据块还没分配,还没有实际写入任何内容。

教训与修复

Ted Ts’o(ext4主要开发者)在LWN上发表了详细解释:这不是ext4的bug,而是应用程序假设了ext3的特定行为——而这种行为本身只是ext3设计的"意外后果",并非POSIX保证。

修复方案包括:

  1. 应用层:正确使用fsync()确保数据持久化后再进行元数据操作
  2. 内核层:在2.6.30中引入自动分配机制,在特定危险模式(truncate后立即写入、rename覆盖)下强制分配延迟块

这个事件给开发者的教训是深刻的:不要假设文件系统会为你保证任何超出POSIX承诺的行为。

SSD的断电保护:企业级与消费级的真实差异

在讨论数据持久化时,SSD的断电保护(Power Loss Protection, PLP)是一个常被误解的话题。

SSD内部缓存的真相

大多数SSD使用DRAM作为写入缓存,缓存映射表(FTL)和数据都会暂存于此。消费级SSD的DRAM缓存通常是易失性的——断电即丢失。

这意味着:即使操作系统执行了fsync()并收到成功返回,数据可能仍停留在SSD的DRAM缓存中。如果此时断电,数据就会丢失。

企业级SSD的保护机制

企业级SSD通常具备完整的断电保护:

硬件PLP:板载钽聚合物电容,在检测到断电时提供足够的电量将DRAM缓存中的数据写入NAND闪存。金士顿、三星、Intel等企业级SSD都采用这种方案。

固件PLP:确保固件在断电后能够重建映射表。关键元数据在写入时始终包含标记(备用字节),包括LBA、ECC和其他结构信息,断电后可以从这些信息重建映射表。

消费级SSD的"伪保护"

许多消费级SSD声称支持断电保护,但实际上:

  • 部分SSD只保护映射表(FTL),不保护用户数据
  • 部分SSD的电容容量不足以完成完整的缓存刷新
  • 固件可能没有正确实现数据硬化(data hardening)流程

对于关键数据,最安全的做法仍然是:假设消费级SSD没有断电保护,在每次关键写入后执行fsync()

生产环境最佳实践

数据库服务器

# 降低脏页比例,减少fsync时的I/O风暴
vm.dirty_background_ratio = 3
vm.dirty_ratio = 10

# 缩短脏页过期时间,减少断电时的数据丢失窗口
vm.dirty_expire_centisecs = 1500  # 15秒
vm.dirty_writeback_centisecs = 100  # 1秒

对于PostgreSQL/MySQL等数据库,还需要确保:

  • WAL/data目录挂载时启用barrier(默认)
  • 使用fdatasyncO_DSYNC而非fsync以减少元数据开销
  • 考虑使用O_DIRECT绕过Page Cache,实现更精确的缓存控制

桌面环境

# 允许更多脏页,提升响应速度
vm.dirty_background_ratio = 10
vm.dirty_ratio = 20

桌面环境对数据丢失的容忍度较高(用户通常会保存文档),但需要避免I/O风暴导致的UI卡顿。

嵌入式设备

嵌入式设备(如树莓派、工业控制器)经常面临意外断电,需要更激进的策略:

# 最小化脏页窗口
vm.dirty_background_ratio = 1
vm.dirty_ratio = 3
vm.dirty_expire_centisecs = 100  # 1秒
vm.dirty_writeback_centisecs = 50  # 0.5秒

或者使用sync挂载选项(性能代价极高)或日志文件系统(如ext4的data=journal模式)。

结语:缓存无处不在,警惕无处不在

现代存储系统的每一层都引入了缓存:CPU缓存、用户态缓冲、Page Cache、块层请求队列、存储设备缓存、SSD的DRAM缓冲区。每一层缓存都在提升性能,每一层缓存都在增加数据丢失的风险。

write()返回成功只意味着数据被内核接收,fsync()返回成功只意味着内核完成了它该做的操作,而数据是否真正持久化,取决于存储设备是否正确实现了flush命令、是否有足够的断电保护。

理解这一点,才能在系统设计时做出正确的权衡:哪些数据需要fsync(),哪些可以使用fdatasync(),哪些可以接受异步写入。在性能与可靠性之间,没有万全之策,只有精心设计的取舍。


参考资料

  1. LWN, “Ensuring data reaches disk”, https://lwn.net/Articles/457667/
  2. LWN, “The end of block barriers”, https://lwn.net/Articles/400541/
  3. LWN, “ext4 and data loss”, https://lwn.net/Articles/322823/
  4. Linux Kernel Documentation, “ext4 General Information”, https://www.kernel.org/doc/html/v6.1/admin-guide/ext4.html
  5. KernelNewbies, “Linux 2.6.32 - Per-backing-device based writeback”, https://kernelnewbies.org/Linux_2_6_32
  6. Kingston, “SSD Power Loss Protection”, https://www.kingston.com/cn/blog/servers-and-data-centers/ssd-power-loss-protection
  7. “The Secret Life of fsync”, https://puzpuzpuz.dev/the-secret-life-of-fsync
  8. USENIX FAST 2018, “Barrier-Enabled IO Stack for Flash Storage”, https://www.usenix.org/system/files/conference/fast18/fast18-won.pdf
  9. USENIX OSDI 2014, “All File Systems Are Not Created Equal: On the Complexity of Crafting Crash-Consistent Applications”, https://www.usenix.org/system/files/conference/osdi14/osdi14-paper-pillai.pdf