ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

原子缓冲写:Linux内核如何消除page cache与回写撕裂

原子缓冲写:Linux内核如何消除page cache与回写撕裂

接上一篇把背景铺开之后,这篇聊聊原子缓冲写(atomic buffered writes)的实现细节。直接 I/O 的原子写已经有了 RWF_ATOMIC 这套规范,但缓冲写路径一直没人做干净:一边是 page cache 本身会部分更新,一边是后台回写可能把一段数据拆成多个 bio,掉电之后文件里咬着新旧两代数据的情况很难彻底避免。这篇文章不贴补丁全文,而是把“为什么这么难、现在走到哪一步、我实测时踩过哪些坑”讲清楚,适合关心内核存储栈的人,也适合后端同学理解数据库/消息队列底层的写放大和一致性来源。

1. 原子缓冲写的难点拆解:先弄清楚撕裂到底发生在哪

1.1 撕裂写不是数据库的专属问题

平时说“原子写”,大多数人第一反应是数据库需要它。确实,数据库页、WAL、索引分裂这些场景都怕文件内容在崩溃恢复后变成“新头旧尾”的缝合怪。但撕裂写其实比我们想的更普遍。

我见过一个很典型的翻车案例:有人用文本文件保存程序配置,几百字节的内容,写入时直接 overwrite。断电之后文件前半截是新的,后半截还是旧的,程序重启解析到一个既不是新配置也不是旧配置的中间状态,直接起不来。传统解决办法是“写新文件再 rename”,但这种方式在需要覆盖文件元数据或者做人见人爱的 O_DSYNC 同步时并不总能覆盖所有场景。对多副本存储、分布式日志、本地 KV 引擎来说,撕裂写意味着恢复逻辑必须额外做校验和+版本对比,复杂度一下子就上来了。

1.2 直接 I/O 的原子写已经做了什么

RWF_ATOMIC 是这几年推进比较快的一块。应用发起 pwritev2 时带上这个 flag,并保证偏移和长度满足设备与文件系统给出的对齐约束,内核存储栈就会尝试把这次写作为一个不可分割单元下发。底层 NVMe 有原子写能力,SCSI 也有类似定义,块层把这些能力统一暴露出来,文件系统再配合做映射和日志处理。

这个流程里最关键的点是:直接 I/O 路径下,应用数据从用户态一次性进入 bio,中间没有 page cache 这个“搅局者”。只要块设备确实兑现原子性,一个 bio 要么完整落盘,要么完全不落盘,恢复后不会出现半个 bio 的碎片。所以直接 I/O 原子写在协议层相对干净。

但问题也很明显:现代应用大量采用 buffered 模式,指望 page cache 做读聚合、写合并、延迟回写。硬要改成 O_DIRECT,要么靠工程师手动管理缓存,要么牺牲掉很多场景下的吞吐。这也是为什么“缓冲路径的原子写”会成为一个独立话题。

1.3 缓冲路径里藏着两个撕裂点

缓冲写和原子性之间的矛盾,根源在于数据要经过两个完全不同的阶段。

第一段是从用户态拷贝到 page cache。write() 系统调用并不是直接落盘,它先把这几十 KB 数据刷进内存页。此时如果回写线程恰好在某个时机把页写出去,可能出现一个页里只有前几个扇区是新的、后面还是旧内容的情况。更微妙的是,这个写回过程对应用不可见,应用甚至可能在同一个页面后续再改写一部分数据,导致页缓存里记录的内容从来就没有以一个整体出现过。

第二段是后台回写落盘。一个 4KB 页在构造 bio 时,可能因为块层合并、文件系统延迟分配等原因被拆成多个 bio,也可能在设备的原子写边界处被截断。每个 bio 完成时机不同,掉电就可能让其中一部分持久化。即便底层设备对单个 bio 有原子保证,只要上层把一次用户写拆成两个 bio,原子性就无从谈起。

