load average 飙到 229 但 CPU 不满:atop 回放定位无 swap 机器上 go build 引发的 iowait 读盘风暴

现象一句话:一台 8 核测试机 load average 冲到 229,但没有 OOM、服务没挂,十分钟自己恢复了。最后用 atop 回放把根因钉到分钟级——不是 CPU 不够,是内存被编译榨干、又没有 swap,把 page cache 砸穿引发了读盘风暴

这篇记录排查方法和那条容易被忽略的因果链:load 高 ≠ CPU 忙,以及为什么一台没有 swap 的机器,一次 go build 就能把 load 顶到 200+。所有命令可照抄,结尾有速查清单。

故障级联:go build 一步步把 load 滚到 229

环境

  • OS:Ubuntu 22.04(kernel 5.15)
  • 规格:8 vCPU / 15G 内存,系统盘 ext4
  • 关键:无 swap(SwapTotal: 0)
  • 角色:测试环境业务机,单机挤了约 30 个 Go 微服务 + MySQL/搜索引擎/缓存等常驻进程,内存长期吃紧
  • 已装 atop(LOGINTERVAL=30,日志 /var/log/atop/atop_<YYYYMMDD>)——这是事后能回放的前提

没装 atop 的机器,过去那一秒的逐进程现场是找不回的。生产/测试机建议常备 atop 或 sysstat。

一、现象:load 飙到 229,CPU 却几乎空闲

先两次取样看 load 是不是已经回落(避免对着一个已经过去的尖峰瞎查):

uptime
# load average: 11.10, 67.21, 69.87   ← 1分钟已回落到11,但5/15分钟还67~70,说明刚发生过尖峰

free -h
#                total        used        free      shared  buff/cache   available
# Mem:            14Gi        11Gi       275Mi        57Mi       2.9Gi       2.7Gi
# Swap:             0B          0B          0B        ← 没有 swap

内存只剩 275M,且没有 swap。再看当下谁在吃 CPU:

ps -eo pid,user,%cpu,%mem,rss,etime,comm --sort=-%cpu | head
# 最高的进程才 3.4% CPU —— 没有任何进程在打满 CPU

到这里第一个反直觉点出现了:load 飙到几十上百,但没有任何进程在烧 CPU。这通常意味着负载来自 D 状态(不可中断睡眠,基本是在等磁盘 I/O),而不是 CPU 算力。

Linux 的 load average 统计的是「处于 R(可运行)+ D(不可中断睡眠)状态的任务数」,不是 CPU 利用率。一堆进程同时卡在等磁盘,load 一样会爆。

二、用 atop 回放尖峰窗口

load 已经回落,现场没了——但 atop 把历史每 30 秒存了一帧。先用解析模式拉出 load 和 CPU 利用率的时间线,定位尖峰在哪一段:

F=/var/log/atop/atop_$(date +%Y%m%d)
# CPU 各状态时间线,重点看 wait(iowait)
LC_ALL=C atop -r $F -b 10:50 -P CPU | grep '^CPU' | awk '{print $5, "sys="$8, "usr="$7, "wait="$12}'

输出里 wait(iowait)那一列在尖峰段直接拉满:

... wait=18
... wait=13720      ← 开始飙
... wait=19101
... wait=23403      ← 持续两万多

确认是 iowait 主导。再用 -P CPL 找出 load 的精确峰值时刻,然后把那一帧完整打出来。

三、峰值帧:所有线索都在这一帧里

LC_ALL=C atop -r $F -b 11:02 -e 11:04

峰值那一帧(已脱敏)信息量极大:

ATOP - <testbox>  11:03:02  ----------------  30s elapsed
PRC | sys    9.97s | user  79.92s | #proc    381 |
CPU | sys      37% | user    258% | irq    0% | idle    1% | wait    504% |   ← 8核里约5核在等磁盘,2.5核在跑计算
CPL | avg1  229.47 | avg5  116.83 | avg15  66.32 |                            ← load 峰值 229
MEM | tot    14.7G | free  288.2M | buff    4.3M | slab  500.1M |             ← page cache 只剩 4MB(!)
SWP | tot     0.0M | free    0.0M | vmcom  28.8G | vmlim   7.4G |             ← 无swap,已承诺内存是上限的~4倍
PSI | cpusome   2% | memsome  78% | memfull  57% | iosome   97% | iofull  62% | ← 内存与IO双重停顿
DSK |          vda | busy    102% | read  122660 | write    282 | avio 0.24 ms | ← 磁盘被读打满,几乎没有写

