Linux Page Cache 实战:日志服务 p99 从 200ms 降到 8ms

日志服务每天 200GB,半年后 IO util 95% p99 200ms。本文讲透 Linux Page Cache / Dirty Page / fsync / O_DIRECT / mmap / io_uring 底层机制 + iostat/iotop/blktrace 诊断 + 批量缓冲 + 内核参数调优 + posix_fadvise,p99 降到 8ms。

有个日志收集服务每天写 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%,磁盘是瓶颈'

调优总结

  1. 应用层:批量缓冲 → 减少系统调用
  2. fsync 时机:关键节点 + 周期性,不要每写都 fsync
  3. 大文件读用 posix_fadvise DONTNEED 防污染 Page Cache
  4. 顺序写大量数据用 mmap 或 sendfile
  5. 数据库类应用考虑 O_DIRECT 自管缓存
  6. 内核参数:dirty_ratio / dirty_background_ratio 根据业务调
  7. NVMe 加 multi-queue 调度器:echo none > /sys/block/nvme0n1/queue/scheduler
  8. 监控:iostat / iotop / blktrace / 脏页 / IO wait 全要

Page Cache 是 Linux IO 的核心机制,理解它能解释 90% 的"为什么 write 这么慢 / 这么快"问题。我们这个日志服务通过优化批写 + fsync 策略,p99 从 200ms 降到 8ms,同硬件下吞吐量翻 5 倍。底层知识不会过时,值得花时间啃透。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

大促网关 50w QPS 雪崩复盘:多维度限流 + Sentinel 系统保护实战

2026-5-19 11:41:48

技术教程

Elasticsearch 8000 主分片治理实战:p99 从 5s 降到 200ms

2026-5-19 11:45:56

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索