1. 从一次磁盘告警说起:为什么IO是Linux性能优化的深水区
凌晨两点,监控面板上某个节点的磁盘IO利用率突然飙到98%,业务接口的P99延迟从80毫秒直接跳到2.3秒。登录机器一看,iostat显示%util接近满载,await数值高得离谱,但CPU和内存却风平浪静。这种场景相信不少做后端运维或SRE的朋友都遇到过——CPU和内存的问题往往有比较明确的排查路径,而IO问题因为涉及块设备、文件系统、页缓存、调度器等多个层次,排查起来像剥洋葱,一层套一层。
Linux性能优化里,IO子系统是最容易被忽视、也最容易踩坑的一块。很多人对IO的认知停留在"磁盘读写慢就换SSD"这个层面,但实际生产环境中,IO瓶颈的成因可能是页缓存命中率低、可能是IO调度器选型不当、可能是文件系统挂载参数不合理、也可能是应用层的写入模式本身就有问题。如果不把这些层次理清楚,盲目加硬件只会让成本上升而问题依旧。
这篇内容面向的是有一定Linux基础、正在或即将负责线上服务稳定性的一线开发和运维人员。我会从IO的核心概念讲起,把块设备层、文件系统层、页缓存层的关系梳理清楚,然后给出可落地的观测工具组合和调优手段。所有命令和参数都是我实际排查中反复验证过的,你可以直接拿去用。需要说明的是,文中涉及的具体数值和场景是基于常见生产实践做的合理演绎,不同硬件和业务负载下会有差异,重点是理解背后的逻辑。
2. 先把IO的层次模型搞清楚:数据从应用到磁盘到底走了哪几条路
2.1 一次write调用背后的完整链路
很多人调优IO时直接盯着iostat看,但其实在数据落到物理磁盘之前,它已经经过了应用缓冲区、内核页缓存、文件系统、块设备层、IO调度器这几道关卡。理解这条链路,是判断瓶颈到底在哪一层的前提。
当你调用write()写入文件时,数据首先从用户空间拷贝到内核空间的页缓存(Page Cache)。这时候write调用就返回了,应用以为写完了,实际上数据还在内存里。内核会根据一定的策略决定什么时候把脏页(Dirty Page)回写到磁盘。这个回写过程由pdflush(现在叫writeback内核线程)负责,触发条件包括:脏页比例超过dirty_ratio、脏页存在时间超过dirty_expire_centisecs、或者显式调用fsync()。
数据从页缓存往下走,进入文件系统层。文件系统负责把逻辑块映射到物理块,同时维护元数据(inode、目录项等)。不同的文件系统(ext4、xfs、btrfs)在这层的策略差异很大,比如ext4的延迟分配(Delayed Allocation)会尽量攒一批数据再分配块,减少碎片,但也意味着崩溃时可能丢更多数据。
再往下是块设备层,这里有个重要的概念叫IO请求队列。多个IO请求会在队列里排队,然后由IO调度器决定以什么顺序、合并成多大的请求发给磁盘。调度器的选择对机械硬盘和SSD的影响完全不同,后面会详细讲。
最后数据到达物理设备,可能是SATA SSD、NVMe SSD、还是机械盘,它们的IOPS和延迟特性天差地别。NVMe SSD的随机读延迟可以低到几十微秒,而机械盘随机读要几毫秒,差了上百倍。
2.2 顺序IO和随机IO:性能差异的根源在哪
理解顺序IO和随机IO的区别,是IO调优的基本功。机械硬盘的盘片旋转和磁头寻道是物理动作,顺序读写时磁头只需要小幅移动甚至不动,而随机读写每次都要重新寻道,这就是为什么机械盘随机IOPS只有几百而顺序吞吐能到200MB/s以上。
SSD虽然没有机械结构,但随机读写依然比顺序慢,原因是SSD内部的闪存颗粒需要按页读写、按块擦除,随机写会触发更多的垃圾回收和写放大。NVMe SSD通过多队列和更高的并行度,把随机IOPS做到了几十万甚至上百万,但写放大问题依然存在。
这里有个实操中很容易忽略的点:判断业务是顺序还是随机,不能只看应用层的读写模式,还要看文件系统的块分配策略和IO调度器的合并行为。比如你用dd做顺序写测试,但如果文件系统碎片严重,实际落到磁盘上可能是随机的。反过来,数据库的WAL日志看起来是顺序追加,但如果多个事务并发写入,在块设备层可能变成交错的小IO。
2.3 页缓存:既是加速器也是定时炸弹
页缓存是Linux IO性能的关键。读操作如果命中页缓存,直接从内存返回,延迟在微秒级;未命中则要触发磁盘读,延迟直接跳到毫秒级。写操作先写页缓存,由内核异步回写,应用感知的延迟很低。
但页缓存也是很多IO问题的根源。如果脏页积累太多,回写时会形成IO风暴,把磁盘带宽瞬间打满,影响其他业务。更严重的是,如果脏页比例达到dirty_ratio(默认20%),新的write调用会被阻塞,直到脏页回写到一定程度。这时候应用层会看到明显的写入延迟抖动。
我见过一个典型案例:某服务做批量日志写入,平时QPS低的时候一切正常,一到高峰期写入延迟就飙升。排查发现是dirty_ratio和dirty_background_ratio的默认值在高写入负载下导致回写不及时,脏页堆积后触发同步回写,应用线程被阻塞。后来把dirty_background_ratio调低到5%,让后台回写更早启动,延迟抖动就消失了。
3. 观测工具怎么选:从全局到进程的IO排查路径
3.1 iostat、iotop、pidstat的分工与配合
排查IO问题,工具不在多而在用得对。我通常按"全局→设备→进程→系统调用"这个顺序逐层下钻。
iostat -x 1是第一站,重点看几个指标:%util表示设备繁忙程度,接近100%说明设备饱和;await是平均IO等待时间,包括排队时间和设备处理时间,机械盘超过20ms、SSD超过5ms就值得警惕;r/s和w/s是每秒读写次数,rkB/s和wkB/s是吞吐量。注意%util对NVMe SSD的参考意义有限,因为NVMe支持多队列,即使%util到100%也不一定饱和,这时候要看aqu-sz(平均队列长度)和await。
iotop -o能看到具体是哪个进程在读写,-o只显示有IO活动的进程。但iotop本身有性能开销,在高负载机器上要谨慎使用。更轻量的替代是pidstat -d 1,它按进程统计读写速率,开销小很多。
如果要看单个进程的系统调用级别IO,strace -e trace=read,write,open,fsync -p PID可以追踪,但strace会显著拖慢目标进程,生产环境慎用。更好的选择是perf trace或者eBPF工具,比如biolatency可以统计块设备IO的延迟分布,biosnoop能打印每次IO的详细信息,这些工具基于eBPF,开销可控。
3.2 用fio做基准测试:别再用dd了
很多人测磁盘性能就用dd if=/dev/zero of=test bs=1M count=1024,这个测试只能反映顺序写的吞吐,而且受页缓存影响很大,结果参考价值有限。专业的做法是用fio,它可以模拟各种IO模式。
一个典型的随机读测试配置:
[global] ioengine=libaio direct=1 runtime=60 time_based size=1G directory=/data/test [randread] rw=randread bs=4k iodepth=32 numjobs=4这里几个参数很关键:direct=1绕过页缓存,测的是真实设备性能;iodepth=32模拟异步IO的队列深度,更接近数据库等应用的实际情况;numjobs=4起4个并发任务。测出来的IOPS和延迟才有参考意义。
对比测试时,顺序读写用bs=1M、rw=read/write,随机读写用bs=4k、rw=randread/randwrite。混合读写可以用rw=randrw配合rwmixread=70指定读写比例。每次测试前记得清页缓存:echo 3 > /proc/sys/vm/drop_caches,否则结果会被缓存污染。
3.3 块设备延迟分布:biolatency的实际用法
iostat给的是平均值,但IO延迟的分布往往比平均值更重要。如果P99延迟很高而平均值正常,说明有少量IO卡顿,可能是设备偶发错误或者队列调度问题。
biolatency是bcc工具集里的一个,直接输出块设备IO的延迟直方图:
biolatency -D 10-D表示输出延迟分布而不是每次IO的明细,10是统计间隔秒数。输出会按延迟区间分桶,你能直观看到有多少IO落在1ms以内、多少在10ms以上。如果发现长尾明显,可以进一步用biosnoop定位是哪些进程、哪些扇区的IO慢。
需要注意的是,biolatency统计的是块设备层的延迟,不包括文件系统和页缓存的时间。如果要看端到端的IO延迟,得用fileslower或者应用层的埋点。
4. 调优手段落地:从调度器到文件系统参数
4.1 IO调度器选型:noop、deadline、cfq到底怎么选
IO调度器决定了请求队列里的IO以什么顺序发给设备。Linux内核提供了多种调度器,常见的有noop、deadline、cfq(新内核里被bfq和mq-deadline取代)。
noop基本不做排序和合并,适合SSD和NVMe这类随机访问性能已经很好的设备,把调度开销降到最低。deadline在保证吞吐的同时给每个请求设置截止时间,防止某个请求饿死,适合数据库这类对延迟敏感的场景。cfq(完全公平队列)按进程分配IO带宽,适合桌面和混合负载,但在高并发服务器上开销较大。
查看和修改调度器:
# 查看当前调度器 cat /sys/block/sda/queue/scheduler # 修改为deadline echo deadline > /sys/block/sda/queue/scheduler对于NVMe设备,通常建议用none(即noop),因为NVMe的多队列硬件本身就支持高并发,软件调度反而增加开销。对于SATA SSD,mq-deadline是不错的选择。机械盘上如果跑数据库,deadline通常比cfq表现更好。
这里有个经验:调度器的效果和硬件、负载强相关,没有万能选项。我一般会先用fio在候选调度器下各跑一轮基准测试,对比IOPS和P99延迟,再决定用哪个。切换调度器不需要重启,但会影响正在进行的IO,生产环境建议在低峰期操作。
4.2 文件系统挂载参数:noatime只是开始
文件系统挂载参数对IO性能的影响经常被低估。最基础的优化是加noatime,禁止记录文件的访问时间。默认的relatime虽然比atime好,但在大量读操作的场景下依然会产生元数据写入。noatime直接省掉这部分开销。
对于ext4,data=writeback模式只记录元数据的日志,数据写入不保证顺序,性能比默认的data=ordered好,但崩溃时可能读到旧数据。如果业务能容忍这种一致性级别,可以用。barrier=0关闭写屏障能提升性能,但在没有电池保护缓存的RAID卡上可能导致数据丢失,要谨慎。
XFS在大文件和高并发场景下通常比ext4表现更好,特别是它的延迟分配和并行分配策略。挂载时可以用noatime,nodiratime,logbsize=256k,logbsize增大日志缓冲区能减少日志写入次数。
还有一个容易被忽略的点:文件系统的块大小和RAID条带大小的对齐。如果文件系统块是4K而RAID条带是64K,一次4K写入可能触发读-改-写,性能大幅下降。创建文件系统时用mkfs.ext4 -b 4096 -E stride=16,stripe-width=64这类参数对齐,能明显改善写入性能。
4.3 脏页回写参数:让IO更平滑的关键
前面提到脏页回写是IO抖动的重要来源,这里展开讲怎么调。相关参数在/proc/sys/vm/下:
| 参数 | 默认值 | 作用 | 调优建议 |
|---|---|---|---|
| dirty_background_ratio | 10 | 后台回写启动的脏页比例 | 高写入负载降到5 |
| dirty_ratio | 20 | 同步回写触发的脏页比例 | 降到10-15 |
| dirty_background_bytes | 0 | 按字节数设置后台回写阈值 | 内存大的机器用这个更精确 |
| dirty_expire_centisecs | 3000 | 脏页最长存活时间(厘秒) | 降到1500加快回写 |
| dirty_writeback_centisecs | 500 | 回写线程唤醒间隔 | 保持默认或降到200 |
调优的核心思路是让回写更早、更频繁地发生,避免脏页堆积到触发同步回写。比如一台64GB内存的服务器,默认dirty_ratio=20意味着脏页到12.8GB才触发同步回写,这个量级的回写会形成巨大的IO压力。把dirty_ratio降到10,dirty_background_ratio降到5,回写会更平滑。
但也不能调得太激进,否则回写太频繁会浪费IO带宽。我的经验是观察/proc/vmstat里的nr_dirty和nr_writeback,如果nr_dirty经常接近阈值,就说明该调低了。另外对于数据库这类自己管理缓存的场景,通常建议把脏页参数调得更保守,让数据库自己控制刷盘节奏。
5. 那些年踩过的IO坑:真实排查案例复盘
5.1 案例一:日志写入导致的周期性卡顿
某服务每隔几分钟出现一次接口超时,持续时间几秒到十几秒不等。查监控发现超时期间磁盘%util打满,await飙升。用iotop看到是一个日志采集进程在大量写入。
进一步排查发现,这个日志进程用的是同步写(每次write后fsync),而且写入的文件在ext4上启用了data=ordered。高峰期日志量大时,fsync会等待数据落盘,而ext4的日志提交是周期性的,导致fsync延迟不稳定。
解决方案分两步:一是把日志写入改成批量异步写,攒够一定量或超过时间窗口再fsync;二是把日志目录单独挂载一个分区,用data=writeback模式,减少日志开销。改完后周期性卡顿消失。
这个案例的教训是:同步写在小流量下没问题,高流量下会成为性能杀手。任何频繁fsync的场景都要评估是否可以用异步写加批量提交替代。
5.2 案例二:RAID卡缓存策略引发的写入延迟
一台数据库服务器,SSD做RAID10,但写入延迟比预期高很多。iostat显示await在10ms以上,而单块SSD的写入延迟应该在1ms以内。
查RAID卡配置发现,写缓存策略是Write Through,也就是每次写入都要等数据落到磁盘才返回。改成Write Back后延迟降到1ms以下。但Write Back在断电时可能丢缓存里的数据,所以必须配合电池保护单元(BBU)或者超级电容。这台机器有BBU,所以可以安全启用。
这里的关键点是:RAID卡的缓存策略对写入性能影响巨大,但启用Write Back前必须确认有断电保护。没有BBU的机器用Write Back,一次意外断电就可能丢数据甚至损坏阵列。
5.3 案例三:容器环境下的IO隔离缺失
某环境用容器跑多个服务,其中一个服务做大量临时文件读写,导致同宿主机上其他服务的IO延迟明显上升。原因是容器默认共享宿主机的IO调度器和带宽,没有隔离。
解决方案是用cgroup的blkio子系统做IO限速。对于cgroup v1:
# 限制设备读写IOPS echo "8:0 1000" > /sys/fs/cgroup/blkio/mygroup/blkio.throttle.read_iops_device echo "8:0 500" > /sys/fs/cgroup/blkio/mygroup/blkio.throttle.write_iops_device # 限制带宽 echo "8:0 104857600" > /sys/fs/cgroup/blkio/mygroup/blkio.throttle.read_bps_devicecgroup v2里对应的是io.max文件,格式类似。限速后那个服务的IO被约束在合理范围,其他服务的延迟恢复正常。
这个案例说明:在多租户或混部环境下,IO隔离和CPU、内存隔离同样重要。不做隔离,一个服务的IO风暴会拖垮整台机器。
6. 写给一线同行的IO调优心得
IO调优这件事,最忌讳的就是"一招鲜吃遍天"。我见过有人把所有机器的IO调度器都改成noop,结果机械盘上的数据库性能反而下降了;也见过有人盲目调大脏页比例,以为能提升写入吞吐,结果高峰期IO抖动更严重。每一个参数的调整都要基于对当前硬件、文件系统、业务负载的准确判断。
我的习惯是建立一套基准测试流程:新机器上线前用fio跑一遍顺序读写、随机读写、混合读写,记录基线数据;上线后用iostat和biolatency持续观测,一旦发现延迟分布偏离基线就介入排查。这套流程帮我提前发现过好几次潜在问题,比如某批SSD的随机写性能明显低于同型号其他盘,后来确认是固件版本差异。
另外提醒一点,调优前一定要有回滚方案。修改内核参数、调度器、挂载选项都可能影响业务,改之前记录原值,改之后密切观察,出问题能快速恢复。生产环境的稳定性永远排在性能前面。
IO子系统的内容很多,这一篇主要覆盖了概念模型、观测工具和核心调优手段。后面如果有机会,可以再聊聊IO隔离、异步IO框架(io_uring)以及分布式存储场景下的IO优化,那些又是另一片天地了。