逐行读这帧:

  1. CPU wait 504%(总共 800%):8 个核里约 5 个卡在磁盘等待,sys 37%、user 258%——CPU 不是瓶颈,磁盘才是。
  2. DSK vda busy 102%,read 122660 / write 282:30 秒读了 12 万次、几乎不写——是疯狂读盘
  3. MEM free 288M,buff 4.3M:page cache 被挤到只剩 4MB(正常应有几个 G)。
  4. SWP 0,vmcom 28.8G vs vmlim 7.4G:没有 swap,且内存严重超分
  5. PSI iosome 97% / memfull 57%:内存和 I/O 两头都在制造停顿,进程被卡住等资源。

四、谁在读盘:不是一个进程,是全体一起重读

按磁盘读排序看进程级(atop -d):

LC_ALL=C atop -r $F -b 11:03 -e 11:04 -d
    PID  RDDSK  WRDSK  CMD
   ...  318.2M    76K  app_server_a
   ...  239.2M    12K  link            ← Go 链接器
   ...  225.6M     4K  app_server_b
   ...  213.5M    48K  app_server_c
   ...  204.2M     4K  app_server_d
   ...(十几个服务各读 160~320M)

关键现象:读盘量几乎均摊在十几个服务上,没有谁独大。这正是 page cache 塌缩的特征——所有服务的代码页都被驱逐了,于是它们同时从磁盘重读自己的二进制

再按 CPU 排序(atop -c),抓到了点火的那个进程:

PID  CPU  COMMAND-LINE
...  47%  .../go/pkg/.../tool/linux_amd64/link -o /tmp/go-build.../a.out -buildmode=exe   ← Go 链接器,D态
...   0%  go build --ldflags -X .../GitVersion=<commit> -X .../GitBranchName=origin/develop

峰值帧里有一个 go build 父进程 + 一个 Go link(链接器)子进程在 D 态读盘。 这是直接证据:编译就在尖峰那一刻正在跑。

五、用三个旁证把"是 go build"钉死

光看到进程还不够,再用三类时间戳交叉验证(不依赖 atop):

# ① 编出来的二进制 mtime = 编译完成时刻,正好落在尖峰窗口
ls -lt --time-style='+%H:%M' /path/to/built/binaries/ | head
#  11:10  app_server_a   ← 最大的二进制,编完时刻=load 229 那波结束点
#  10:39  app_server_b   ← 对应更早一波尖峰

# ② go build 缓存逐分钟落盘直方图,爆发点与尖峰对齐
find ~/.cache/go-build -newermt '10:00' ! -newermt '11:15' -printf '%TH:%TM\n' | sort | uniq -c
#  1490 10:34   ← 尖峰起点
#  3090 11:10   ← 最大一波,落盘文件最多=load最高

# ③ 两天对比:昨天没人编译 → 昨天全天 0 次 load>40
LC_ALL=C atop -r /var/log/atop/atop_<昨天> -P CPL | grep '^CPL' | awk '$8>40{print $5,$8}'
#  (空)        ← 同样的定时任务天天在跑,昨天却一点不尖,排除"定时任务"嫌疑

证据链闭环:源码改动 → atop 抓到 go build/link 在尖峰运行 → 编译缓存落盘直方图对齐 → 二进制 mtime 对齐 → 两天对比排除其它因素

六、根因:满载内存 + 无 swap,被一次编译压崩

把现象串成因果:

  1. 这台机常驻服务把 15G 内存常年压到 free < 300M,几乎没有空闲余量;
  2. 一次 go build 大体积 Go 服务,编译器/链接器要吃掉一块内存,这台机没有这块余量给它;
  3. 关键放大器——没有 swap:内存一紧,内核无法把匿名页换出(没地方换),就只能反复驱逐文件页(代码/库)再立刻重读;
  4. 于是 page cache 当场塌到 4MB,十几个服务同时从磁盘冷读自己的二进制 → vda 100% busy、30 秒读 12 万次;
  5. 大批进程卡进 D 状态等磁盘 → load = R+D 任务数,雪球一样堆到 229;
  6. 编译在 11:10 落盘、读压解除,队列瞬间泄掉,load 直接跌回 40 以下,全程十分钟、无 OOM

无 swap 机器:一次 go build 引发的 load 雪崩(完整因果链)

为什么没 OOM 反而抖 load?因为没有 swap 时,内核倾向于反复回收+重读文件页(page cache),而不是触发 OOM killer——表现就是"不崩,但磁盘和 load 爆炸"。

