线上 IoT 边缘设备出现异常,监控显示 load average 飙到 89,持续数小时未恢复。
设备配置是 8 核 ARM 架构,跑着多个容器化服务。
一、矛盾的现象
load average: 48.45, 64.62, 89.64%Cpu(s): 5.3 us, 16.8 sy, 0.0 ni, 77.6 id, 0.0 waTasks: 243 total, 1 running, 242 sleeping
load 最高 89,但 CPU idle 是 77%,I/O wait 是 0,就 1 个进程在跑。
CPU 没饱和,磁盘也没压力,这么高的负载哪来的?
二、一步步排查
排除 D 状态进程
load average 统计的是 R(运行中)和 D(不可中断睡眠)状态的线程数。CPU 明明空着,会不会是大量进程卡在内核里出不来?
ps aux | awk '$8 ~ /D/ {print $0}'
没输出。当前没有 D 状态进程,可能之前有,随着负载下降已经恢复了。
vmstat 看上下文切换
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 2 0 0 3257096 152380 1814252 0 0 0 16 9197 14344 10 24 67 0 0 2 0 0 3251544 152380 1814268 0 0 0 164 8511 13274 5 14 81 0 0 1 0 0 3248808 152380 1814300 0 0 0 4 9757 14384 14 12 74 0 0 0 0 0 3250724 152380 1814304 0 0 0 160 8286 11653 8 24 67 0 0
抓到几个异常点:
内存还有 3GB 多,swap 是 0,I/O 基本没动(bi/bo 都接近 0)。看起来是线程在内核层面频繁切换导致的。
找谁在频繁切换
pidstat -w 1 5 | sort -k6 -rn | head -20
09:39:34 AM 0 1794325 1.00 277.00 pidstat09:39:36 AM 0 1794325 1.00 167.00 pidstat09:39:36 AM 1000 1794618 0.00 28.00 java09:39:34 AM 0 2019 8.00 18.00 checknet.sh09:39:33 AM 0 2019 34.00 12.00 checknet.sh09:39:35 AM 1000 1794580 1.00 7.00 ping09:39:36 AM 0 2019 26.00 6.00 checknet.sh
java 进程上下文切换 28 次/秒,不算特别夸张。倒是 checknet.sh 在反复 fork 子进程。
先看看 java 有多少线程:
ps -eo pid,nlwp,comm --sort=-nlwp | head -10
PID NLWP COMMAND3969687 7148 java1786510 37 nanomq 2506 21 containerd2894914 21 MediaServer 3117 18 vedgecore1786445 15 containerd-shim
7148 个线程。8 核机器跑 7000 多线程,调度器能不累吗。
三、进容器看 Java 线程
进容器抓个 thread dump:
jstack 99 | grep 'java.lang.Thread.State' | sort | uniq -c | sort -rn
4638 TIMED_WAITING (on object monitor)2321 RUNNABLE 123 WAITING (parking) 43 TIMED_WAITING (parking) 5 WAITING (on object monitor) 2 TIMED_WAITING (sleeping)
4638 个线程在 TIMED_WAITING,2321 个是 RUNNABLE。但 CPU 才用了 20~30%,这些 RUNNABLE 线程其实没在真跑,应该是阻塞在 native 调用里了,Java 这边看着状态还是 RUNNABLE。
看看线程数还在不在涨:
whiletrue; do cat /proc/3969687/status | grep Threads; sleep 2; done
Threads: 7157Threads: 7161Threads: 7161Threads: 7157Threads: 7157Threads: 7157
稳定在 7157~7161,不涨了,说明是之前积累下来的。
关键问题来了:这 7000 多线程都是哪来的?
jstack 99 | grep '"' | awk -F'"''{print $2}' \ | sed 's/-[0-9]*$//' | sort | uniq -c | sort -rn | head -20
1153 SocketListener(22-0-0-1.local.)1153 JmDNS(22-0-0-1.local.).Timer1153 JmDNS(22-0-0-1.local.).State.Timer1152 SocketListener(192-168-3-69.local.)1152 JmDNS(192-168-3-69.local.).Timer1152 JmDNS(192-168-3-69.local.).State.Timer 20 SchedulerTaskService 20 pool-5-thread 20 pool-17-thread 11 Timer 10 http-nio-8081-exec 10 edge
6915 个线程,97%,全是 JmDNS。其他业务线程加起来都不到 200 个。
四、JmDNS 实例泄漏
JmDNS 是 Java 写的 mDNS/DNS-SD 库,做零配置网络服务发现用的。
每次调用 JmDNS.create() 都会创建 3 个线程:
JmDNS.create(iface) ├── SocketListener → 阻塞在 UDP multicast receive() 上 ├── Timer → 维护 DNS 记录的定时器 └── State.Timer → 维护实例状态机的定时器
应用里每次要做服务发现就调一次 JmDNS.create(),但**从来不调 jmdns.close()**。实例越积越多,两个网络接口各积了 1152~1153 个实例,合计 6915 个线程。
这也解释了为啥 2321 个 RUNNABLE 线程不吃 CPU——SocketListener 阻塞在 PlainDatagramSocketImpl.receive0()(native UDP recv),属于 I/O 等待,Java 层面状态显示 RUNNABLE,实际不占 CPU。
五、处理和修复
临时处理
重启应用,load 立马下来了:
15min: 82.69 → 5min: 52.10 → 1min: 4.16
内存也跟着释放了 1.5 GB(都是线程栈占的):
代码怎么改
错的写法(每次 create,从不 close):
JmDNS jmdns = JmDNS.create(iface);jmdns.registerService(info);// 没有 close,3 个线程永远泄漏
对的写法(用完就关):
JmDNS jmdns = JmDNS.create(iface);try { jmdns.registerService(info);// ... 用完了} finally { jmdns.close(); // 释放 3 个线程 + socket fd}
更好的写法(单例,全局复用):
// 应用启动时创建,关闭时销毁,中间一直复用这一个实例@Bean(destroyMethod = "close")public JmDNS jmDNS()throws IOException {return JmDNS.create(InetAddress.getLocalHost());}
六、高负载排查通用流程
这个案例走完,把通用的排查流程也整理一下。
如何读 top
负载与趋势
load average: 48.45, 64.62, 89.64 ↑1min ↑5min ↑15min
- 1min > 15min:负载上升中,问题仍在恶化
CPU 各字段含义
%Cpu(s): 5.3 us, 16.8 sy, 0.0 ni, 77.6 id, 0.0 wa, 0.0 hi, 0.4 si
Tasks 行
Tasks: 243 total, 1 running, 242 sleeping, 0 stopped, 0 zombie
running:当前在 CPU 上的线程数,超过核心数说明在排队zombie:僵尸进程,父进程没有回收子进程,通常是代码 bug
排查决策树
第一步:load / nproc 是否 > 1?│└─ 是 → 系统过载,继续判断 │ ├─ Swap used > 0 且 si/so 不为 0 │ └─→ 内存压力,正在换页(见下文) │ ├─ wa > 10% │ └─→ I/O 瓶颈(见下文) │ ├─ us > 60% │ └─→ CPU 计算饱和(见下文) │ ├─ sy > 20%(且 us 不高) │ └─→ 内核态开销高(见下文) │ 通常是线程数爆炸或频繁系统调用 │ └─ 以上均不明显,但 id 仍偏高 └─→ D 状态进程(见下文) CPU 没事干,但进程卡在内核操作出不来 常见:NFS 超时、内核锁、异常驱动
遇到 id 高但 load 高的情况最容易误判。CPU 空闲不代表系统正常,要重点排查 D 状态进程和线程数。
各类瓶颈的排查方法
内存压力(Swap 被使用)
特征:top 底部 Swap used > 0,vmstat 中 si/so 不为 0
# 查看内存使用free -h# 观察换页速率(si=换入, so=换出)vmstat 1 5# 找内存占用最多的进程ps -eo pid,rss,comm --sort=-rss | head -20# Java 应用查看堆内存jmap -heap <pid>
si/so 持续不为 0 说明系统在频繁换页,性能会严重下降,需要找内存泄漏或扩容。
I/O 瓶颈(wa 高)
特征:wa > 10%,进程大量处于 D 状态
# 确认哪块磁盘繁忙iostat -x 1 5# 关注 %util(接近100%说明磁盘饱和)、await(I/O 平均等待时间)# 找出哪个进程在读写iotop -o# 确认 D 状态进程ps aux | awk '$8 ~ /D/ {print $0}'
常见原因:日志疯狂写入、数据库大量读写、磁盘故障导致 I/O 超时。
CPU 计算饱和(us 高)
特征:us > 60%,top 中有进程长期占满 CPU
# top 中按 P 键按 CPU 排序,找高占用进程# 定位 CPU 热点函数(需要安装 perf)perf top -p <pid># Java 应用找热点线程jstack <pid> | grep -A10 'RUNNABLE' | grep -v 'java.lang\|sun\.\|jdk\.' | head -50
常见原因:死循环、正则回溯、GC 频繁(Full GC)、加密/压缩运算。
内核态开销高(sy 高)
特征:sy > 20%,us 不高,vmstat 中 cs 偏高
# 查看上下文切换频率vmstat 1 5# cs 列:正常服务器通常 < 10000/s,超过 50000/s 需关注# 找上下文切换最多的进程pidstat -w 1 5 | sort -k6 -rn | head -20# 查看线程数异常的进程ps -eo pid,nlwp,comm --sort=-nlwp | head -20
常见原因:
- 大量短生命周期进程频繁 fork(shell 脚本循环)
D 状态进程(以上均不明显,但 load 仍高)
特征:CPU idle 高,但 load 居高不下,ps 看到 D 状态进程
# 找 D 状态进程ps aux | awk '$8 ~ /D/ {print $0}'# 或ps -eo pid,ppid,stat,wchan:30,comm | grep '^.*D'# 确认进程卡在哪个内核函数cat /proc/<pid>/wchan# 内核日志排查dmesg -T | grep -E 'hung|blocked|timeout|error' | tail -30
常见 wchan 与含义:
| |
|---|
nfs_... | |
mutex_lock | |
blk_... | |
xfs_... | |
各类问题的指标指纹
| | | | | | |
|---|
| | | | | | vmstat si/so |
| | | 高 | | | top |
| | 高 | | | | iostat |
| | | | 高 | | ps nlwp |
| | | | | 高 | ps D状态 |
| | | | | | sar -n DEV |
工具速查
# 全局概览top # 实时概览,按 1 展开每个 CPU 核# CPU + 内存 + I/O + 上下文切换vmstat 1 5 # 每秒采样,看趋势# 磁盘 I/Oiostat -x 1 5 # 磁盘利用率、等待时间iotop -o # 哪个进程在读写磁盘# 进程/线程ps aux | awk '$8~/D/'# D 状态进程ps -eo pid,nlwp,comm --sort=-nlwp | head -20 # 线程数排行pidstat -w 1 5 # 进程上下文切换# 网络ss -s # 连接状态概览sar -n DEV 1 5 # 网卡流量# 内核日志dmesg -T | tail -50 # 内核错误/告警# Java 专项jstack <pid> # 线程 dumpjstack <pid> | grep 'Thread.State' | sort | uniq -c | sort -rn # 线程状态统计jstack <pid> | grep '"' | awk -F'"''{print $2}' | sed 's/-[0-9]*$//' | sort | uniq -c | sort -rn | head -20 # 线程名聚合
七、几个容易搞错的点
1. load 高不等于 CPU 高
load 统计的是 R+D 状态线程数,D 状态进程卡在内核里不吃 CPU,但照样贡献 load。看到高负载别急着找高 CPU 进程,先看 id 和 wa。
2. Java RUNNABLE 不等于真在跑
大量 RUNNABLE 线程阻塞在 native I/O(比如 UDP recv)时,CPU 利用率可以很低,但这些线程照样占线程栈内存和调度器资源。
3. 线程数稳定不代表没泄漏
这个案例里线程数稳定在 7157 左右,是因为触发创建的代码路径不活跃了,不是代码修好了——存量一直在,只是不再新增。
4. 有 create 的地方必须有 close
持有线程、socket、文件句柄的对象,不关就是慢性泄漏,在高频路径上会快速放大。
5. vmstat 很好用
top 看完方向,接着 vmstat 1 5 一条命令可以查 CPU/内存/I/O/上下文切换,快速缩小范围。