所以缓冲原子写不能只依赖硬件能力,必须从 page cache 管理到回写 bio 构造整条路径上做约束。理解了这两个撕裂点,再看后续补丁设计就会觉得每一处改动都不是无的放矢。

2. RWF_ATOMIC 进入缓冲路径:接口、对齐与 iomap 的实现思路

2.1 用户侧接口与对齐约束

无论是直接 I/O 还是将来的缓冲 I/O,用户侧入口大概率还是那套 RWF_ATOMIC 协议,而不是另起炉灶发明新系统调用。

应用要做的准备工作很明确:先通过 statx 拿到文件系统支持的原子写参数,主要包括最小原子写单元(unit_min)、最大原子写单元(unit_max)和单次原子写允许的最大分段大小(segments_max)。然后让本次读写的 fd 偏移和长度都对齐到这些值上。偏移和长度不是对齐的整数倍,内核直接返回 EINVAL,连碰都不会碰设备。

实际开发中容易忽略一点:对齐的是“文件偏移”而不是“内存地址”。用户态 buffer 是否对齐也有影响,如果底层要走 direct 路径的话,但缓冲写路径里内存对齐的约束主要来自后续 folio 的处理和可能的硬件 DMA 限制,接口规范上通常建议用户侧尽量两者都对齐,省得踩到边缘情况。

statx 拿到能力之后,再用类似这样的写法做一次最小验证:

#define _GNU_SOURCE #include <fcntl.h> #include <linux/stat.h> #include <sys/stat.h> #include <sys/uio.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> int main(int argc, char **argv) { if (argc != 2) return 1; struct statx stx; int fd = open(argv[1], O_RDWR); if (fd < 0) { perror("open"); return 1; } if (statx(fd, "", AT_EMPTY_PATH | AT_STATX_SYNC_AS_STAT, STATX_ALL, &stx) < 0) { perror("statx"); return 1; } if (!(stx.stx_mask & STATX_ATOMIC_WRITE)) { printf("该文件系统未暴露原子写能力\n"); return 0; } printf("unit_min=%llu unit_max=%llu seg_max=%llu\n", stx.stx_atomic_write_unit_min, stx.stx_atomic_write_unit_max, stx.stx_atomic_write_segments_max); size_t len = stx.stx_atomic_write_unit_min; void *buf; if (posix_memalign(&buf, 4096, len) != 0) return 1; memset(buf, 0xa5, len); struct iovec iov = { .iov_base = buf, .iov_len = len }; long ret = pwritev2(fd, &iov, 1, 0, RWF_ATOMIC); if (ret < 0) { printf("pwritev2: %s\n", strerror(errno)); } else { printf("write ok: %ld bytes\n", ret); } free(buf); close(fd); return 0; }

这段代码故意做得简洁,重点在于演示对齐参数和 flag 的配合方式。RWF_ATOMIC 在较老的内核头文件里可能没有定义,需要同步更新 uapi;不同内核版本之间 statx 字段也有细微调整,编译时以手头头文件为准。

2.2 缓冲写路径怎么把一次写变成一个整体

前面说过,直接 I/O 的原子写天然就是“一个 bio 对应一个用户请求”。缓冲写难在 page cache 是异步、分散的,回写时它不知道用户原来是怎么写入的。

相关补丁的核心思路,是让文件系统在映射阶段就把“这个范围是原子的”这个信息记录到 folio 上。用户带 RWF_ATOMIC 写进来时,iomap 层不是在页缓存里随便找一个位置拷贝数据,而是检查这次写能否映射到连续的物理块;同时给对应的 folio 打上原子写标记,锁定这个范围,不允许后台线程把它拆散处理。

到回写流程时,文件系统看到原子标记,会把这个范围内的所有块集中到一个 bio 里下发,并使用设备的原子写能力。如果原子范围太大,超过设备允许的单次原子长度,要么直接报错,要么按 segment_max 边界切分并确保每个 segment 本身也是原子的。这样应用看到的语义就是:一旦某次写“真正持久化”,它一定是整段落盘的。

