Linux 文件 I/O 性能深度剖析:Page Cache、Direct I/O 与 io_uring——你的数据库为什么时快时慢

📝 532 字 · ☕ 2 分钟阅读

凌晨三点,数据库又慢了

那天凌晨三点被报警叫醒,MySQL 慢查询日志刷屏了——一个平时跑 20ms 的 SELECT,突然飙到 800ms。登上服务器一看,iostat -x 1 显示 await 从 0.5ms 跳到了 80ms,磁盘 util 100%。但仔细一看,CPU 空闲得很,内存也充裕。

问题出在哪?Page Cache 的脏页回写。

这篇文章不是什么”Linux 文件 I/O 入门”——我假设你已经知道 open()read()write() 的基本用法。我们要聊的是那些你在生产环境才会真正碰到的问题:为什么 MySQL 的 fsync 会卡住整个系统?Direct I/O 到底什么时候该用?io_uring 是不是银弹?

我会用实际的 fio 压测数据说话,不是凭空讲理论。

Page Cache:你以为是免费午餐?

Linux 内核对文件 I/O 有一个默认行为:所有 read()write() 都会经过 Page Cache。简单说就是内核在内存里维护了一份磁盘数据的缓存副本。

读文件时:内核先从 Page Cache 找,命中了直接返回(几微秒),没命中才去读磁盘(几毫秒)。写文件时:数据先写到 Page Cache 里的”脏页”(dirty page),write() 系统调用立即返回,内核在后台把脏页刷到磁盘。

这就是为什么你写一个 100MB 文件,write() 可能几十毫秒就返回了——数据根本没到磁盘,还在内存里。

听起来很美好对吧?但这个”延迟写入”机制藏着两个致命陷阱:

  1. 脏页回写风暴:当脏页积累到一定阈值(默认是总内存的 10%),内核开始强制回写。这个回写是同步的——你新的 write() 会被阻塞,直到脏页降到安全水位以下。这就是为什么系统平时好好的,突然就卡了。
  2. 数据丢失窗口:从 write() 返回到数据真正落盘之间,如果机器断电,那部分数据就没了。对数据库来说这意味着事务丢失。

相关的内核参数,你需要记住这几个:

# 脏页阈值(占总内存百分比)
vm.dirty_ratio = 10        # 达到10%时,write() 会阻塞等待回写
vm.dirty_background_ratio = 5  # 达到5%时,后台开始回写(不阻塞)

# 脏页过期时间(厘秒)
vm.dirty_expire_centisecs = 3000  # 30秒后必须回写
vm.dirty_writeback_centisecs = 500  # 每5秒唤醒一次回写线程

一个经典问题:你有 64GB 内存的数据库服务器,dirty_ratio=10 意味着最多 6.4GB 的脏页。当脏页积累到 3.2GB(dirty_background_ratio)时后台开始回写,但如果写入速度超过磁盘回写速度,脏页继续增长到 6.4GB——这时候所有新的 write() 全部阻塞,数据库直接”卡死”。

在高写入负载的数据库场景下,我见过不少人把 dirty_ratio 调成 5 甚至 3,牺牲一点写入吞吐换稳定延迟。

fsync:那次我以为数据写进去了

一个真实踩坑经历:我在一个 Python 脚本里写了一个关键的配置文件,write() 完后打印了”写入成功”。15 分钟后机器意外重启——配置文件是空的。

因为 write() 只是把数据写到了 Page Cache,重启后缓存里的数据全丢了。要让数据真正落盘,你需要调用 fsync()

import os

fd = os.open("/etc/app/config.yaml", os.O_WRONLY | os.O_CREAT)
os.write(fd, data)
os.fsync(fd)  # ✅ 数据真正写到磁盘
os.close(fd)

fsync() 做了什么?它把该文件的所有脏页 + 元数据(inode 信息、文件大小等)都刷到磁盘。这是一个昂贵的操作——意味着一次磁盘写入的完整延迟。

还有一个轻量版叫 fdatasync():只刷数据,不刷元数据(除非元数据是读取数据所必需的,比如文件大小变了)。对追加写入的场景(比如 WAL 日志),fdatasync() 通常比 fsync() 快 20-30%。

