有个日志收集服务每天写 200GB,部署 6 个月后磁盘 IO util 持续 95%、p99 延迟从 5ms 涨到 200ms。看代码"明明用了缓冲和异步写",问题出在 Page Cache 被频繁脏页回收。本文把 Linux Page Cache、Dirty Page、O_DIRECT、fsync 这些 IO 底层概念讲透,附实测数据。
故障现象
$ iostat -x 1
Device r/s w/s rkB/s wkB/s avgqu-sz await %util
nvme0 12 8500 256 320000 85 45 95
$ vmstat 1
procs ----memory---- ---swap-- -----io---- -system-- ----cpu----
r b free buff cache si so bi bo in cs us sy id wa
4 12 2.1G 500M 60G 0 0 0 320M 120k 80k 20 35 5 40
# wa(IO wait) 40%,bo 写出 320MB/s,b(blocked) = 12 个进程被 IO 卡住
# cache 60GB,几乎用满
Linux Page Cache 模型
关键概念:
- Page Cache: 文件 IO 在内存中的缓存层,默认占用所有空闲内存
- Dirty Page: 内存中已修改但还没写盘的页
- pdflush / kworker: 后台线程定期把脏页刷盘
- write() 系统调用默认只写到 Page Cache,不到磁盘
- fsync() 强制刷盘,保证落盘
应用写文件流程:
1. write() → Page Cache(快,微秒级)
2. Page Cache 标记 dirty
3. 后台 pdflush 异步刷盘(可控)
4. 或应用主动 fsync() 同步刷盘(慢,毫秒级)
关键内核参数
# 看当前 dirty 配置
$ sysctl -a | grep dirty
vm.dirty_background_ratio = 10 # 脏页占内存 10%,后台开始刷
vm.dirty_ratio = 20 # 脏页占内存 20%,应用 write 阻塞,强制刷
vm.dirty_expire_centisecs = 3000 # 脏页超 30 秒强制刷
vm.dirty_writeback_centisecs = 500 # pdflush 每 5 秒唤醒一次
# 实时看脏页数量
$ cat /proc/meminfo | grep -i dirty
Dirty: 1234567 kB
Writeback: 456789 kB # 正在写盘的页
$ watch -n 1 'cat /proc/meminfo | grep -E "Dirty|Writeback|Cached"'
问题诊断:用 iotop / perf / blktrace
# iotop 看哪个进程在猛写
$ iotop -bod 1 -t -P
PID USER DISK READ DISK WRITE COMMAND
12345 app 0B/s 250 M/s java -Xmx16g -jar log-collector.jar
12346 kworker 0B/s 180 M/s [kworker/u8:2-events_unbound]
# ↑ kworker 在帮你刷脏页
# perf 看系统调用
$ perf record -e syscalls:sys_enter_write -p 12345 -- sleep 30
$ perf report
# blktrace 跟踪块设备 IO
$ blktrace -d /dev/nvme0n1 -o trace -w 30
$ blkparse trace.blktrace.0 | head -50
# pidstat 看 IO 详情
$ pidstat -d 1
12:00:01 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
12:00:02 0 12345 0.00 256000.00 0.00 245 java
问题 1:小写多次 → Page Cache 被瞬间打满
// 错:每个日志记录写一次磁盘
public void logEntry(String json) {
try (FileWriter fw = new FileWriter("/data/log.txt", true)) {
fw.write(json + "\n");
// 每次 open + write + close,大量系统调用 + Page Cache 抖动
}
}
// 对:批量缓冲 + 大块写
public class BatchLogger {
private final BufferedWriter writer;
private final List<String> buffer = new ArrayList<>(1000);
public BatchLogger() throws IOException {
// 64KB 缓冲,自动批量刷
writer = new BufferedWriter(new FileWriter("/data/log.txt", true), 64 * 1024);
}
public synchronized void logEntry(String json) throws IOException {
buffer.add(json);
if (buffer.size() >= 1000) {
flush();
}
}
private void flush() throws IOException {
for (String line : buffer) {
writer.write(line);
writer.newLine();
}
writer.flush(); // 只刷到 Page Cache,不刷盘
buffer.clear();
}
}
问题 2:误用 fsync 频繁刷盘
// 错:每次写都调 fsync,性能崩
public void writeAndSync(String data) throws IOException {
try (FileOutputStream fos = new FileOutputStream("/data/log.txt", true);
FileChannel ch = fos.getChannel()) {
ch.write(ByteBuffer.wrap(data.getBytes()));
ch.force(true); // ← fsync,每次都是同步刷盘
}
}
// 对:仅在关键节点 fsync
public class JournalLogger {
private final FileChannel channel;
public void append(byte[] data, boolean important) throws IOException {
channel.write(ByteBuffer.wrap(data));
if (important) {
channel.force(false); // false = 只刷数据,不刷元数据(更快)
}
}
// 定时 fsync,而不是每次
@Scheduled(fixedRate = 5000)
public void periodicFlush() throws IOException {
channel.force(false);
}
}
// 对比:fsync 性能(NVMe SSD)
// 每条记录 fsync: 500 ops/s
// 每秒 fsync 一次: 50w ops/s,数据丢失风险 1 秒
问题 3:大文件读放凉了 Page Cache
业务场景:每天凌晨备份 50GB 文件
cp /data/db.bin /backup/
# 这一次操作把 50GB 数据塞进 Page Cache
# Page Cache 把热数据(在线业务的索引)挤出去
# 备份后业务读 IO util 飙升,因为索引要重新从盘上读
# 对:大文件读用 posix_fadvise 告诉内核别缓存
$ cat backup.c
#include <fcntl.h>
int fd = open("/data/db.bin", O_RDONLY);
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
// 读完之后这个文件页就会被立即丢弃
# rsync 自带 --drop-cache 选项(部分发行版)
$ rsync --drop-cache /data/db.bin /backup/
# 或用 dd 替代 cp(可控)
$ dd if=/data/db.bin of=/backup/db.bin bs=1M oflag=direct
# O_DIRECT 完全绕过 Page Cache
O_DIRECT vs O_DSYNC vs fsync
模式 Page Cache 立即落盘 适用场景
=========================================
普通 write 用 不 通用
write+fsync 用 是 数据库 WAL
O_DSYNC 用 是 类似 fsync,每写都同步
O_DIRECT 不用 应用控制 数据库自管缓存(MySQL InnoDB)
O_DIRECT+fsync 不用 是 最严格,极少用
性能(NVMe SSD,4K 块):
write 异步 1.5 GB/s
write + 周期 fsync 1.2 GB/s
write + 每次 fsync 50 MB/s ← 慢 30 倍
O_DIRECT 800 MB/s ← 比 Page Cache 慢,但更可控
O_DIRECT + fsync 600 MB/s
调优案例:日志收集服务
# 改造前
- 每条日志 fwrite + fflush
- 后台 fsync 每 100ms
- 200GB / 天写入
- IO util 95%, p99 200ms
# 改造措施
1. 应用层 64KB 缓冲,1000 条批写
2. fsync 改为每 5 秒 (异步线程做)
3. 日志文件用 mmap 写(更直接)
4. 调整内核参数
echo 5 > /proc/sys/vm/dirty_background_ratio
echo 10 > /proc/sys/vm/dirty_ratio
echo 1000 > /proc/sys/vm/dirty_expire_centisecs
# 改造后
- IO util 25%, p99 8ms
- 同样 200GB / 天,但能再扛 5 倍流量
mmap 写文件
// mmap 把文件映射到内存,写内存即写文件
// 适合大文件,顺序写,延迟敏感
public class MmapLogger {
private final MappedByteBuffer buffer;
private final FileChannel channel;
private long writePos = 0;
private static final int SIZE = 1024 * 1024 * 1024; // 1GB 映射
public MmapLogger(String path) throws IOException {
RandomAccessFile raf = new RandomAccessFile(path, "rw");
raf.setLength(SIZE);
channel = raf.getChannel();
buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE);
}
public synchronized void write(byte[] data) {
buffer.position((int) writePos);
buffer.put(data);
writePos += data.length;
}
public void flush() throws IOException {
buffer.force(); // mmap 的 fsync
}
}
// mmap 优势:
// - 零拷贝(write 系统调用要 user → kernel 拷贝)
// - 文件页可以被多进程共享
// - 顺序写最快
// mmap 注意:
// - 进程崩溃时未 force 的数据可能丢失
// - 内存映射也占进程地址空间
// - 不适合频繁追加扩文件
io_uring(Linux 5.1+)
// 现代异步 IO 接口,比 libaio 强大
// Java 21+ 通过 Netty 5 / 自研 binding 可用
// 优势:
// - 真正的异步,不阻塞主线程
// - 批量提交多个 IO 操作
// - 支持文件 / socket 一起
// - 内核可减少系统调用次数
// MySQL 8.0.30+ 支持 innodb_use_native_aio
// PostgreSQL 17 实验性支持
// 应用层:RocksDB 已用,大幅提升写性能
监控指标
- alert: DiskIOUtilHigh
expr: rate(node_disk_io_time_seconds_total[5m]) > 0.8
for: 5m
annotations:
summary: '磁盘 {{ $labels.device }} IO util > 80%'
- alert: DirtyPagesHigh
expr: node_memory_Dirty_bytes > 5e9 # 5GB
for: 2m
annotations:
summary: '脏页 > 5GB,系统可能要强制刷盘卡顿'
- alert: IoWaitHigh
expr: rate(node_cpu_seconds_total{mode="iowait"}[5m]) > 0.2
annotations:
summary: 'CPU IO wait > 20%,磁盘是瓶颈'
调优总结
- 应用层:批量缓冲 → 减少系统调用
- fsync 时机:关键节点 + 周期性,不要每写都 fsync
- 大文件读用
posix_fadvise DONTNEED防污染 Page Cache - 顺序写大量数据用 mmap 或 sendfile
- 数据库类应用考虑 O_DIRECT 自管缓存
- 内核参数:dirty_ratio / dirty_background_ratio 根据业务调
- NVMe 加 multi-queue 调度器:
echo none > /sys/block/nvme0n1/queue/scheduler - 监控:iostat / iotop / blktrace / 脏页 / IO wait 全要
Page Cache 是 Linux IO 的核心机制,理解它能解释 90% 的"为什么 write 这么慢 / 这么快"问题。我们这个日志服务通过优化批写 + fsync 策略,p99 从 200ms 降到 8ms,同硬件下吞吐量翻 5 倍。底层知识不会过时,值得花时间啃透。
—— 别看了 · 2026