这里要注意,缓冲写的原子性并不能保证 write() 一返回就落盘。应用如果要求写完之后立即持久化,仍然要配合 fsync/fdatasync。原子写保证的是持久化时刻的一致性,而不是持久化时机。

2.3 为什么不能简单粗暴地“先落盘再返回”

有人会问:那我在 write() 时检测到 RWF_ATOMIC,直接强制回写,等落盘成功再返回,不就把问题解决了?

技术上当然可以,但这等于让缓冲写退化成同步写。一次原子写可能涉及几十 KB 甚至更大范围,每次都要等设备块的完整写周期,写放大和延迟都会失控。更关键的是,page cache 里可能还有其他数据指向同一个 folio,强制 writeback 并且要独占这个 folio,会造成大量锁竞争和写阻塞。

所以内核补丁没有往“同步刷盘”这个方向走,而是保持了缓冲写异步回写的基本模型,只在关键节点上增加原子性约束。这与 O_DSYNC 的定位完全不同:O_DSYNC 保证“写返回后数据已在持久化域”,原子写保证“持久化后不会出现缝合数据”,两者是正交的。

2.4 与文件系统日志机制的配合

文件系统本身有日志,ext4 的 ordered 模式、XFS 的日志提交都一直在处理崩溃一致性。有人误以为“已经有日志了,原子缓冲写就是多此一举”,这是混淆了两种保证。

日志机制主要保证元数据和文件系统结构的一致性,让挂载不会坏、文件大小和引用计数不会错乱;它同时也能通过 journal 回调保证一定的数据顺序,但并不会为“用户在某一时刻写入的一段数据是否整体出现”提供严格承诺。data=journal 模式能把数据也放进日志,代价是数据写两遍、性能损失明显,很多生产环境根本不开。

原子缓冲写想要解决的正是 ordered 模式与 data=journal 之间的那个空档:既要像 data=journal 那样避免数据撕裂,又想避免双写开销。它的落地依赖文件系统在日志提交和回写路径里同时处理原子 meta 信息,这比单纯改 iomap 要复杂得多。

3. 实操验证:从检查设备能力到崩溃一致性测试

3.1 先看设备有没有原子写能力

在真机上验证之前,先查两个地方:block 层暴露的队列参数,以及文件系统通过 statx 给出的原子写参数。

block 层参数在 sysfs 里:

cat /sys/block/nvme0n1/queue/atomic_write_max_bytes cat /sys/block/nvme0n1/queue/atomic_write_unit_min cat /sys/block/nvme0n1/queue/atomic_write_unit_max cat /sys/block/nvme0n1/queue/atomic_write_boundary

这几个文件不是每个块设备都有,很多老内核或者虚拟设备根本没有。没有也不代表绝对不能用,但说明至少当前环境里块层没有暴露可用的原子能力。

文件系统层面用 statx 拿参数。注意不是所有文件系统都已经接入,ext4 和 XFS 的跟进进度就不一样,btrfs 也有自己的实现路数。实际测试时建议先在确认支持的文件系统上做,否则容易把“文件系统不支持”误判成“对齐参数写错”。

我自己踩过的坑是:最开始拿一个 loop 设备测,sysfs 里什么都看不到,折腾半天才意识到 loop 层根本没透传底层设备的原子写能力。要用真机 NVMe,或者用支持原子能力的模拟器/虚拟机实现,否则测试结果没有参考意义。

3.2 最小原子缓冲写测试

上面那段 C 代码可以作为起点,跑通之后再做几件事:把长度从 unit_min 逐渐增大到 unit_max,看哪些长度会被拒绝;把偏移设成非对齐,确认会返回 EINVAL;对比同一个文件在普通 pwritev2 和 RWF_ATOMIC 下的行为差异。

如果只是快速验证,Python 的 ctypes 也行,但要注意 Python 的写路径可能和 C 的 libc wrapper 行为不完全一样,最好还是用 C 或者直接用编译工具链里的测试程序。

