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()的执行过程包含三个关键步骤:
-
写入脏页:调用
file_write_and_wait_range()将文件的所有脏页写入存储设备。此时数据可能仍停留在设备的易失性缓存中。 -
写入元数据:如果文件大小发生变化,需要将inode元数据写入日志(journal)。
-
发送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_SYNC或O_DSYNC标志:
int fd = open("important.dat", O_WRONLY | O_CREAT | O_SYNC, 0644);
write(fd, data, size); // write()返回时数据已在磁盘
这等同于每次write()后自动执行fsync()。性能代价极高,通常只在极端可靠性场景使用。
Write Barrier与FUA:存储层的顺序保证
即使内核正确执行了fsync(),存储设备仍可能重新排序写入请求,这对于日志文件系统是致命的。
为什么写入顺序很重要
考虑日志文件系统的一次事务提交:
- 将数据块写入日志
- 写入提交记录(commit record)
- 将数据写入最终位置
如果在步骤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_PREFLUSH和REQ_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保证。
修复方案包括:
- 应用层:正确使用
fsync()确保数据持久化后再进行元数据操作 - 内核层:在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(默认)
- 使用
fdatasync或O_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(),哪些可以接受异步写入。在性能与可靠性之间,没有万全之策,只有精心设计的取舍。
参考资料
- LWN, “Ensuring data reaches disk”, https://lwn.net/Articles/457667/
- LWN, “The end of block barriers”, https://lwn.net/Articles/400541/
- LWN, “ext4 and data loss”, https://lwn.net/Articles/322823/
- Linux Kernel Documentation, “ext4 General Information”, https://www.kernel.org/doc/html/v6.1/admin-guide/ext4.html
- KernelNewbies, “Linux 2.6.32 - Per-backing-device based writeback”, https://kernelnewbies.org/Linux_2_6_32
- Kingston, “SSD Power Loss Protection”, https://www.kingston.com/cn/blog/servers-and-data-centers/ssd-power-loss-protection
- “The Secret Life of fsync”, https://puzpuzpuz.dev/the-secret-life-of-fsync
- USENIX FAST 2018, “Barrier-Enabled IO Stack for Flash Storage”, https://www.usenix.org/system/files/conference/fast18/fast18-won.pdf
- 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