一台机器感觉很慢,CPU 却闲着,元凶通常是磁盘 I/O 延迟,而不是计算。负载平均值
统计的是卡在 D 状态的任务,所以在什么都没真跑的时候它照样往上涨;iowait 告诉你
有失速,却不告诉你慢的是哪块设备。能点名设备的那一个指标,是 iostat 里的平均
await。这就是我从「服务器好慢」走到「一块指定的设备、一个指定的进程、一个具体的
修法」所走的路径。
为什么负载平均值与 iowait 会藏住磁盘 I/O 延迟
我之前写过为什么 Linux 负载平均值会骗人,
存储版本的剧情很短。一个阻塞在慢盘上的任务停在不可中断睡眠(D 状态),在 I/O 返回
之前杀都杀不掉,而每个这样的任务都给负载计数加一。这段时间 CPU 全程空闲。于是你得到
那个经典形状:负载顶得很高,%us 接近零,%wa 接近 100。
$ vmstat 1 3
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 14 0 210400 39812 1890000 0 0 4096 12288 720 1440 2 1 3 94 0
0 15 0 208900 39812 1889000 0 0 5120 13000 700 1420 2 1 2 95 0
b 是 14~15,r 是 0,wa 约 94%。这是一个披着 CPU 外衣的磁盘问题。但 iowait
反过来也是个坑:它是一个按 CPU 统计的计数器,只有当那颗 CPU 本来会空闲时才会计数。
它完全不说哪一块设备慢,而且在一台忙碌的多核机器上,哪怕某个容器正被共享卷饿着,
它也可能一直很低。把 iowait 当成「有东西阻塞在 I/O 上」的提示就够了——别把它当设备
指标。
该看的指标:await、%util、队列深度、百分位
四个数字,以及每个到底意味着什么:
await—— 每个 I/O 请求从提交到完成的平均耗时,单位毫秒。它包含在队列里等待的 时间加上设备真正处理这个请求的时间。iostat把它拆成r_await和w_await,这 很重要,因为读和写在同块设备上的耗时特性差别极大。%util—— 设备至少有一个请求在途的墙上时钟占比。对单块机械盘,100% 就是饱和。 对深队列的 NVMe,%util可能读到 95%,而设备离极限还差得远。它是提示,不是判决。- 队列深度(新版
iostat里叫aqu-sz,老版本是avgqu-sz)—— 平均在途请求数。 就是它解释了await为什么高:队列深,说明每个请求都在排队等前面的。 - 吞吐(
r/s、w/s、rkB/s、wkB/s)—— 对成本和容量有用,对延迟没用。
它们由利特尔法则连在一起:队列深度 ≈ 到达速率 × 延迟。知道两个,第三个就是算术。 所以「磁盘 100%」是一句没说完的话,直到你知道那是单条队列的 100%,还是设备在跑 80 万 IOPS 的 100%。
读懂 iostat -x:一次完整走读
一定要开扩展统计(-x),跳过无活动的设备(-z),并且忽略第一个采样——那是自开机
以来的平均,不是上一秒的。给个次数,拿到实时样本:
$ iostat -xz 1 2
Device r/s w/s rkB/s wkB/s %util await r_await w_await aqu-sz
nvme0n1 12.0 1840 256.0 74240.0 98.7 18.4 0.9 18.6 34.2
sda 0.2 6 8.0 384.0 3.1 6.2 4.1 6.3 0.0
从左往右读:nvme0n1 每秒 12 次读、1840 次写,所以这是写密集负载。%util 是 98.7——
设备几乎从不空闲。await 平均 18.4 ms,而读写拆分才是全部剧情:r_await 只有
0.9 ms,w_await 却是 18.6 ms。读没事,写才是问题。aqu-sz 是 34,意味着平均有 34
个写请求在排队,这正是 w_await 是 18 ms 而不是 1 ms 的原因。sda 闲着,无关。
这一行已经给出了形状:一块写受限、队列很深的设备。它还没告诉我,该修的是更多 IOPS、 更短的队列,还是一个更不病态的写入者。
吞吐问题 vs 延迟问题
这是两种不同的病,药也不同,而 %util 分不清它们。一个备份以 500 MB/s 跑出 20 ms 的
await,那是吞吐问题:活儿很多,延迟平坦。一个数据库每秒做 4000 次微型 fsync 写入、
await 也是 20 ms,那是延迟问题:数据很少,但每个请求都很贵。同样的 await,相反的
修法。
await 成倍放大。拐点就是全部要害。在它下方,一块健康的磁盘用队列吸收短突发,await 保持平坦。在它
上方,额外的负载直接换算成延迟:吞吐不变,await 更差,p99 更差。如果一张图上
%util 是 100% 且 await 在往上爬,你就已经越过拐点了——再加活也帮不上忙。
找出真凶进程
设备级数字告诉你在哪里;下一步是谁。
iotop 给出一份按 I/O 排序的实时逐进程视图——在一百个闲着的进程里揪出一个失控
写入者,这是最快的办法。pidstat -d 是可脚本化的版本:它按间隔打印逐进程的读/写速率,
这正是你真正想记进日志的东西。
$ iotop -oPa # -o:只看有 I/O 的进程,-P:按进程,-a:累计
$ pidstat -d 1 5 # 逐进程 I/O 速率,每秒每个进程一行
/proc/<pid>/io 是地面的真相,在「该把账记在谁头上」这件事上从不说谎。
write_bytes 数的是真正下到块层的字节;cancelled_write_bytes 数的是被弄脏、又在落盘
之前被丢弃的页——用来识别一个白白浪费了磁盘的写入者很管用。
$ cat /proc/$(pgrep -x postgres)/io
read_bytes: 918273645
write_bytes: 4471293840
cancelled_write_bytes: 812304
在容器里,改读 cgroup 计数器,因为命名空间里的 iotop 常常看到错的东西(cgroup v2
看 io.stat,v1 看 blkio.throttle*):
$ cat /sys/fs/cgroup/system.slice/postgresql.service/io.stat
8:0 rbytes=918273645 wbytes=4471293840 rios=120441 wios=981223 dbytes=0 dios=0
常见成因
- fsync 密集的负载。 一次
fsync会等设备把写入变得持久。一个每条记录都fsync一次的负载,会把一块快 SSD 变成慢设备,因为每次调用都要付设备整轮的刷盘延迟。 - 数据库 WAL。 Postgres 和 MySQL 都在提交时
fsync它们的预写日志。组提交能摊薄, 但 WAL 设备一慢,就给提交延迟钉下一个 CPU 再怎么加都补不回来的硬地板。 - 超大文件。 一次几 GB 的
cp、一个构建 tarball、或者一次快照,会灌满页缓存、触 发回写,并在那个工具早就返回之后继续把设备压满。 - swap 抖动。 如果
vmstat显示si/so大于零,内存压力已经变成了磁盘延迟。在 虚拟机上,这把每一次缺页都变成一次网络往返。 - 吵闹的邻居。 在共享 EBS、vSAN 或宿主本地磁盘上,你的
await有一部分是别人的 负载。你能看见的那块设备,并不是你共享的那块设备上唯一的写入者。 - 日志刷屏。 每个请求一行 debug 日志,如果是同步写、或者落在急于刷盘的 fs 上,就是
一场自己招来的
fsync风暴。
修法
把修法和「你是超了吞吐上限还是超了延迟上限」对上。
批量写入。 最便宜的一手。用分块缓冲刷新代替逐条刷新,用 group commit,写更少更大
的记录。吞吐(更少的操作数)和延迟(更短的队列)两头都受益。
小心调 vm.dirty_*。 脏页回写控制内核何时开始刷盘,它是一笔延迟/吞吐的交易:
# 调低 = 延迟更平滑、突发吞吐更低;除非你知道原因,否则别往上调
vm.dirty_background_bytes = 268435456 # 脏页到 256 MiB 就开始回写
vm.dirty_bytes = 536870912 # 脏页到 512 MiB 就阻塞写入者
vm.dirty_expire_centisecs = 1500 # 脏超过 15s 的页就刷
vm.dirty_writeback_centisecs = 500 # 每 5s 唤醒一次回写线程
把 dirty_bytes 调低,能让刷盘突发更小、await 更低,代价是内核永远不会一次性用满整
块盘。调高能让寻道合并,却恰好制造出你想修的那种长停顿。一次只改一个值,盯着 await
看。
更快的盘或更多 IOPS。 如果 %util 顶死、队列又深,那设备是真的没容量了。更多
IOPS(SATA 换 NVMe、云盘上配 provisioned IOPS)能把拐点抬高。
拆分设备。 把 WAL、日志和批量数据放到不同设备上,让一个没法把另一个饿死。一块专用 的 WAL 卷,是数据库存储上最值的一个决定。
少记日志。 降到 info,对 debug 采样,并让日志异步化。你通常会发现,那些正在偷
走你磁盘的东西,你有 90% 从来没读过。
如果你在管一支舰队,把告警挂到你能采取行动的症状上,而不是那个原始数字—— 监控一支小舰队里的那套纪律在这里直接适用。
延迟看的是 p99,不是均值
iostat 最重要的一条局限:它打印的每一个 await 都是均值。iostat 没有百分位输出。
而均值会藏住尾巴。一块平均 await 只有 2 ms 的设备,仍然可能把 200 ms 给到 1% 的请求,
因为那些请求排在了一次刷盘或一次突发的后面。那 1% 才是用户能感觉到的东西:它是你的
p99,而这是 iostat 看不见的。
同一类里还有两个坑。第一,平均 await 包含排队时间,所以两块均值相同的设备可能有截然
不同的尾巴——一块稳定 2 ms,另一块在 0.2 ms 和 400 ms 之间来回横跳。第二,拿一个很长
的间隔去平均,会把短停顿整段抹掉。
改为去拿分布。biolatency(eBPF)打印块层 I/O 延迟的直方图,而且它作用在真实设备上,
不是均值上:
$ biolatency -m 1 # 单位 ms 的延迟直方图,每 1s 刷新
一旦你能看见形状,「磁盘很慢」就变成一句你可以动手的话:直方图平坦,说明服务时间稳定; 长长的右尾巴,说明排队或刷盘停顿;双峰的直方图,通常意味着两个不同的负载在共用一块 设备。每一种都有不同的修法,而没有一种能在均值里看见。
主线是这样:负载平均值和 iowait 告诉你「有队列」;iostat 点名设备并把读和写分开;
iotop 和 io.stat 点名进程;延迟直方图告诉你这到底是吞吐见顶,还是尾部停顿。
去量尾巴,去修真正卡住的那一环,别再调那颗从来就不是问题的 CPU。