一个容易被忽略的细节:即使带了 RWF_ATOMIC,文件可能仍然没有开启 O_DIRECT,走的还是 buffered 路径。这正是目标场景。如果代码里同时加了 O_DIRECT,那测出来的其实是旧有 direct I/O 原子写,对验证缓冲写路径没有意义。

3.3 用 dm-flakey 模拟掉电场景做一致性检查

验证原子写不能只看 write 是否返回成功,最有效的方法是制造崩溃。真实断电测试成本高、复现难,我一般用 dm-flakey 这类设备映射工具,在块层随机丢弃或翻转一部分写请求。

大致流程是:准备一个小分区,把它作为 flakey 设备的底层映射,在这个设备上建文件系统;然后用脚本循环写入固定模式的数据,每写一轮就触发一次模拟故障;重启或者重新挂载后,读回数据,检查是否出现“既有新字节又有旧字节”的混合状态。

需要注意,dm-flakey 的丢写行为是块粒度的,它模拟的是 bio 层面的部分完成,不能精确模拟“某个字节写了一半”的介质状态。但对于验证缓冲原子写的核心保证已经够了:我们关心的是回写时一个原子范围是否被拆成多个独立 bio。只要发现恢复后文件出现混搭,就说明要么底层不支持 atomic,要么上层 bio 被拆开了。

这种测试需要对比基线:先在普通缓冲写模式下跑同一套流程,大概率能观测到撕裂;再在带 RWF_ATOMIC 的模式下跑,如果一次都测不到撕裂,才算初步通过。不要拿一两次成功就当结论,至少反复随机几十轮。

3.4 性能印象:原子写不是免费的

从现有补丁的反馈来看,原子缓冲写在正常路径上会有额外开销,主要来自三块:对齐检查与 folio 标记、回写时 bio 合并的限制、以及文件系统为维护原子语义多出来的锁定逻辑。

实测下来,普通缓冲写随机 4K 能做到很高的 IOPS,但带上原子标记之后,性能会往直接 I/O 的小块随机写靠拢。尤其当应用频繁写小于最小原子单元的范围时,写放大明显,设备原子写单元一般不会小于 4K,甚至可能是 64K。所以不要把所有写都无脑带 RWF_ATOMIC,应该只对确实需要防撕裂的关键数据块开启。

我目前的实践是:热数据走普通缓冲写,只有元数据、配置块和日志提交点走原子缓冲写。这样既保留 page cache 的提速效果,又把原子性的性能成本控制在可接受范围。

4. 高频问题的排查与避坑速查

4.1 常见问题速查表

现象大概率原因处理建议
pwritev2 返回 EINVAL偏移或长度不满足对齐要求用 statx 读 unit_min/unit_max,重新对齐
返回 EOPNOTSUPP文件系统或设备不支持原子写换个文件系统,或确认内核版本
statx 里没有原子能力字段内核太老,或文件系统未实现更新内核,检查文件系统类型
sysfs 里看不到 atomic_write_*块设备/驱动未暴露能力换 NVMe 设备,或检查是否被 RAID/loop 挡掉
与 O_DSYNC 同时使用行为怪两种语义叠加导致回写路径更复杂分开使用,原子写关注“不撕裂”,同步写关注“何时落盘”
文件同时被 mmap 写入mmap 修改绕过原 write 路径,folio 状态复杂避免同一文件混合使用 mmap 和原子缓冲写
崩溃恢复后仍然有撕裂底层原子能力没透传,或回写 bio 被拆用 dm-flakey 抓回写路径,检查是否命中 atomic 标记

4.2 日志模式救不了命:一个容易误导的点

ext4 的 ordered 模式在很多人印象里是“安全的”,但它主要保证“元数据不会指向未写的数据”,对数据页本身的覆盖并不做原子性保证。即使开了 ordered,一个 4K 数据页依然可能以两个扇区为单位被部分写坏,文件系统日志并不会为此重建数据块。