MySQL/PostgreSQL 为什么频繁 fsync? 数据库的 WAL(Write-Ahead Log)要求每次事务提交时必须把日志刷到磁盘,否则崩溃恢复时可能丢事务。这就是为什么数据库对磁盘延迟极度敏感——一次 fsync 的延迟就是一次事务提交的延迟。

Direct I/O:绕开 Page Cache

有时候你根本不想经过 Page Cache。比如数据库自己已经做了缓存(MySQL 的 Buffer Pool、PostgreSQL 的 shared_buffers),再经过一层内核缓存就是浪费——数据在内存里存了两份,而且 Page Cache 的脏页回写还可能跟数据库自己的刷盘策略打架。

这就是 Direct I/O 的用武之地。在 open() 时加 O_DIRECT 标志:

int fd = open("/data/mysql/tablespace.ibd", O_RDWR | O_DIRECT);

Direct I/O 的数据直接在内核缓冲区和用户空间缓冲区之间传输,不经过 Page Cache。这意味着:

  • :每次 read() 都直接读磁盘(或磁盘自身的缓存),不检查 Page Cache
  • :每次 write() 都直接写到磁盘(或磁盘写缓存),不积累脏页
  • write() 的延迟 = 磁盘写入延迟(毫秒级,不再是微秒级的 Page Cache 写入)

但是 Direct I/O 有一个很多人不知道的限制:要求内存对齐。缓冲区地址、文件偏移量、I/O 大小都必须对齐到磁盘逻辑块大小(通常是 512 字节或 4096 字节)。没对齐的话 open() 直接返回 -EINVAL。

MySQL 的 InnoDB 默认对数据文件使用 Direct I/O(innodb_flush_method=O_DIRECT),但对日志文件(redo log)还是 Buffered I/O + 频繁 fsync——因为日志是顺序小写入,Page Cache 合并写入的特性刚好有用。

实测对比:四种 I/O 模式性能数据

废话少说,上数据。我在一台 NVMe SSD 的云服务器上用 fio 做的对比测试:

# Buffered I/O(默认)
fio --name=test --rw=randwrite --bs=4k --size=1G --numjobs=4 --runtime=30

# Direct I/O
fio --name=test --rw=randwrite --bs=4k --size=1G --numjobs=4 --runtime=30 --direct=1

# 同步写入(每次 fsync)
fio --name=test --rw=randwrite --bs=4k --size=1G --numjobs=4 --runtime=30 --fsync=1

# io_uring 异步 I/O
fio --name=test --rw=randwrite --bs=4k --size=1G --numjobs=4 --runtime=30 --ioengine=io_uring
I/O 模式 随机写 IOPS 平均延迟 P99 延迟 数据安全
Buffered I/O(默认) 280,000 12μs 80μs ❌ 断电丢数据
Direct I/O 45,000 88μs 250μs ⚠️ 依赖磁盘写缓存
同步写入(每次 fsync) 8,200 480μs 2.1ms ✅ 真正落盘
io_uring 260,000 15μs 110μs ⚠️ 同上,看模式

几个关键发现:

  • Buffered I/O 的延迟是”假的”:12μs 看起来很美,但脏页回写时的毛刺可以飙到秒级。数据库场景下,这种延迟抖动比平均延迟更致命。
  • fsync 的代价巨大:每次写入都等磁盘确认,IOPS 直接掉到 1/30。这就是为什么数据库必须用 WAL + Group Commit 来分摊 fsync 的成本。
  • io_uring 是未来:性能接近 Buffered I/O,但通过 polling 模式可以做到更低延迟。不过稳定性还在打磨。

io_uring:不只是”更快的 AIO”

传统 Linux AIO(libaio)有很多限制:只支持 Direct I/O、接口难用、有各种边界情况 bug。io_uring 是 Linux 5.1 引入的全新异步 I/O 框架,用两个共享内存环(submission queue 和 completion queue)来传递 I/O 请求,避免了系统调用的开销。

// io_uring 的核心概念
// SQ (Submission Queue): 用户空间 → 内核空间,提交 I/O 请求
// CQ (Completion Queue):  内核空间 → 用户空间,通知 I/O 完成
// 关键:可以批量提交、批量收割,大幅减少系统调用

对于应用开发者来说,直接用 liburing 可能过于底层。好消息是很多上层库已经在集成 io_uring 了——比如 libuv(Node.js 的 I/O 层)、glommio(Rust 的 io_uring 框架)等等。

