一线血泪:凌晨500ms延迟告警
凌晨2点,告警电话响了。数据库主库写入延迟从平时2ms飙升到500ms,业务接口大面积超时。我登录服务器,iostat -x 1 显示磁盘利用率100%,但await只有3ms——不像是磁盘硬件瓶颈。再查perf top,发现大量进程在争抢inode_lock和ext4_journal_start。问题出在Linux IO栈的多个环节:page cache dirty比例过高导致突然回刷、IO请求分散到多个队列引发调度震荡、应用层使用同步read/write造成大量上下文切换。
这不是孤例。很多开发者在遇到存储性能下降时,第一反应是换SSD、加内存,但往往忽略了IO栈本身的可调优空间。本文从实战出发,先复现问题,再给出两套优化方案(传统内核参数调整 + 现代io_uring),最后用压测数据说话。你拿去就能用。
Linux IO栈全景(必知)
一切优化基于理解。Linux IO栈分层如下:
- VFS层:虚拟文件系统,屏蔽底层差异,提供open/read/write接口。
- 文件系统层:如ext4、xfs、btrfs,管理inode、dentry、journal等。
- Page Cache层:内核缓存最近访问的磁盘数据,减少磁盘I/O。
- 块层:包含IO调度器(mq-deadline/kyber/none)、blk-cgroup、多队列(blk-mq)等。
- 设备驱动层:直接与NVMe/SATA/SCSI硬件交互。
每个层级都可能产生瓶颈。下面聚焦三处最常见的:Page Cache参数、IO调度器、系统调用开销。
问题复现:测试环境与基准数据
测试机配置:
- CPU: Intel Xeon Gold 6248R (48核, 2.9GHz)
- 内存: 256GB DDR4 2933
- 磁盘: 2× Intel Optane P5800X (NVMe 3.2TB, 随机读IOPS 1.5M, 延迟 4μs)
- 内核: 5.10.0-28-generic (Ubuntu 20.04 LTS)
- 文件系统: ext4, 挂载参数 defaults
- 压测工具: fio 3.33
基线压测命令(4KB随机写,队列深度64,直接IO绕过cache):
fio --name=baseline --ioengine=libaio --direct=1 --bs=4k --iodepth=64 --numjobs=4 --rw=randwrite --size=10G --runtime=60 --group_reporting
| 指标 | 基线值 |
|---|---|
| IOPS | 682K |
| 平均延迟 (μs) | 94 |
| 99%延迟 (μs) | 205 |
| CPU用户+系统占用 | 35% |
注意:直接IO已绕过page cache,但系统调用(io_submit、io_getevents)仍有上下文切换开销。
方案一:传统内核参数调优
调节内核参数和IO调度器,成本最低,效果立竿见影。
调整Page Cache回写策略
默认dirty_ratio为20%(可用内存的20%),dirty_background_ratio为10%。当脏页达到10%时,后台回刷开始;达到20%时,写操作会同步阻塞等待。大内存(256G)下,20%意味着51.2GB脏页,一旦触发同步回刷,延迟飙升。我们调小:
# 减少脏页上限,避免批量回刷导致的尖峰延迟
sysctl -w vm.dirty_ratio=5
sysctl -w vm.dirty_background_ratio=2
# 缩短回刷间隔(单位厘秒,默认500)
sysctl -w vm.dirty_writeback_centisecs=100
# 提高dirty_expire_centisecs,减少频繁回刷
sysctl -w vm.dirty_expire_centisecs=3000
参数含义:
dirty_ratio:进程同步等待的脏页上限(百分比)dirty_background_ratio:后台回刷线程唤醒阈值dirty_writeback_centisecs:pdflush线程唤醒间隔dirty_expire_centisecs:脏页存活时间超过此值则被回刷
更换IO调度器
NVMe SSD通常建议使用none(禁调度),或mq-deadline减小队列饥饿。查看当前调度器:
cat /sys/block/nvme0n1/queue/scheduler
结果:[mq-deadline] none kyber。我们切换为none:
echo none > /sys/block/nvme0n1/queue/scheduler
同时调整队列深度和预读:
# 增加硬件队列深度(默认64,可以调大)
echo 256 > /sys/block/nvme0n1/queue/nr_requests
# 关闭磁盘预读(对随机读写无用)
echo 0 > /sys/block/nvme0n1/queue/read_ahead_kb
减少sysctl其他关键参数
# vm.swappiness设为1,减少swap导致的IO
sysctl -w vm.swappiness=1
# 增大min_free_kbytes,防止内存碎片(大内存建议1G)
sysctl -w vm.min_free_kbytes=1048576
# 对文件系统开启noatime,减少元数据写
# /etc/fstab: UUID=xxx / ext4 noatime,nodiratime 0 0
方案二:io_uring——绕过系统调用,纯异步IO
传统libaio(异步IO)仍有系统调用开销(每轮需调用io_submit和io_getevents)。io_uring通过用户态与内核共享SQ/CQ环形队列,实现真正的零系统调用提交和收割。
内核5.1引入,5.10已稳定。启用需要编译支持(默认包含)。我们编写一个C测试程序,展示io_uring的基本用法(也可用liburing库简化)。
// io_uring_randwrite.c 编译:gcc -o io_uring_randwrite io_uring_randwrite.c -luring -lpthread
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
#include <liburing.h>
#define QD 64
#define BS 4096
int main(int argc, char **argv) {
struct io_uring ring;
int fd, ret;
char *buf;
struct iovec *iovecs;
if (argc < 2) { fprintf(stderr,"用法: %s <文件路径>\n", argv[0]); exit(1); }
fd = open(argv[1], O_RDWR | O_CREAT | O_DIRECT, 0644);
if (fd < 0) { perror("open"); exit(1); }
ret = io_uring_queue_init(QD, &ring, 0);
if (ret < 0) { perror("io_uring_queue_init"); exit(1); }
// 分配对齐内存
posix_memalign((void**)&buf, BS, BS * QD);
iovecs = malloc(sizeof(struct iovec) * QD);
for (int i = 0; i < QD; i++) {
iovecs[i].iov_base = buf + i * BS;
iovecs[i].iov_len = BS;
}
srand(time(NULL));
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
off_t file_size = 10UL * 1024 * 1024 * 1024; // 10G
int pending = 0;
for (int i = 0; i < 100000; i++) {
// 等待SQE可用(最多QD个并发)
while (io_uring_sqring_wait(&ring, 1) < 0 && errno == EINTR);
sqe = io_uring_get_sqe(&ring);
if (!sqe) break;
off_t offset = (rand() % (file_size / BS)) * BS;
io_uring_prep_writev(sqe, fd, &iovecs[i % QD], 1, offset);
io_uring_sqe_set_data(sqe, (void*)(uintptr_t)i);
io_uring_submit(&ring);
pending++;
if (pending >= QD) {
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) { perror("wait_cqe"); break; }
if (cqe->res < 0) { fprintf(stderr,"IO error: %s\n", strerror(-cqe->res)); }
io_uring_cqe_seen(&ring, cqe);
pending--;
}
}
// 收割所有剩余
while (pending > 0) {
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) break;
io_uring_cqe_seen(&ring, cqe);
pending--;
}
io_uring_queue_exit(&ring);
close(fd);
free(buf);
free(iovecs);
return 0;
}
实际压测我们仍用fio的io_uring引擎,更标准化:
fio --name=io_uring_test --ioengine=io_uring --direct=1 --bs=4k --iodepth=64 --numjobs=4 --rw=randwrite --size=10G --runtime=60 --group_reporting
效果数据:优化前后对比
测试顺序:先跑基线,再依次应用方案一(内核参数+调度器),最后跑io_uring(保持方案一所有参数)。每个场景跑3次取中位数。
| 场景 | IOPS | 平均延迟(μs) | 99%延迟(μs) | CPU占用(用户+系统) |
|---|---|---|---|---|
| 基线 | 682K | 94 | 205 | 35% |
| 方案一(内核参数+调度器) | 913K (+34%) | 70 | 142 | 31% (-4%) |
| 方案二(io_uring + 方案一参数) | 1.38M (+102%) | 46 | 88 | 22% (-13%) |
进一步分析:io_uring不仅IOPS翻倍,CPU占用降低13%,因为操作系统减少了上下文切换和系统调用。99%延迟从205μs降到88μs,业务99分位延迟改善显著。
避坑指南(我踩过的)
1. dirty_ratio设太小导致fsync慢。 我们曾将dirty_ratio设到1%,结果大量小写操作频繁触发回刷,fsync耗时增加2倍。建议根据业务写密集度调整,4KB随机写场景5-10%合适。
2. io_uring必须配合O_DIRECT才能发挥优势。 如果用buffer IO,页面缓存会引入大量缺页中断,性能反而不如传统read/write。测试中我们加了--direct=1。
3. 多队列系统下不要盲目调大nr_requests。 该值控制每个请求队列的深度,但NVMe每队列本身有硬件深度。设太大(如1024)可能引起内存浪费和队列风暴,推荐256-512。
4. io_uring与cgroup v1兼容问题。 内核5.10中,如果cgroup v1的blkio模块打开,io_uring可能失败(返回-EINVAL)。我们的生产环境需切换到cgroup v2或卸载blkio模块。检查方法:cat /proc/cgroups | grep blkio,若有则为v1。
5. 部分文件系统(如btrfs、zfs)对io_uring支持不完善。 ext4和xfs经过充分测试,推荐使用。
6. 切勿在生产环境直接修改sysctl而不持久化。 使用sysctl -w只临时生效,重启丢失。应写入/etc/sysctl.d/99-storage-tune.conf。
总结
Linux IO栈优化不是玄学。从Page Cache参数到调度器,再到io_uring,每个环节都可能拉高延迟。本文提供的两个方案可组合使用:
- 快速见效:调整
dirty_ratio、调度器为none,配合noatime + 降低swappiness。 - 极致性能:迁移到io_uring引擎(需应用代码改造),平均延迟降低50%+,CPU占用降低30%+。
下次遇到IO告警,别再急着换硬件。先查perf top,再调内核参数,最后考虑io_uring。记住:性能优化是减少浪费,不是压榨硬件。