1. 先想清楚:收到告警后第一件事不是敲命令
1.1 分清楚“100%”到底是哪类指标
线上CPU 100%的告警,很多人一上来就打开top,然后盯着进程列表发呆,发现能看到一堆进程名,却看不出问题在哪。我见过太多人把“CPU使用率”和“负载(load average)”混为一谈,这俩是完全不同的概念,搞混了,后面的排查方向都会跑偏。
CPU使用率,是单位时间内CPU处于非空闲状态的比例,也就是真正在做计算、等IO、处理中断的时间占比。而负载是个更“宏观”的指标,表示正在运行和等待运行的进程平均数量。负载高,不一定CPU打满,也可能是进程阻塞在IO上、锁上、网络上,大量任务排着队,CPU反而很闲。
还有一层容易被忽略的:现在服务器动辄16核、32核,CPU 100%到底指“所有核都满了”,还是“某一个核满了”?单核打满会导致总体使用率看起来只有百分之几,比如32核机器单核满载,整体才3.1%,表现就是接口偶发卡顿,但监控面板上似乎没大问题。反过来,如果是所有核都被打满,那就是另一个量级的事故了,系统通常已经处于半瘫痪状态。
遇到告警,我的固定习惯是先看三个数据:
uptime,直接给出1分钟、5分钟、15分钟的平均负载,能快速判断是突发还是持续走高。top,只看第一行和进程列表,重点不是看谁第一,而是看us(用户态)、sy(内核态)、wa(IO等待)、st(被虚拟机偷走的时间)这几项占比。wa高说明问题可能在磁盘,st高说明宿主机资源争抢,别一上来就赖代码。mpstat -P ALL 1,逐核观察,确认是不是个别核被某一组线程打满。
提示:先判断“是计算饱和还是排队变长”,再决定往下查的方向。这一步决定了你是往代码堆栈里深挖,还是往基础设施上找问题。
1.2 动手之前,先建立“变更时间线”
CPU 100%很少是凭空冒出来的。线上系统出问题,绝大多数都有触发点——只是有时候触发点不明显,看起来像“突然就高了”。
收到告警后,我要求团队先别急着敲命令,花1~2分钟把时间线拉出来:
- 告警是什么时候触发的?持续了多久?是第一次还是周期性出现?
- 这个时间点前后,有没有发版记录、配置变更、数据库扩容、依赖服务上线?
- 流量图有没有异常上涨?比如促销活动、脚本刷量、爬虫集中来袭。
- 有没有定时任务刚好在这个时间段执行?数据补偿、报表生成、日志清理,这些都很容易引发CPU陡增。
我遇到过不少案例,CPU打满其实是因为半夜的数据对账脚本多跑了一批数据,或者新上线的功能在流量高峰期触发了某个慢查询的连锁重试。如果一上来就盯着jstack分析线程栈,绕了一大圈才发现是同部门的离线任务在抢资源,那就白费功夫了。
所以现在我的团队有一个不成文的规定:所有线上排查群里的第一句话,永远是“先同步告警时间点和最近30分钟的变更记录”,数据没齐之前,谁都不许去生产环境乱敲命令。这不是流程主义,这是无数次踩坑换来的教训。
2. 进程→线程→代码,逐层拆解高CPU现象
2.1 top命令的正确打开方式
明确了是CPU本身的计算压力上来之后,再去看进程。
top几乎是每个后端开发者都会敲的命令,但大多数人只是进去按一下大写的P(按CPU排序),看一眼高的那个进程就退了。这个操作本身没错,但信息量远远不够。
我的顺序是这样的:
# 全量看一眼:load、us/sy/wa/st、进程占用排序 top -bn1 -o %CPU | head -30-b是批处理模式,-n1只输出一帧,-o %CPU按CPU占用排序。这个命令的意义在于:它对终端输出的格式是“干净”的,适合存日志、发给同事、回放现场。
然后针对嫌疑进程,再看它的线程分布:
# 查看指定进程内的线程,以及线程的CPU占用 top -H -p <PID>这一步很关键。一个进程整体CPU 100%,并不代表进程里每个线程都在干活,通常只是某一个或某几个线程在高速运转。通过top -H -p PID,你能看到进程内每个线程的CPU占比,那个能冲到90%以上的线程ID,就是接下来的突破口。
还有两个细节我建议大家养成习惯:看TIME+列,这个值是进程/线程累计消耗CPU的时长。如果某个线程现在的CPU占用看起来不是最高,但TIME+特别大,说明它是持续吃CPU的“惯犯”,而不是刚被流量带上来的“临时工”。另一个是看进程的启动时间,如果是刚刚才启动的新进程,CPU又居高不下,这基本就是业务逻辑问题了。
2.2 用pidstat锁住具体线程
top是快照,但CPU抖动很快,有时候一秒钟前还是100%,下一秒就掉下来了。所以快照之后,最好用pidstat做一段连续采样,把跳变的线程抓出来。
pidstat属于sysstat工具包,CentOS上装过sysstat的话,系统里基本都有。它的好处是可以按进程、按线程分别统计CPU占用,并且是定时采样输出,方便留证据。
# 按进程维度,每1秒采样一次,连续5次 pidstat -p <PID> 1 5 # 按线程维度,每1秒采样一次,连续5次 pidstat -t -p <PID> 1 5-t就是thread的维度,会在输出里多一个TID列,这个TID就是线程号,后面跳转堆栈的时候要用它。
实测中,pidstat比top更直观的地方在于:top只会显示当前一瞬的CPU值,而pidstat能看出线程在这5秒内的平均占用,稳定性更高。遇到那种“一下100%一下掉到0”的间歇性CPU飙高,pidstat的连续输出比top截图更容易锁定真凶。
2.3 ps命令作为补充
还有人习惯用ps来查,比如:
ps -L -p <PID> -o pid,tid,psr,pcpu,stat,commpsr是线程跑在哪个CPU核上,pcpu是CPU占用,stat是线程状态。这个命令好处是输出清爽,适合导出归档,但它的CPU百分比也是平均值,时效性不如top和pidstat。所以我的建议是:ps用来“做记录”,top和pidstat用来“抓现场”。
这一层排查做完,你手里应该握着三个明确的信息:哪个进程高、哪个线程高、线程大概占了多少CPU。接下来的问题是:这个线程到底在干什么?这才真正开始考验功力。
3. 从线程号翻译成代码:jstack、GC日志与火焰图的实战
3.1 jstack和线程ID的十六进制换算
Java应用在线上占据半壁江山,排查Java进程CPU飙高的经典套路,就是拿线程号去匹配jstack输出的线程栈。
第一步,拿着pidstat里的TID,转成十六进制。Java的线程栈里,线程ID是以nid=0x...的十六进制形式出现的,所以需要用printf算一下:
# 十进制TID转十六进制 printf '%x\n' <TID>比如TID是23456,转出来就是5ba0,在jstack里搜nid=0x5ba0即可。
第二步,采集线程栈。这里有一个特别重要的习惯:多取几次,别只取一次。我出线上事故时,一般会连续取三份,每份间隔3~5秒:
for i in 1 2 3; do jstack <PID> > /tmp/jstack_$(date +%s).log sleep 3 done为什么要多取几次?因为线程栈是瞬态快照。如果一个线程持续处于CPU密集计算中,它在不同时刻的调用栈大概率是接近的,这样更容易通过对比发现“稳定复现的路径”;如果只取一次,可能刚好抓到一段中间态的栈,反而误导排查方向。
第三步,看栈:
grep -A 20 "nid=0x<十六进制ID>" /tmp/jstack_xxx.log输出的前几行就是这个线程当时正在执行的代码路径。配合jstack -l <PID>还能输出锁的补充信息,如果线程在做Lock等操作,能看到具体在等哪把锁。
需要提醒的是,生产环境如果开了很大的JVM堆,jstack在采集时会导致STW式的停顿,会让服务出现几十甚至几百毫秒的卡顿。所以我的习惯是:优先在业务低峰执行这个操作,如果必须高峰期处理,也得先通知业务侧,并且只做一两次,别循环十次。
3.2 从堆栈里读出常见问题
拿到堆栈之后,读栈是个经验活。我挑几个在线上遇到过的高频场景,给大家参考。
最典型的是正则回溯。Java的正则在某些极端输入下会进入灾难性回溯,CPU消耗可以被拉满。堆栈里的特征非常明显,大量栈帧都是java.util.regex包下的方法,比如Pattern$Curly.match0、GroupHead.match、Pattern$BmpCharPredicate.match这类方法重复出现。这类问题大多出现在日志打印、参数校验、手机号/邮箱/URL格式校验这些场景。排查到正则层面之后,建议立刻用简单的字符串判断替代正则,或者给正则加上更严格的锚点和量词,避免回溯失控。
另一个常见的是大对象序列化/反序列化。比如把一个几万条数据的List直接放在日志里打点,或者对一个大String频繁做JSON解析,堆栈里会反复看到com.fasterxml.jackson.databind或com.alibaba.fastjson的相关方法。这种问题有时候CPU占比看起来不是特别高,但TIME+涨得非常快,因为序列化本身是“慢节奏的CPU消耗”。
还有一种是自旋/循环等待。比如基于while(true)的空转轮询、Thread.sleep搭配不好的重试机制、用System.currentTimeMillis()死等某个状态位。堆栈里往往是一个简单的业务方法反复被调用,没有明显的外部依赖。识别这类问题的关键,是看栈深度:如果每个线程的栈都特别浅,来回就是同一个业务方法,那多半是死循环或者自旋。
我把常见栈特征整理成了一张速查表,方便大家对照排查。
| 堆栈特征 | 典型场景 | 处置方向 |
|---|---|---|
java.util.regex.Pattern系列方法高频出现 | 正则回溯 | 简化正则、加锚点,或改用字符串处理 |
| Jackson/Fastjson相关帧反复出现 | 序列化大对象/频繁解析 | 去掉大对象打印点、升级类型引用、考虑改用流式解析 |
| 自定义业务方法反复出现且栈浅 | 死循环、热点轮询 | 查循环退出条件、加退出标志或超时熔断 |
sun.nio.ch.*、epollWait等NIO方法比例很高 | 网络事件循环正常状态 | 重点排查事件回调里的业务耗时 |
GC线程(VM Thread、G1 Young RemSet Sampling)占CPU | GC本身跑得勤 | 立刻看GC日志和堆内存 |
堆栈分析是很上头的操作,但别陷进去太久。我的经验是:如果连看三份堆栈,都指向同一个热点,那基本已经定位了;如果三份堆栈各不相同,反而要考虑是不是有锁竞争或者随机性的外部流量扰动,这时候把堆栈合在一起看特征,而不是纠结单个线程。
3.3 用perf和火焰图兜底
jstack对Java有效,但如果线上服务不全是Java,或者问题出在更底层——比如JVM源码层、C/C++服务、内核某个模块——就得换工具了。
perf是Linux自带的性能剖析工具,可以采集CPU指令级别的调用栈,对任何用户态进程都有效。
# 实时查看CPU事件 perf top -p <PID> # 采样并保存30秒的数据 perf record -g -p <PID> -- sleep 30 # 分析采样结果 perf reportperf record采集完成后,会生成一个perf.data文件,perf report可以把它渲染成带调用栈的统计。如果觉得命令行输出不够直观,可以用火焰图工具链把结果可视化。火焰图的核心阅读逻辑就是“看宽条、不看高条”:横向宽度代表CPU时间占比,越宽的条,越是焦点;纵向是调用层级,越深越靠近叶子函数。
不过在生产环境用perf有两件事要提前确认:一是部分容器环境里perf因为权限受限无法使用,可能需要额外配置或换用perf top --guest等方案;二是perf record的采样本身会给服务带来额外开销,事件采样频率默认很高,如果服务本就已经很吃紧,建议加-F 99把采样频率降下来,比如每秒99次。
注意:机器上如果出现
perf: permission denied类似的输出,别硬着头皮改内核参数。先看看是不是没有perf_event_paranoid权限,或者直接改用受限容器下的perf替代采样,否则为了排查一次事故,把机器搞得更不稳定就亏大了。
4. 线上CPU 100%的几大典型场景与避坑
4.1 死循环与热点业务逻辑
说到CPU 100%,很多人第一反应就是业务代码死循环。没错,这确实是最常见的原因之一,而且它最大的特点是:CPU飙升毫无征兆,进程健康检查还在跳动,但所有线程都在空转,业务吞吐量断崖式下跌。
死循环的经典触发方式,我在线上见过几类:
while循环的退出条件依赖外部状态,但这个状态永远等不到。- 循环里对集合做了
remove/add操作,导致迭代器行为异常,永远走不到末尾。 - 用
contains在一个几万条元素的List里做循环判断,复杂度从O(n)变成O(n*m),数据量一大,CPU直接饱和。 - 定时任务里的数据补偿逻辑,补偿失败之后没有退避重试限制,一直疯狂重跑。
排查这类问题,jstack往往是最好用的。看到堆栈里有明显的for/while循环帧,找到循环变量相关的业务代码,基本就跑不掉。解决方式也直接:循环加最大次数限制、每次循环做一次状态校验、对集合预判大小或改用HashSet。
一个更隐蔽的点:业务代码里的热点逻辑不一定是死循环,也可能是“正常但在错误场景下被放大”的算法。比如大量日志打印、拦截器里做了重复的权限查询、网关层对每个请求都做全链路动态配置解析。这些逻辑平时看起来人畜无害,一旦流量翻个几倍,CPU就会被打爆。这种情况下的特点是:提升流量后CPU增长不成线性,而是类似指数爆炸。排查时重点用火焰图看叶子函数的横向宽度,找到宽度最大的那个函数,往往就是热点。
4.2 GC频繁、锁竞争导致“假业务线程”
第二种典型但相对难判断的场景,是JVM的GC和锁竞争。
先说Full GC。老年代对象过多、元数据区膨胀、内存泄漏没爆出来之前,都可能出现频繁Full GC。每次Full GC都会触发STW(Stop-The-World),JVM所有业务线程都会暂停,从外部看CPU使用率暴涨,但业务完全没有响应。
识别方式很简单:jstack里只能搜到GC相关线程,甚至看不到业务线程在干活;再配合jstat看一下GC数据:
jstat -gcutil <PID> 1000 5重点关注FGC列,如果这个数字在5次采样内持续快速上涨,FGCT(Full GC耗时)也在涨,那CPU很大概率是被垃圾回收吃掉的,业务线程反而是受害者。这种情况优先处理的是内存:dump堆、分析大对象、在线上的临时方案是调大堆内存或调整GC策略,治本的方案还是看代码哪里不停造对象、哪里把大对象都堆在了老年代。
锁竞争的情况稍微复杂一点。很多人有个误解,认为线程在等待锁的时候会占CPU,实际上Java里阻塞态的线程是不占CPU的,真正占CPU的是持锁线程在占用锁期间执行的代码,以及锁释放和重新申请过程中的系统调用开销。
如果通过jstack看到大量线程处于BLOCKED状态,都在等待同一把锁,而持锁线程栈显示正在做CPU密集型操作,那问题就清楚了:要么把这个同步块拆小,要么优化锁粒度。还有一种情况,线程状态是RUNNABLE但一直盯着元空间做类加载、反射调用,这种情况在Spring早期版本、动态代理比较多的项目里比较常见,需要关注VM Thread的CPU时间和类加载统计。
4.3 基础设施与容器层:别把所有锅都扣给代码
排查进到第4层,我强烈建议冷静下来回退一步,看看问题到底是不是在应用层。
容器化时代,CPU 100%很可能不是业务代码造成的,而是基础设施的干扰。常见的有:
- 容器被设置了CPU quota(
cfs_quota_us),当容器内多线程并发跑到配额上限时,会被强制节流(throttled)。这时应用看到的CPU使用率可能不到100%,但容器内负载很高、请求很慢。排查方式是看容器所在目录下的cpu.stat文件里的nr_throttled数值。 - 云服务器上hypervisor层面的资源争抢,其他虚拟机疯狂读写、占CPU,宿主机会把一部分CPU时间“借走”,表现就是
top里st值很高。这种场景在物理机的mpstat输出里尤其明显,如果是纯应用层排查,代码分析再久也没用。 - 虚拟机平台的故障也不少见。比如虚拟CPU进入异常状态、VMware里出现资源调度异常,表现为整个VM忽然卡死、内部CPU冲高但服务无响应,甚至直接重启。这时候应用层的数据已经失真了,要看虚拟机管理后台的事件记录。
还有一类特别容易被忽略的:CPU指令集和二进制不匹配。比如某次Linux升级内核后,服务启动直接报类似cpu does not support x86-64-v2的错误,或者性能断崖式下降。这不是业务逻辑问题,是编译或运行环境要求的CPU指令集版本变了,只能通过更换镜像、重编译或者调整虚拟机的CPU模式来解决。
基础设施层的排查,最核心的原则是:先确认问题区域的边界。如果应用层的三份jstack看不出任何热点,GC指标也正常,top里st又居高不下,那大概率已经不是代码的事了。放下代码思维,去看宿主机指标、容器限制、虚拟化平台事件,往往比在代码里纠结更高效。
5. 开发机与服务器:同一套排查思路的不同侧重
5.1 Windows开发机上CPU飙高的另类元凶
聊完线上服务器,顺手聊一下开发机的CPU 100%。很多小伙伴在本地开发时也遇到过CPU打满的情况,桌面环境虽然和服务器不同,但排查思路是可以平移的。
在Windows上,最典型的几个高CPU进程包括:
ntoskrnl.exe,这是Windows内核进程。它在开发机上CPU飙高,通常与驱动异常、电源策略、内存管理有关。比如休眠唤醒后偶发,或者某些老旧网卡驱动疯狂中断,就会看到ntoskrnl占着大量CPU。antimalware service executable,这是Windows自带的杀毒组件。它在做全盘扫描、定期更新病毒库时非常吃CPU,尤其在小内存机器上,一扫描基本就让开发机进入半卡状态。遇到这种占用,优先查看“病毒和威胁防护”里最近一次的扫描时间和触发策略。compattelrunner.exe,这是Windows兼容性遥测组件。它会定期把系统使用数据上传,CPU占用偶发拉升。我见过好几台机器,配了它之后CPU经常无端飙到100%,可以调整相关计划任务的执行策略,或者彻底关掉遥测功能。
排查这些开发机上的问题,方法很简单:任务管理器→详细信息→按CPU排序,看到可疑进程之后右键→转到具体线程;再做一次“分析等待链”或者用Process Explorer看线程的调用栈。不过开发机毕竟不是服务器,处理起来自由度大得多,找到元凶后卸载组件、调整服务、修改电源计划,都不影响线上,可以大胆一点。
5.2 线上服务器不同于本机的操作纪律
本地怎么折腾都行,线上不行。
排查线上问题时,我给自己和团队定的纪律有三条:
第一,所有命令的输出必须留档。top、pidstat、jstack、jstat的结果,按时间戳命名存放到统一目录。这不只是为了事后复盘,更关键的是:很多问题的现场只有一次,如果当时没记录,后面再想分析就什么都没有了。
第二,能不重启就不重启,必须先原地取证再往后处理。CPU 100%的现场,最值钱的资产就是进程内的线程栈和GC日志。一旦重启,这些内存中的证据就全部丢失了。我承认很多时候重启能快速止损,但止损之前,花三分钟抓一下现场,这是对整个团队最友好的行为。
第三,不要跳过中间态直接猜结论。线上环境的变量非常多,流量、发版、依赖、资源限制,都可能改变现象。看到CPU高就一口咬定“是某段代码的问题”,然后让业务开发去查代码,这往往是低效的。正确的方式,是按前面说的流程从宏观指标逐层向下,一步步缩小范围,每一步都有数据支撑。
6. 常见问题与排查技巧实录
6.1 一张线上CPU告警快速处理速查表
把前面提到的内容浓缩成一个“告警后60秒”的操作卡片,实测下来非常管用。
| 时间窗口 | 操作 | 目的 |
|---|---|---|
| 0~10秒 | 确认告警时间点、机器范围、当前负载、top -bn1 | 建立基线,区分单点还是集群问题 |
| 10~20秒 | 查看最近变更、发布记录、流量趋势 | 建立时间线,找到可能触发点 |
| 20~40秒 | pidstat -t -p 锁定高CPU进程/线程 | 定位到线程粒度 |
| 40~60秒 | jstack连续取2~3份 + jstat查看GC | 获取代码级证据 |
这几步做完,要么问题已经定位,要么手里的证据足以支持下一步专项分析。很多新手容易在40秒这个节点上慌了,甚至直接跳到了“重启”这个动作。我的感受是:CPU 100%的问题,大部分都可以通过冷静的60秒现场取证得到线索,真正需要“果断重启”的场景反而很少。
6.2 那些年踩过的坑
按我自己的经历,再补充几个最容易踩的坑,每一条都是真金白银换来的。
第一个坑:只看top一秒钟的结果就下结论。CPU是动态的,瞬时快照很可能恰好抓到一个不具代表性的画面。比如某个后台任务刚启动3秒,正好被你看到了100%,它跑完就退了,你却把它当成事故主因,在那折腾了半天。正确的做法是至少以5~10秒为窗口观察,用pidstat或者top的-d刷新频率来看趋势,而不是看单帧。
第二个坑:把单核满载和多核满载混为一谈。两种场景的处理方式完全不同。单核满载,可能是某个带状态的On/Off任务被放在单一线程上执行,业务影响通常较小;多核满载,往往意味着并发压力全面上来,或者死循环产生了多个线程。用mpstat -P ALL确认核数分布,再决定要不要喊更多人协助。
第三个坑:忽略了容器/虚拟化边界。现在线上环境动不动就是容器、虚拟机,但很多人的排查习惯还停留在物理机时代,看到CPU高就以为一定是进程问题。结果查了半天jstack,最后发现是容器CPU quota被限流,或者宿主机高负载传导过来。所以排查之前先确认运行环境:容器看cpu.stat,虚拟化看st值和平台事件,这些东西不花一分钟就能排除掉,但可以有效避免几个月排查毫无进展的尴尬。
第四个坑:拿开发机的Windows经验套生产服务器。Windows上排查高CPU进程的思路没问题,但生产环境基本都是Linux,而且系统裁剪得更严格、权限更受限。比如很多排查命令需要sudo或者特定的capability,没有提前开通,遇到事故时你连diagnostic工具都跑不起来。建议平时就把常用命令在堡垒机上试一遍,确认权限、确认输出格式,别等出事了再临时找运维开通权限。
6.3 把排查步骤脚本化,把经验沉淀给团队
分享一个我个人的习惯:线上CPU排查的常用命令,我是写成脚本存起来的,取名叫cpu-debug.sh。它做的事情不多,但都是按时间顺序自动执行,包括当前时间戳、load、top快照、pidstat线程采样、jstack连续取样、GC概览,最后自动打包成一个目录。出问题时,我可以直接一条命令把现场的“证据包”完整留存下来,然后慢慢分析。
这个脚本花不了多少时间,但它带来的价值非常大。第一,它把排查动作标准化了,团队里任何人遇到同类问题都能复用同一套流程;第二,它减少了事发时的手忙脚乱,人是情绪动物,半夜被叫起来处理事故,精神高度紧张,手敲命令很容易出错或漏步骤,脚本能保证每次采集的完整性。
最后再分享一个从实战中得来的小技巧:排查CPU高的问题时,手机拍屏和截图可以作为辅助,但真正可靠的是命令输出的文本文件。文本可以精确检索、可以计算、可以对比,突发事件哪怕当时没看懂,事后依然能够复盘。所以无论当时多慌,多花十秒钟把输出重定向到文件,都不会亏。