作者视角:15 年运维,长期盯 Linux 基础层。这篇文章不聊「要不要上 ZFS」,只讲一个更常见、也更容易被忽略的坑:ext4 的 journal 默认只管元数据,不管数据——你在应用层看到
write()全部成功、close()返回 0,掉电那一刻数据页可能还压在 page cache 里。下面是一次真实复盘 + 一套可复现的排查与选型清单。
一、现象与背景:一次 11 秒的掉电,73 个文件变成 0 字节
先交代规模。我们一台离线特征计算节点(48C/192G,CentOS 7.9,内核 5.10.134,数据盘 2TB SATA SSD 走 ext4),跑的是每小时一次的批处理任务:上游导出 ~40GB 的宽表快照落到本地目录,下游再逐文件做特征聚合后写结果集。数据盘 12 个月前从 ext4 默认挂载改成了 data=writeback,理由很朴素:当时 fio 顺序写从 512MB/s 提到 705MB/s,吞吐涨了 38%,导出阶段耗时从 96s 降到 71s,于是全组都当这是一次「白捡的性能」,没有任何风险评审。
某周五凌晨机房空调故障引发 PDU 掉电,UPS 顶了 11 秒后整机断电。恢复后的事故面:
dmesg里能看到 ext4 做了 journal replay,fsck 全程 exit 0,e2fsck -n也干净——文件系统层面「一切正常」;- 但业务侧对账发现:73 个上游快照文件里,11 个被截断为 0 字节,62 个大小正确但内容 md5 与上游不一致,合计 3.2GB 数据需要重跑上游导出;
- 更反直觉的是:这 73 个文件的 mtime 全部完整,时间戳都精确到掉电前 0~40 秒。我们一开始就是被 mtime 骗了,盯着下游聚合逻辑查了 3 个小时,才回头怀疑源文件本身是坏的。
二、根因:journal 模式决定「掉电丢什么」,write() 成功不等于「数据安全」
ext4 的 journal 有三种数据模式,核心差异是日志里记不记数据本身:
data=journal:数据 + 元数据都先进 journal 再落数据区。掉电最多丢「还没进 journal 的尾部写入」,已 commit 的文件保证完整。代价是顺序写吞吐大约打 5~7 折。data=ordered(默认):元数据走 journal,数据页在对应元数据 commit 之前保证先落盘(内核用 IO 屏障/flush 保证顺序)。掉电后文件要么是旧版本、要么是完整新版本,不会出现「新大小 + 旧/空内容」的撕裂页。data=writeback:元数据走 journal,数据页按 LRU 顺序独立回写,不保证与元数据提交顺序一致。掉电时完全可能出现:目录项里记录的 size 已经是新值(元数据已 journal),但数据块还压在 page cache 里没写出去——文件表现为 0 字节、半截内容或新旧混合。
我们这次 73 个文件里那 11 个 0 字节文件,就是典型的「size 元数据已提交、数据页全部蒸发」;62 个内容损坏的文件则是「部分数据页落了盘、部分没有」的撕裂状态。
另一个容易被忽略的细节:应用层看到的成功返回(write() 返回字节数、close() 返回 0)只表示数据进了内核 page cache,不是进了盘。真正决定持久性边界的只有两种动作:fsync()(进程显式刷) 和内核的 writeback 定时器(/proc/sys/vm/dirty_writeback_centisecs,默认 500,即 5 秒)。我们那个导出脚本用的是普通 cp + Python 逐文件读写的组合,全程没有一处 fsync,所以掉电窗口内所有 dirty page 的生死完全交给运气。
三、具体做法:排查命令与选型建议(可复现)
1. 掉电后第一件事:先取证,再谈恢复
# 记录 journal replay 痕迹(replay 完成就没了,趁早)
dmesg -T | grep -iE 'ext4|journal'
journalctl -k -b -1 | grep -iE 'ext4|fsck' # 上一个启动周期
# 不修改文件系统,只做检查
e2fsck -n /dev/sdb1
# 检查挂载模式(确认到底是哪种 journal 语义)
mount | grep ' /data '
tune2fs -l /dev/sdb1 | grep -i 'default mount'
# 关键:dirty 页水位,掉电前如果这里长期很高,风险就长期存在
cat /proc/meminfo | grep -i dirty
watch -n1 grep -E 'Dirty|Writeback' /proc/meminfo
2. 数据完整性对账(别信 mtime,信校验和)
# 上游导出时同步落一份 checksum 清单(这次我们缺的就是这一步)
md5sum /data/snapshots/20261108/*.parquet > /data/snapshots/20261108/MD5
# 恢复后逐文件比对,而不是只看 ls -l 的大小
md5sum -c MD5 --quiet || echo "有损坏文件"
3. 选型矩阵(48C 节点,2TB SATA SSD,fio 实测,顺序 1M 写)
| journal 模式 | 顺序写吞吐 | 随机写 4K IOPS | 掉电后数据风险 | 适用场景 |
|---|---|---|---|---|
| data=journal | ~230MB/s | ~38K | 已 commit 文件保证完整 | 交易/账本类、单文件原子性要求高 |
| data=ordered(默认) | ~512MB/s | ~52K | 文件要么旧版要么完整,不撕裂 | 绝大多数业务数据的默认答案 |
| data=writeback | ~705MB/s | ~61K | 0 字节/半截/撕裂页都可能 | 只适合「可整体重算」的缓存/临时数据 |
4. 如果确实要 writeback 的性能,把持久性边界显式收回来
- 关键写入路径用
O_SYNC/O_DSYNC打开文件,或每写完一个文件显式fsync()(Python 里是f.flush(); os.fsync(f.fileno()),注意 只 flush 到 user space 是不算的); - 把「每批文件落盘后写一个 generation 标记文件并 fsync」作为原子边界,恢复时只信任标记之前的批次;
- 或者干脆别动挂载参数,用
dd if=... of=... bs=1M conv=fsync这类带 fsync 的批量落盘方式,把 38% 的吞吐收益换回 ordered 语义——这次事故的账算下来:重跑上游导出 + 人工对账 + 补数,花了约 2 人日,远超省下的 25 秒导出时间。
四、踩过的坑与反直觉之处
- fsck 通过 ≠ 数据没丢。 ext4 journal 只保证元数据一致,文件「存在且大小正确」是元数据层面的成功,内容层面它一句承诺都不给你。我们第一反应跑 e2fsck,看到 exit 0 差点收工。
- mtime 不能作为「数据已落盘」的证据。 时间戳是元数据的一部分,在 writeback 模式下它可能先于数据页提交。这次 73 个坏文件的 mtime 全部「漂亮」,直接误导了排查方向。
- 掉电后系统「看起来很好」是最危险的状态。 没有 fsck 报错、没有应用异常、服务正常拉起——因为坏的是数据,不是系统,而大多数监控只盯后者。
data=writeback的收益是全局的,风险是选择性的。 同一块盘上,临时缓存文件享受到了 38% 的提速,而上游快照这种「丢了就要重跑上游」的资产却承担了全部撕裂风险。挂载参数是盘级的,数据重要性是文件级的,这个错配本身就是设计缺陷。sync命令和fsync不是一回事,且sync也不等落盘完成。sync只是把所有 dirty 页提交给块层,真正等盘返回要用sync -f /dev/sdb1(针对指定文件系统)或fsync单个 fd。我们运维手册里原来写的是裸sync,这次也一起修了。
五、结论与可复用清单
- 默认用
data=ordered,别为了吞吐切 writeback;真要切,先回答「这块盘上最贵的数据能不能整体重算」,不能就配data=journal或显式 fsync 边界。 - 持久性的唯一硬边界是 fsync:应用层
write()成功、close()成功、sync命令执行完,都不算。 - 关键数据链路必须有端到端校验和(上游落 checksum 清单、下游比对),把「丢没丢」从事故后的猜测变成事故后的确认。
- 监控加两条:
Dirty/Writeback内存页水位(长期 >2GB 说明回写跟不上)、掉电/重启后首次启动的 journal replay 耗时(journalctl -k里 grep ext4,replay 超过 10s 说明断电时 dirty 数据量不小,值得复盘)。 - 运维手册里把「掉电/宕机后检查步骤」写成固定动作:先 dmesg 取证 → e2fsck -n → checksum 对账 → 再重启业务,顺序不能反。
一句话总结:journal 模式决定了掉电时你丢的是「一点尾数据」还是「半截文件」,而 fsync 决定了你的边界在哪——别把这两件事都交给内核的默认值和运气。