load average 38,CPU 却空闲 71%:D 状态进程排查
8 核机器 load 38,CPU 七成空闲。vmstat 里 r=1、b=21,二十个进程堵在 IO 上。根因是备份任务冲掉页缓存,让采集服务的写退化成同步写,以及 load 告警该怎么改。
凌晨两点十七分,一台跑着数据采集服务的机器触发负载告警:1 分钟 load 38.6,5 分钟 34.2,15 分钟 29.8。这台机器是 8 核,按经验 load 超过核数的两倍就该动手了。
我登上机器跑 top,第一眼就懵了:%Cpu(s) 那一行,id 是 71.3,wa 是 24.1,us 只有 3.2。也就是说 CPU 七成时间在闲着,两成半在等 IO。一个 CPU 大量空闲的机器,load 却是核数的五倍。
如果只看 load 这一个数字,很容易得出「机器过载,该扩容」的结论。但 CPU 空闲说明根本不是算力不够。这次排查让我把「load 到底是什么」这件事从头捋了一遍。
一、load average 到底统计了什么
Linux 的 load average 是三个时间窗口(1/5/15 分钟)的指数衰减平均值,统计的是处于可运行(R)和不可中断睡眠(D)状态的进程数。它从来就不是「CPU 使用率」的同义词。
关键词是「不可中断睡眠」。D 状态指的是进程正在等待某个内核态操作完成,且这个等待不能被信号打断——典型场景是等磁盘 IO、等 NFS、等某些驱动。这类进程不消耗 CPU,但它占着「可运行队列」的位置,被算进 load。
所以 load 高有两种完全不同的形态:
| 形态 | top 里的特征 |
本质 | 处理方向 |
|---|---|---|---|
| 计算型高负载 | us + sy 接近 100%,wa 低 |
CPU 真的不够用 | 扩容 / 优化算法 / 减少线程 |
| IO 阻塞型高负载 | id 高,wa 高,us 低 |
存储层跟不上 | 查 IO、错峰、升盘、加缓存 |
| 混合型 | wa 和 us 都高 |
两者互为因果 | 先解 IO,再看 CPU |
我们这台是典型的第二种。判断方法很直接:vmstat 1 看 r(可运行队列)和 b(阻塞的进程数)两列。
vmstat 1 5
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa st
# 1 23 0 412800 18240 9482112 0 0 4216 8830 1284 2210 3 1 71 24 0
# 2 19 0 398220 18240 9479080 0 0 3980 8120 1176 2180 4 1 70 25 0
# 1 21 0 401012 18240 9483320 0 0 4102 8410 1203 2190 3 2 71 24 0
r 只有 1 到 2,b 稳定在 20 上下。这就把结论钉死了:没有多少进程在抢 CPU,有二十来个进程堵在 IO 上。 load 的构成是 b 列贡献的,跟算力无关。
二、把 D 状态进程揪出来
top 默认按 CPU 排序,D 状态进程的 CPU 占用接近 0,永远排在最后,按 Shift+P 也翻不出来。得换命令。
# 按状态筛选,STAT 以 D 开头
ps -eo pid,ppid,stat,wchan:32,%cpu,%mem,etime,cmd --sort=-stat | awk 'NR==1 || $3 ~ /^D/'
输出里 wchan 那一列是进程当前卡在哪个内核函数,这是最关键的信息:
812 1 Dl jbd2_log_wait_commit 0.0 0.1 412:11:03 /usr/sbin/mysqld
1204 812 Dl jbd2_log_wait_commit 0.0 0.2 412:10:47 /usr/sbin/mysqld
2210 1 Dl __lock_page_killable 0.0 0.0 198:42:11 /opt/collector/bin/collector --shard=3
2211 1 Dl __lock_page_killable 0.0 0.0 198:42:08 /opt/collector/bin/collector --shard=4
...(共 21 个)
二十一进程全是我们的采集服务,卡在 __lock_page_killable,另外几个 mysqld 的线程卡在 jbd2_log_wait_commit(等日志提交)。
__lock_page_killable 基本可以翻译成人话:进程要读一个内存页,这个页正在被换出或写回,得等 IO 完成。二十个采集进程同时卡在这个点上,说明它们在同一时间涌向了磁盘。
三、IO 打满的真相:云盘基线
接着看磁盘。iostat -x 1 是关键,重点看三个字段:%util、await、aqu-sz(队列深度)。
iostat -x 1 3 /dev/vdb
# Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s wrqm/s %wrqm w_await wareq-sz aqu-sz %util
# vdb 412.00 65920.0 0.00 0.0 186.42 160.0 1024.00 131072.0 0.00 0.0 92.31 128.0 24.86 99.80
%util 99.8,aqu-sz 24.86,r_await 186 毫秒。三个数字一起说明:磁盘队列排了二十多个请求,平均每个读要等 186 毫秒,设备满负荷。
但这里有个反直觉的点需要澄清:云盘的 %util 到 100% 并不代表「物理磁盘转不过来」,而是代表「你已经把这块盘的 IOPS 配额用完了」。云盘的性能是按容量配比的,比如我们这块 500 GB 的 ESSD,基线 IOPS 大概 5000,突发能到 10000 但只能维持一小段时间。
我算了一下当时的实际 IOPS:r/s + w/s 约 1436。看起来离 5000 还远,但云盘还有一个吞吐配额:rkB/s + wkB/s 约 197 MB/s。基线吞吐通常在 120–140 MB/s 之间(同样按容量配比)。也就是说我们撞的是吞吐墙,而不是 IOPS 墙。这种「IOPS 没到但吞吐到了」的情况,只看 IOPS 监控是完全看不出来的。
四、谁在写 131 MB/s
iostat 只能告诉你「盘忙」,不能告诉你「谁在忙」。这时要用 iotop,但它对容器内的进程识别有限,我们用的是 pidstat 按进程维度看:
pidstat -d 2 5 | sort -k4 -rn | head -15
# UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
# 999 2210 0.00 41280.00 0.00 1420 collector
# 999 2211 0.00 39820.00 0.00 1380 collector
# 999 2212 0.00 38110.00 0.00 1310 collector
三个采集进程各自写 40 MB/s 左右,加起来 118 MB/s。再看它们写的文件,lsof 一查就明白了:
ls -l /proc/2210/fd | grep -v socket | head
# l-wx------ 1 root root 64 Nov 5 02:20 7 -> /data/collector/shard-3/wal/000018.log
写的是 WAL(预写日志)。这个采集服务每收到一条数据就先 append 到 WAL 再批量落盘,设计上没问题,问题在于没人给它限速。
而真正让它爆发的,是另一个因素:凌晨两点整有一个全量备份任务启动,mysqldump 加 tar 把整个 /data 目录读了一遍。读操作把页缓存挤掉,采集进程的写入就退化成同步写,每次都要等盘。这就是 __lock_page_killable 集中出现的原因——不是采量突然涨了,是缓存被备份任务冲掉了。
这条因果链值得记下来:备份任务读盘 → 页缓存被脏页和备份数据挤占 → 采集服务的写从「进缓存就返回」变成「等磁盘」→ 进程进 D 状态 → load 飙升 → 但 CPU 完全空闲。
五、修复:三个层次,按成本排序
第一层,错峰,零成本。 备份任务从凌晨两点挪到凌晨四点,并且给它加上 ionice 降级:
# 备份脚本改造:最低 IO 优先级,且限制读取速率
ionice -c2 -n7 nice -n19 \
tar --use-compress-program='zstd -T2 -19' \
-cf /backup/data-$(date +%F).tar.zst /data
ionice -c2 -n7 是 best-effort 类的最低优先级,在有其他 IO 时它排最后。实测备份耗时从 40 分钟变成 65 分钟,但业务侧的 D 状态进程从 21 个降到 2 个。这个交换非常划算。
第二层,给采集服务的写入限速。 它在设计上允许「写慢一点」,只是没实现。我们在 WAL 写入路径上加了一个令牌桶,用 cgroup v2 的 IO 控制器硬限:
# 给采集服务的 cgroup 设 IO 上限:读写各 60MB/s
cgcreate -g io:/collector
echo "259:16 rbps=62914560 wbps=62914560" > /sys/fs/cgroup/io/collector/io.max
echo 2210 > /sys/fs/cgroup/io/collector/cgroup.procs
259:16 是 vdb 的 major:minor,用 lsblk -o NAME,MAJ:MIN 查。限速之后采集进程的写入变成匀速,不再和备份抢带宽,aqu-sz 从 24 降到 6 左右。
第三层,扩容。 把云盘从 500 GB 升到 1 TB,吞吐基线从 140 MB/s 提到 280 MB/s,同时把 vm.dirty_ratio 从 20 调到 10:
sysctl -w vm.dirty_ratio=10
sysctl -w vm.dirty_background_ratio=5
dirty_ratio 是「脏页占总内存多少比例时,写操作同步阻塞」,默认 20%。这台机器 16 GB 内存,20% 就是 3.2 GB 脏页,一旦触发阈值,所有写进程会被强制同步刷盘,集体进 D 状态——这正好解释了为什么 D 状态进程是「同时出现二十个」而不是逐渐增加。
调到 10% 之后,刷盘更平缓、更早开始,单次阻塞的进程数明显减少。
六、监控口径也得改
这次最大的收获其实在监控侧。原来我们的负载告警只有一条规则:load1 > 2 * cpu_cores。这条规则在 IO 阻塞场景下会造成两种误判:
一是误报。CPU 明明空闲,机器完全能处理更多请求,却因为 load 高而告警,值班的人久而久之就麻木了。
二是漏报。如果一台机器 us 已经 95% 但进程数刚好等于核数,load1 可能只有 8(等于核数),没到 16 的阈值,反而不告警。
改成三条规则组合:
# 计算型过载:CPU 真的忙
(1 - node_cpu_idle_ratio) > 0.85 and node_load1 > node_cores * 0.9
# IO 阻塞型:load 高但 CPU 闲
node_iowait_ratio > 0.15 and node_load1 > node_cores * 1.5 and node_cpu_idle_ratio > 0.4
# 排队严重:不管什么原因,可运行队列持续过长
node_procs_blocked > 10
第三条来自 node_procs_blocked(对应 vmstat 的 b 列),它比 load 更直接——就是「有多少进程正在被 IO 卡住」的原始值,没有指数平滑,反应也更快。
我还加了一个 node_disk_throughput_ratio 指标,用实际吞吐除以云盘基线吞吐,超过 0.8 就预警。这条是专门针对「IOPS 没到但吞吐到了」这种隐形瓶颈的,因为云厂商的 IOPS 监控面板不会替你看这个。
七、几个容易踩的坑
坑一:把 load 当 CPU 使用率。 这是最常见也最致命的误判,会直接导致「CPU 闲着的机器被判定过载」或者「CPU 打满的机器被判定健康」。
坑二:只看 %util 不看 await。 %util 100% 在 SSD 和云盘上不代表设备饱和,它只代表「队列一直非空」。真正的体感指标是 await(每次 IO 平均多久)和 aqu-sz(排多长的队)。
坑三:用 top 找 D 状态进程。 找不到的,D 状态进程 CPU 占用是 0,永远在列表底部。用 ps -eo stat,wchan 才对,而且 wchan 那一列直接告诉你卡在哪个内核函数,比任何猜测都靠得住。
坑四:忘了页缓存会被挤掉。 这次的根本诱因不是采量上涨,而是备份任务把页缓存冲了,导致原本「写进缓存就返回」的操作退化成同步写。任何大范围扫盘的任务都会产生这种连带影响,评估备份任务时不能只算它自己的耗时。
坑五:dirty_ratio 默认 20%。 内存大的机器上,20% 意味着几 GB 的脏页可以堆积,一旦触发阈值就是集体阻塞。内存越大,这个比例越该调小。
小结
- load average 统计的是 R + D 状态的进程数,D 是不可中断睡眠(等 IO 等内核操作),所以 load 高不等于 CPU 忙,CPU 空闲也完全可以有高 load。
- 判断类型看
vmstat的r和b:r大是算力问题,b大是 IO 阻塞。我们那次r=1、b=21,一秒定性。 - D 状态进程要用
ps -eo pid,stat,wchan,cmd找,wchan直接给出卡住的内核函数;top永远看不到它们。 - 云盘的
%util100% 可能撞的是吞吐墙而不是 IOPS 墙,必须单独监控「实际吞吐 / 基线吞吐」这个比值。 - 大范围扫盘的任务(备份、迁移、全量校验)会挤掉页缓存,把其他服务的写退化成同步写,是 IO 负载尖峰的常见隐形诱因,务必错峰 +
ionice。 vm.dirty_ratio默认 20% 对大内存机器偏高,调小能让刷盘更平缓,避免写进程集体进 D 状态。- 负载告警要拆成「计算型」「IO 阻塞型」「排队严重」三条规则,单看
load1 > 2×核数既会误报也会漏报。
正在加载评论…