在 Linux 上调用 write() 返回成功,并不意味着数据已经写入磁盘——它只意味着数据被写进了页缓存(page cache)。真正的落盘由内核的回写(writeback)线程在后台异步完成。这个设计让写入延迟从磁盘量级降到内存量级,但也带来了两个必须理解的问题:数据何时真正持久化,以及当脏页堆积时系统会如何"自我刹车"。
本文沿一条写入路径走完整个生命周期:write() 如何进入页缓存、页如何变"脏"、脏页比例如何被限流、writeback 线程如何把它们冲刷到块设备、fsync 如何保证持久化,最后给出可落地的调优参数与观测方法。它与 https://plumephp.com/os-filesystem/(文件系统抽象)、https://plumephp.com/os-io-stack/(IO 栈全景)、https://plumephp.com/os-linux-memory/(内存管理)以及 https://plumephp.com/os-nvme-storage-stack/(块设备路径)构成完整闭环。
一、页缓存:文件在内存中的样子
1.1 为什么需要页缓存
磁盘随机访问比内存慢 4~5 个数量级。页缓存把最近读写的文件页留在内存里,让后续访问命中内存。它在 Linux 里是"统一"的:读、写、mmap 共享同一份缓存,这也是"文件即内存"哲学的基础。
1.2 address_space:缓存的组织者
每个 inode 都有一个 struct address_space,它是页缓存的核心抽象:
/* include/linux/fs.h */
struct address_space {
struct inode *host; /* 所属 inode */
struct xarray i_pages; /* 页索引:pgoff -> struct page */
unsigned long nrpages; /* 缓存页数 */
const struct address_space_operations *a_ops; /* 读写回写钩子 */
...
struct list_head private_list;
};
i_pages 早期是 radix tree,现在改为 xarray(基数树的一种,支持无锁读、更好的内存效率)。键是文件内的页偏移(pgoff_t),值是 struct page。这允许按文件偏移 O(log n) 定位任意页。
1.3 address_space_operations
文件系统通过一组回调把"通用页缓存"与"具体存储布局"解耦:
/* include/linux/fs.h */
struct address_space_operations {
int (*readpage)(struct file *, struct page *);
int (*writepage)(struct page *, struct writeback_control *);
int (*write_begin)(struct file *, struct address_space *, ...);
int (*write_end)(struct file *, struct address_space *, ...);
sector_t (*bmap)(struct address_space *, sector_t);
...
};
ext4、XFS、Btrfs 各自实现这些钩子。通用层只负责"什么时候调",文件系统负责"怎么写"。
1.4 页的四种状态
页缓存中的每个页有状态标志,决定了回写行为:
| 标志 | 含义 |
|---|---|
| PG_uptodate | 页内容有效(已从磁盘读入或已写满) |
| PG_dirty | 页被修改,与磁盘不一致 |
| PG_writeback | 正在被回写 |
| PG_locked | 正在被操作,其他路径需等待 |
理解这四个标志,就理解了页缓存并发控制的全貌。
二、脏页的产生与限流
2.1 一条 write 的路径
write(fd, buf, n)
→ vfs_write()
→ generic_file_write_iter()
→ filemap_get_pages() 获取/创建缓存页
→ copy_from_user() 把数据拷进页
→ __set_page_dirty() 标记 PG_dirty
→ 返回用户态(此时数据仅在内存)
关键点:数据拷贝完成后,write() 就返回了。此时如果断电,数据会丢失——除非你调用了 fsync()。
2.2 mmap 写也会变脏
通过 mmap 映射文件后直接写内存,页的 PTE 被标记为可写,写入触发页错误,内核把页标记为脏。msync() 相当于 mmap 世界的 fsync。
2.3 balance_dirty_pages:自我刹车
如果进程疯狂写入,脏页会无限增长,最终耗尽内存并造成巨大的回写风暴。内核用 balance_dirty_pages() 来限流:当一个进程把页弄脏时,内核检查全局与每设备(bdi)的脏页比例,若超过阈值就让该进程睡眠一段时间,等待回写线程把脏页冲下去。
/* mm/page-writeback.c */
static void balance_dirty_pages(struct bdi_writeback *wb,
unsigned long pages_dirtied, ...)
{
...
/* 超过 background_thresh 唤醒回写;
超过 dirty_thresh 则强制限流并睡眠 */
}
这是一个负反馈控制环:写得越快,睡眠越久,从而把脏页稳定在阈值附近。副作用是写入进程会出现"莫名其妙的停顿",在延迟敏感应用里表现为尾延迟抖动。
2.4 两个关键阈值
dirty_background_ratio:达到它就唤醒后台回写(但不阻塞写入进程)。dirty_ratio:达到它就阻塞写入进程,直到脏页降下来。
cat /proc/sys/vm/dirty_ratio # 默认 20(百分比)
cat /proc/sys/vm/dirty_background_ratio # 默认 10
三、回写机制:writeback 线程与 bdi
3.1 谁在冲刷脏页
内核为每个块设备维护一个 bdi_writeback(struct bdi_writeback),并有一组内核线程:
ps -e -o pid,comm | grep -E 'flush|writeback'
# kworker/u8:1+flush-8:0 ← 每设备 flush 线程
# kworker/0:1H-kblockd ← 块设备工作队列
线程命名规则是 flush-<major>:<minor>,8:0 就是第一块 SCSI/SATA 磁盘。kblockd 负责提交块层请求。
3.2 三个触发源
回写由三种情况触发:
- 周期性回写:内核周期性唤醒 flush 线程,冲刷"过期"的脏页(超过
dirty_expire_centisecs)。 - 阈值触发:脏页超过
dirty_background_ratio时唤醒。 - 同步请求:
fsync()、sync()、syncfs()显式要求。
3.3 回写路径
wb_workfn() ← flush 线程主循环
→ wb_do_writeback()
→ writeback_sb_inodes() ← 遍历该 bdi 上的 inode
→ __writeback_single_inode()
→ do_writepages()
→ a_ops->writepages() ← 文件系统实现
→ submit_bio() ← 提交到块层
文件系统的 writepages 会做两件事:分配磁盘块(若为延迟分配文件系统,如 ext4 的 delalloc)与构建 bio。这也是"延迟分配"文件系统的关键:磁盘块直到回写时才分配,能减少碎片。
3.4 回写控制结构
/* include/linux/writeback.h */
struct writeback_control {
long nr_to_write; /* 本次回写多少页 */
enum writeback_sync_modes sync_mode; /* WB_SYNC_NONE / WB_SYNC_ALL */
...
bool for_kupdate; /* 周期性回写 */
bool for_background; /* 后台回写 */
};
WB_SYNC_ALL 会等待每个页写完(fsync 用),WB_SYNC_NONE 则是尽力而为(后台回写用)。
四、fsync 的语义与持久化
4.1 fsync 到底保证了什么
fsync(fd) 的语义是:把该文件的所有脏数据与必要的元数据都写到持久存储,且完成后才返回。它包含三层:
- 数据页写到块设备。
- 文件元数据(大小、块映射)写到块设备。
- 块设备缓存(易失写缓存)被冲刷,即 cache flush / FUA。
第 3 点最容易被忽略:即使数据到了磁盘的 DRAM 缓存,断电仍可能丢失。所以 fsync 必须在块层发出 FLUSH 或使用 FUA(Force Unit Access)写。
4.2 fsync、fdatasync、sync 的区别
| 调用 | 数据 | 元数据 | 范围 |
|---|---|---|---|
| fsync | 是 | 是(含 mtime 等) | 单文件 |
| fdatasync | 是 | 仅影响数据读取的元数据(如文件大小) | 单文件 |
| sync | 是 | 是 | 全系统所有文件系统 |
| syncfs | 是 | 是 | 指定文件系统 |
fdatasync 比 fsync 快,因为它跳过不影响数据完整性的元数据(如访问时间)。数据库 WAL 场景常用 fdatasync。
4.3 O_SYNC 与 O_DSYNC
int fd = open("wal.log", O_WRONLY | O_APPEND | O_DSYNC);
O_SYNC:每次 write 都等同 fsync(数据+全部元数据)。O_DSYNC:每次 write 都等同 fdatasync。
它们让代码更简单,但每次写都同步,吞吐会大幅下降(通常从 GB/s 掉到几百 MB/s)。更好的做法是批量写 + 周期性 fsync。
4.4 一个常见的错误模式
/* 反例:每行都 fsync,吞吐极低 */
for (int i = 0; i < N; i++) {
write(fd, line[i], len[i]);
fsync(fd); /* 每条记录一次磁盘同步 */
}
/* 正例:批量写,按事务边界 fsync */
for (int i = 0; i < N; i++)
write(fd, line[i], len[i]);
fsync(fd); /* 一次同步覆盖一批 */
数据库的 group commit 就是这个思路:把多个事务的 fsync 合并成一次。
五、脏页限流参数与调优
5.1 主要参数
# 比例型(占可用内存百分比)
/proc/sys/vm/dirty_ratio # 默认 20,硬限流阈值
/proc/sys/vm/dirty_background_ratio # 默认 10,唤醒后台回写
# 字节型(覆盖比例型,二者取较小值生效)
/proc/sys/vm/dirty_bytes
/proc/sys/vm/dirty_background_bytes
# 时间型
/proc/sys/vm/dirty_expire_centisecs # 默认 3000(30 秒),多久算"过期"
/proc/sys/vm/dirty_writeback_centisecs # 默认 500(5 秒),回写线程唤醒间隔
5.2 大内存机器的经典陷阱
在 256GB 内存的机器上,dirty_ratio=20% 意味着允许 51GB 脏页。当系统开始回写这 51GB 时,写入进程会被长时间阻塞,出现"卡顿数十秒"的现象。这是大内存服务器上最经典的问题。
解法是改用字节型阈值:
sysctl -w vm.dirty_background_bytes=$((256*1024*1024)) # 256MB
sysctl -w vm.dirty_bytes=$((1024*1024*1024)) # 1GB
注意:设置 dirty_bytes 会自动把 dirty_ratio 清零(二者互斥)。
5.3 不同场景的取舍
| 场景 | 调优方向 | 理由 |
|---|---|---|
| 数据库/事务 | 减小 dirty_bytes,缩短 fsync 间隔 | 降低断电丢失窗口 |
| 大文件顺序写 | 增大阈值 | 允许更大的写合并,提升吞吐 |
| 延迟敏感服务 | 减小 dirty_background_bytes | 避免回写风暴拖尾延迟 |
| 桌面/交互 | 增大 dirty_expire_centisecs | 减少磁盘唤醒,省电 |
5.4 cgroup 级回写
现代内核支持 per-cgroup 回写限速(io.max 的 wb 参数),可以给容器单独限流,避免一个容器写爆整个设备的脏页预算:
# 给 cgroup 限制回写带宽(字节/秒)
echo "8:0 wbps=104857600" > /sys/fs/cgroup/<path>/io.max
六、观测与常见误区
6.1 观测脏页与回写
# 全局脏页数量(单位:页)
grep -E '^(Dirty|Writeback|NFS_Unstable):' /proc/meminfo
# 每设备回写统计
cat /sys/block/nvme0n1/stat
grep -E 'writeback|dirty' /proc/vmstat
# 用 iostat 看磁盘实际写入吞吐
iostat -x 1
# 追踪某个进程的 fsync 调用
strace -T -e trace=fsync,fdatasync,write ./app 2>&1 | tail
Dirty 持续接近 dirty_bytes 时,说明限流已生效,写入进程正在被阻塞。
6.2 用 ftrace 观测回写延迟
cd /sys/kernel/debug/tracing
echo 1 > events/writeback/enable
cat trace_pipe
# 可看到 writeback_dirty_page / writeback_written 事件与耗时
6.3 五个高频误区
“write 成功就是写完了”。write 只保证进了页缓存。要持久化必须 fsync。
“fsync 一定安全”。若块设备(或虚拟机磁盘)谎报 FLUSH 已执行,fsync 后断电仍会丢数据。这是"fsync gate"类事故的根源,需要
blkdev层与硬件都诚实。“关了 write cache 就没问题”。关闭磁盘写缓存能保证顺序,但吞吐会显著下降;更好的方案是带电池保护的 RAID 卡缓存。
“O_DIRECT 就没有页缓存问题”。
O_DIRECT绕过页缓存,但仍要自己处理对齐、并发与持久化(通常配合fdatasync)。“脏页越多越好”。脏页越多,回写风暴越猛,尾延迟越差。大内存机器务必改用字节型阈值。
6.4 一个诊断小清单
# 1) 有没有脏页堆积
awk '/^Dirty/ {print $2/1024 " MB dirty"}' /proc/meminfo
# 2) 回写是否在跑
ps -e -o comm | grep -c flush
# 3) 谁在写
iotop -oPa
# 4) 磁盘是否成为瓶颈
iostat -x 1 3 | tail -20
结语
页缓存与回写机制的核心矛盾是"性能"与"持久性":缓存让写入快,回写让数据最终落盘,而限流让系统不至于被自己的脏页拖垮。理解这条链路上的每个角色——address_space 组织页、balance_dirty_pages 刹车、bdi_writeback 冲刷、fsync 保证持久——才能回答"数据到底丢不丢"“为什么写入会卡"这两个最实际的问题。
落到工程上,记住三点:写入快路径靠缓存,持久化靠 fsync,稳定靠字节型脏页阈值。配合 https://plumephp.com/os-high-performance-io/ 里的直接 IO 与异步 IO 手段,就能为不同负载选择正确的写入模型。
延伸阅读
- Linux Kernel Documentation:
Documentation/filesystems/vfs.rst(address_space 与 a_ops) - Linux 内核源码:
mm/page-writeback.c(脏页限流)、fs/fs-writeback.c(回写主循环) - Linux Kernel Documentation:
Documentation/admin-guide/sysctl/vm.rst(dirty_* 参数详解) - 《Linux Kernel Development》第 16 章页缓存与第 15 章地址空间(Robert Love)
- Linux 内核源码:
mm/filemap.c(page cache 读写路径)与fs/direct-io.c
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。