拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

线上CPU 100%排查实战:从负载、线程堆栈到火焰图

线上CPU 100%排查实战:从负载、线程堆栈到火焰图

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,comm

psr是线程跑在哪个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)占CPUGC本身跑得勤立刻看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 report

perf 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高的问题时,手机拍屏和截图可以作为辅助,但真正可靠的是命令输出的文本文件。文本可以精确检索、可以计算、可以对比,突发事件哪怕当时没看懂,事后依然能够复盘。所以无论当时多慌,多花十秒钟把输出重定向到文件,都不会亏。

返回列表