所以如果应用拿着 O_DSYNC 当原子写用,本质上只是保证数据顺序,并没有解决撕裂。等到原子缓冲写完善后,正确姿势是:关键 block 用 RWF_ATOMIC + fdatasync,普通 block 保持普通 write 交给 page cache 合并。

4.3 为什么一个 4K 写入会被拆成两个 bio

缓冲回写时,bio 的构造受很多因素影响:文件系统的延迟分配、块设备的 chunk 边界、iomap 的映射范围、folio 是否被写时复制等等。有时候文件系统把逻辑地址映射成了两个物理上不相邻的 extent,一个 bio 装不下;有时候块层因为合并策略主动把一个 bio 切到设备队列的不同 segment。

原子写要求物理块也必须连续,这一点在碎片化的文件系统上可能变得异常困难。文件系统实现里有没有做“原子写映射失败时回退”的逻辑,会直接影响用户的体验。目前看到的方案倾向于:如果不能保证连续映射,直接让这次写返回错误,而不是悄悄降级成普通写。这个行为对应用是友好的,至少用户可以感知并调整。

4.4 缓冲写与 mmap 的冲突

同一文件如果用 mmap 映射并修改,修改内容直接在页缓存里改,不会经过 pwritev2 的原子写路径。如果随后又一个原子缓冲写涉及同一 folio,就可能把这个 folio 的状态搞复杂:一部分数据是 mmap 改的,一部分是原子写拷进来的,回写时到底怎么保证原子范围?

最稳妥的方案是不要在同一文件上混用两种访问方式。至少在原子写范围内,保证没有其他线程同时 mmap 并修改相同偏移。如果你的业务模式必须 mmap,那就考虑把 mmap 区域和原子写区域分开存放在不同文件,或者接受暂时不支持。

5. 后续可以怎么扩展:不要把原子缓冲写当万能药

5.1 最值得期待的场景

我目前最看好的是数据库和消息队列的“双写”场景。很多系统为了防撕裂,都是先写日志、再写数据,靠日志重放来修复。有了原子缓冲写之后,某些小元组更新可以直接原子覆盖,不需要额外日志。这能减少日志写放大,也能简化恢复流程,因为“覆盖一半”这个失败模式从根上消失了。

另一个常被忽略的场景是运行时配置更新。配置文件往往只有几百字节,用原子写覆盖整个文件,比“临时文件 + rename + 权限处理”简单得多,而且能保证旧配置被完整保留或新配置完整生效,不会卡在中间状态。对于边缘设备这类“必须断电安全”的系统来说很实用。

5.2 仍然需要应用层校验

原子写并不等于“数据不会丢失”。掉电时可以保证“要么旧的要么新的”,但设备偶尔也会撒点小谎:宣称完成了原子写,实际因为固件 bug 造成数据错位。块层这几个 sysfs 参数暴露的是设备能力,不是设备保障,生产环境还是要在应用层保留校验和。

我自己经历过一次:某批盘片宣称支持 64K 原子写,实际在特定固件版本下掉电会丢最后一段数据。应用层带 CRC 之后才发现低概率的数据损坏。原子写是降低复杂度的工具,不是免死金牌。

5.3 后续方向:小步验证,再铺开

如果你准备在自己的系统里用上原子缓冲写,我的建议是先选一块影响面最小的模块试点,比如配置文件或者单页元数据更新。跑一段时间抓崩溃日志,确认没有撕裂后,再把数据库的关键页更新切过来。

不要一开始就在所有写路径上叠加 RWF_ATOMIC,那样性能损失和数据风险都不可控。接口本身还在演进,文件系统实现也会继续修 bug,追新内核版本测试时对行为变化要有心理准备。我从去年开始做这部分验证,最大的体会是:原子性是存储层给应用的一份“有限承诺”,把它用在刀刃上,效果比无脑铺满好得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进