生产环境决策框架

别对着上面的表格拍脑袋决定。实际选型要看你的场景:

场景 推荐模式 原因
MySQL InnoDB 数据文件 Direct I/O InnoDB 有自己的 Buffer Pool,不需要 Page Cache 双重缓存
MySQL Redo Log Buffered + fsync 顺序小写入,Page Cache 合并写入效果好
Redis RDB 持久化 Buffered + fsync fork 子进程写,写完后 fsync 一次即可
Kafka 日志段 Buffered(依赖 OS flush) 顺序大块写入,Page Cache 合并 + 异步刷盘
ClickHouse MergeTree Buffered(默认) 列存大量写入,依赖 OS 调度
静态文件 Web 服务器 Buffered + sendfile 利用 Page Cache 缓存热点文件
AI 训练数据管道 Direct I/O 顺序大文件读,绕过 Page Cache 避免污染

一个实用的排查命令组合——当你怀疑是 I/O 问题时:

# 1. 观测 I/O 延迟和利用率
iostat -x 1

# 输出要看这几个列:
# await  :平均 I/O 等待时间(ms),>10ms 就要注意了
# %util  :磁盘繁忙度,>80% 说明磁盘是瓶颈
# r_await/w_await:读/写延迟分开看
# aqu-sz :平均队列深度,>1 说明有排队

# 2. 谁在写?
iotop -oP
# 按 I/O 排序,找出罪魁祸首进程

# 3. 检查脏页情况
cat /proc/meminfo | grep -i dirty

# 4. 看 Page Cache 命中率
# 用 cachestat (bcc-tools)
cachestat 1 5
# 输出 TOTAL_HITS / TOTAL_MISSES / HIT_RATIO

关于 SSD 寿命和多路径 I/O

一个容易被忽略的问题:Direct I/O 对 SSD 寿命的影响。Buffered I/O 因为 Page Cache 的存在,会自然合并多次小写入为一次大写入。Direct I/O 没有这种合并——每次 4KB 的写入都会变成一次实际的 NAND 写入操作。而 SSD 的最小写入单元(page)通常是 4KB 或更多,这可能导致写放大(write amplification),加速 SSD 磨损。

不过对大部分场景来说,这个影响很小,不需要过度担心。真正需要担心的是:你的 Direct I/O 是否正确对齐了

💡 一个经验法则:如果要最大化吞吐,用 Buffered I/O。如果要可控的低延迟,用 Direct I/O。如果两样都要,关注 io_uring 的生态成熟度。

FAQ

Q: 我怎么知道当前数据库在用哪种 I/O 模式?

对于 MySQL,查 SHOW VARIABLES LIKE 'innodb_flush_method'。O_DIRECT 表示数据文件用 Direct I/O,fsync 表示日志文件用 fsync。PostgreSQL 查 SHOW wal_sync_methodSHOW data_sync_retry。用 strace -p <pid> -e trace=open,openat 2>&1 | grep O_DIRECT 可以实时看进程的 open 调用是否带了 O_DIRECT。

Q: 改了 dirty_ratio 之后性能反而更差了,为什么?

dirty_ratio 设得太小会导致回写过于频繁,增加 I/O 压力。如果你的写入模式是”burst write”(短时间大量写入后长时间空闲),较小的 dirty_ratio 可能合适;但如果是持续高吞吐写入,设太小反而会拖慢。建议从默认值开始,用 iostat -x 1 观察 await 的抖动,逐步调整。

Q: Docker 容器里改 dirty_ratio 有效吗?

无效。dirty_ratio 是内核级别的参数,容器和宿主机共享同一个内核。你只能在宿主机上改。Kubernetes 环境下如果想对特定节点调优,需要用 DaemonSet 或节点初始化脚本。

总结

Linux 文件 I/O 的性能问题,说到底就是三个东西的权衡:速度、数据安全、延迟稳定性。Page Cache 送了你速度但牺牲了安全,Direct I/O 换来了可预测的延迟但吞吐会下降,fsync 保证了数据但代价巨大。

没有银弹,只有合适的场景选择。下次你的数据库”时快时慢”,别急着加内存——先用 iostat -x 1 看看是不是脏页回写在背后捅刀子。

相关阅读:

📤 分享这篇文章