一句话:问题不在 go build 本身,在这台机"满载 + 无 swap"的底子。同样的编译,放在有充足内存、或哪怕只配了 swap 的机器上,顶多内存紧一点,绝不会爆出 229 的 load。

七、处置:加 swap + 调 swappiness

1. 加一个 8G swapfile(立竿见影)

fallocate -l 8G /swapfile        # ext4 可用;某些文件系统(如 btrfs)要改用 dd
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
swapon --show                    # 确认已启用
# 持久化(重启自动挂载)
grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab

内存见底时有 swap 兜底,内核可以把闲置匿名页换出,而不是把 page cache 砸穿、被迫反复读盘。

2. 调 swappiness——并避开一个覆盖坑

光加 swap 还不够。这台机 vm.swappiness=10 偏低,意味着内核仍倾向于先丢文件缓存、而不是用 swap 换出匿名页——恰恰是引发读盘风暴的那个行为。对这个故障,应调高 swappiness(用默认 60),让编译时优先换匿名页、保住 page cache:

sysctl -w vm.swappiness=60       # 运行时立即生效,无需重启

坑:别只写 drop-in 文件。 我一开始把 vm.swappiness=60 写进 /etc/sysctl.d/99-swappiness.conf,结果 sysctl --system 跑完最终值还是 10:

* Applying /etc/sysctl.d/99-swappiness.conf ...
vm.swappiness = 60
vm.swappiness = 10    ← /etc/sysctl.conf 在 .d/ 之后加载,把 60 覆盖回去了

原因:sysctl --system 的加载顺序里,/etc/sysctl.conf/etc/sysctl.d/* 之后,会覆盖 drop-in。而且这台机的 /etc/sysctl.conf重复写了两行且互相矛盾(=0=10)。正确做法是直接改源头:

cp -a /etc/sysctl.conf /etc/sysctl.conf.bak.$(date +%Y%m%d)              # 先备份
sed -i -E 's/^vm\.swappiness[[:space:]]*=[[:space:]]*[0-9]+/vm.swappiness = 60/' /etc/sysctl.conf
sysctl --system 2>/dev/null | grep -i swappiness    # 复核最终值
sysctl -n vm.swappiness                             # = 60 才算 reboot-safe

3. 根治:别在跑着服务的机器上编译

swap 只是缓解。干净的做法是 CICD 把"编译"和"运行"分离:在专门的 builder/CI 里 go build 出二进制,只把产物投递到目标机重启,目标机不承担任何编译负载。当前是"拉码→本机编译→重启"全压在一台跑满服务的机器上,才会一编就崩。

快速参考

判断 load 高是 CPU 型还是 I/O 型

uptime                              # 看 load 趋势(1/5/15 分钟)
vmstat 1 5                          # r 列高=CPU型;b 列高 + wa 高=I/O型
# 或直接看 atop 峰值帧的 CPU 行:user 高=算力;wait 高=等盘

atop 回放历史尖峰(无现场也能查)

F=/var/log/atop/atop_$(date +%Y%m%d)
atop -r $F -P CPL | grep '^CPL' | awk '$8>40{print $5,$8}'   # 全天 load>40 的时刻
atop -r $F -b HH:MM -e HH:MM                                  # 打印某窗口全帧
atop -r $F -b HH:MM -e HH:MM -d                               # 按磁盘读排序进程
atop -r $F -b HH:MM -e HH:MM -c                               # 看进程完整命令行

内存型 iowait 风暴的识别特征(峰值帧)

  • CPU wait 很高 + idle 接近 0,但 user/sys 不满
  • MEM buff/cache 异常小(被挤塌)、free 见底、SWP 为 0
  • DSK busy ~100%,read 巨大、write 很小(是重读不是写)
  • PSI iosome/memfull

加 swap 速查

fallocate -l 8G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab          # 持久化
swapoff /swapfile && rm /swapfile                        # 回退(并删 fstab 行)

swappiness 易错点

  • 运行时改:sysctl -w vm.swappiness=60(立即生效,无需重启)
  • 持久化要改 /etc/sysctl.conf(它在 /etc/sysctl.d/* 之后加载、会覆盖 drop-in)
  • 改完用 sysctl --system && sysctl -n vm.swappiness 复核最终值

一句话铁律

没有 swap 的机器,内存一紧不是"慢慢满",而是"page cache 瞬间塌缩→读盘风暴→load 爆炸"。给吃紧的机器配 swap、别在跑满服务的机器上做大编译。

posted @ 2026-06-08 12:32  Hello_worlds  阅读(6)  评论(0)    收藏  举报