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 永远看不到它们。
  • 云盘的 %util 100% 可能撞的是吞吐墙而不是 IOPS 墙,必须单独监控「实际吞吐 / 基线吞吐」这个比值。
  • 大范围扫盘的任务(备份、迁移、全量校验)会挤掉页缓存,把其他服务的写退化成同步写,是 IO 负载尖峰的常见隐形诱因,务必错峰 + ionice。
  • vm.dirty_ratio 默认 20% 对大内存机器偏高,调小能让刷盘更平缓,避免写进程集体进 D 状态。
  • 负载告警要拆成「计算型」「IO 阻塞型」「排队严重」三条规则,单看 load1 > 2×核数 既会误报也会漏报。
查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。