运维这行干久了半夜被报警电话叫醒几乎成了肌肉记忆。前几天凌晨两点多线上某核心服务CPU直接拉满100%接口超时率从0.1%飙到30%用户侧已经出现明显卡顿。我一边开电脑一边在脑子里过了一遍排查流程最终从接到报警到定位到具体代码行用了大概二十分钟。这篇文章就把我这些年处理线上CPU 100%问题的完整思路、命令细节和踩坑记录整理出来希望能帮你少走点弯路。这个问题本身不复杂但很多人栽在第一步——不知道该看什么、先做什么、哪些数据必须在现场保留下来。CPU 100%不像内存泄漏那样有渐进过程它往往是突发性的服务器一旦被打满现场转瞬即逝进程可能被OOM Killer干掉线程栈可能已经被回收。所以这篇文章的核心就三件事怎么快速定位到进程怎么从线程栈里找到问题代码怎么在事后复盘出真正的根因。适合刚接触线上问题排查的后端开发、运维和SRE同学也适合那些已经写过不少代码、但还没完整处理过一次CPU危机的朋友。1. 线上CPU 100%先分清是真的忙还是在空转1.1 第一件事不是翻代码而是确认“忙”的形态接到报警后我见过太多人第一反应是打开IDE翻业务代码试图从逻辑上推理哪里可能死循环。这个方向本身没错但顺序反了。CPU 100%分两种截然不同的形态一种是真的在拼命执行指令比如死循环、密集计算另一种是CPU在空转比如自旋锁等待、线程频繁切换、内核态异常。这两种形态的排查路径完全不同前者要看用户态CPU占比后者要关注系统态CPU和上下文切换。如果上来就翻代码很可能被业务逻辑带偏绕半天发现根本不是那行代码的问题。正确的第一步是登上服务器用top命令看一眼全局。这一步的目的不是定位而是确认三件事CPU是单核满还是多核满用户态和系统态的占比分别是多少load average的走势是什么样的。单核满和多核满指向的问题类型完全不一样单核满往往是某个单线程任务卡死多核满则更可能是多个线程同时陷入循环或者是大量线程在竞争锁。1.2 五个关键指标决定排查方向top命令第一屏会输出大量数据但真正需要关注的只有五个指标含义重点关注场景load average最近1/5/15分钟的平均负载负载高于CPU核数时说明任务排队严重%Cpu(s) us用户态CPU占比us很高说明业务代码或JVM在大量计算%Cpu(s) sy系统态CPU占比sy很高说明内核态开销大常见于线程切换或系统调用%Cpu(s) waI/O等待占比wa很高时CPU在等待磁盘/网络不算真正的计算瓶颈%Cpu(s) st被虚拟机偷走的CPU时间云主机上出现st说明宿主资源争抢这里有个容易忽略的细节如果wa占比很高CPU 100%可能是个假象。前几天我还遇到一个案例MySQL所在物理机的CPU显示满了但us和sy都不高wa占了80%以上实际上是某个慢查询在疯狂扫表把磁盘IO打爆了CPU全在等IO。这种情况往代码死循环的方向查就完全跑偏了得先看SQL、看磁盘队列。确认了忙的形态之后再往下走才有意义。us高就走用户态分析路线sy高就要多关注线程切换和系统调用wa高则先去查IO链路。记住这个分流逻辑能省下至少半小时的无效排查时间。2. 五分钟锁定进程从top到pidstat2.1 top命令的正确解读姿势top进入交互界面后第一件事是按一下大写的P让进程列表按CPU使用率排序。这一步很基础但有个细节值得说top默认显示的是累计CPU使用率的某种快照单次刷新可能不够准确建议多按几次空格键强制刷新或者直接看top -n1 -b这种批处理模式的输出避免动态刷新带来的读数跳动。看进程列表时锁定那个CPU占用最高的PID。但千万别只盯着第一行因为线上服务往往是多进程多线程架构有时候罪魁祸首不在最顶上而是在前五名里。我习惯把前十个CPU占用高的进程都记下来连同PID、PPID、进程名一起一并保存到本地日志文件里。现场数据是排查问题的唯一凭据服务器重启之后什么都没了。这里还要提醒一点记下来的不光是进程名最好连启动时间也记。有一次我排查了一个多小时最后发现CPU 100%的进程根本不是我们的Java服务而是某个定时任务脚本fork出来的僵尸进程。如果一开始就从进程名上先入为主很容易忽略掉这种非预期进程。2.2 用pidstat记录现场避免对着动态数字瞎猜top是动态刷新的光靠肉眼盯着一闪而过的数字很难判断CPU占用是持续性的还是瞬时尖峰。这时候pidstat就派上用场了。pidstat是sysstat工具包里的命令专门用来按进程或线程维度统计资源使用情况比top更适合做长时间采样。pidstat -p 12345 -t 1 10这条命令的意思是对PID 12345这个进程按线程维度-t每秒采样一次连续采样10次。输出里能看到每个线程的CPU占用率而且采样是定时的能把哪个线程在持续吃CPU这个事实稳定地记录下来。如果某个线程的CPU占用率始终在90%以上那就说明问题基本锁定了如果所有线程的CPU占用率都不高但进程整体CPU很高那就要考虑是不是GC线程、编译线程或者内核态开销。采样数据一定要重定向到文件里存下来pidstat -p 12345 -t 1 30 /tmp/cpu_issue_pidstat.log 21别嫌这个动作多余事后复盘的时候这份采样日志就是铁证。领导问起来、或者周会上要写事故报告没有当时的现场数据全靠回忆口述那叫讲故事有了这份日志那叫证据链。2.3 确认进程之后先别急着kill很多人一看到CPU 100%的进程手比脑子快直接kill -9。这是线上运维的大忌。kill之前必须想清楚三件事这个进程是不是有状态的节点比如正在处理消息队列里的任务这个进程是不是集群里的唯一副本杀掉之后流量往哪切现场数据是不是已经保存完整了线程栈抓了没网络连接状态看了没。我处理过一起事故同事看到某个消费者进程CPU飙高果断kill掉以为是它在作妖。结果这个进程是消息队列的唯一消费者kill之后消息开始积压下游系统陆续报警最后又是一轮新的排查。CPU 100%的进程往往只是受害者真正的病根可能在它调用的下游服务、它访问的数据库或者它持有的某个共享资源。进程本身只是暴露问题的载体不是问题本身。正确的顺序应该是先抓线程栈、看网络连接、查日志把能收集的证据都收集完再决定是重启还是保留现场。如果必须止损我通常会先尝试让进程load降下来的操作比如限流、摘流量、暂停某个线程最后才考虑重启。3. 深入线程内部从jstack到perf3.1 把线程ID换算成十六进制再抓jstack进程锁定了下一步就是把范围从进程缩小到线程。以Java服务为例最经典的组合拳是top -H加jstack。先用top -Hp拿到进程内所有线程的CPU占用率找到那个最耗CPU的线程记下它的PID注意这里是线程ID。jstack的输出里线程信息是以十六进制显示的所以需要先把十进制线程ID换算成十六进制printf %x\n 34567得到十六进制值之后执行线程栈抓取jstack -l 12345 /tmp/jstack.log 21然后在线程栈文件里搜索这个十六进制线程ID就能看到它当时在干什么。这里有个很关键的实操细节jstack抓取和top -H采样之间要尽可能快最好在几秒内完成。线程栈是瞬态快照错过了就误判了。如果是持续性CPU飙高多抓几次jstack间隔两三秒抓一次连续抓三五份。单份线程栈可能恰好落在某个线程的休眠状态看不出问题但多份对比就能看出哪些线程始终在活跃运行、卡在什么调用栈上。另外jstack不是银弹。它只能看到JVM管理的线程对于JVM自身的一些线程比如GC线程、JIT编译线程也能看到但看不到内核态的系统调用耗时。如果jstack里看不出明显的热点别急着下结论可能问题根本不在Java业务代码里。3.2 jstack输出怎么读找状态找调用栈找特征jstack输出很长新手上手容易看花眼。我的读法分三步第一步先看全局线程状态分布。Java线程常见的状态有RUNNABLE、WAITING、TIMED_WAITING、BLOCKED。CPU 100%的场景里大量线程处于BLOCKED状态往往说明锁竞争严重大量线程处于RUNNABLE但干着无关紧要的事说明可能有隐性的忙循环。第二步定位到之前换算出来的十六进制线程ID看它的线程栈。重点关注栈顶几行也就是正在执行的方法。如果栈顶是JDK的类库方法比如java.util.HashMap的扩容、java.util.regex的匹配别急着往业务代码想先用jstack -l再确认一下是否有锁信息。第三步多份jstack做对比。如果每隔三秒抓一次连续抓了五次每次那个高CPU线程都在同一个方法里而且栈几乎不变那基本就是死循环或者长时间阻塞性的自旋。如果栈在变但始终在同一个类库的不同方法间跳转可能是某种并发组件在做大量重试。这里多说一句很多人忽略了jstack里的Found one Java-level deadlock提示。虽然CPU 100%不一定是死锁但死锁伴随大量线程blocked时CPU也可能被撑高。看到这个提示一定要细看是哪些线程、持有哪些锁、等待哪些锁。3.3 非Java场景perf top和strace交叉定位Java场景有jstack这个神器但线上还有大量非Java服务比如Go、C、Node.js或者压根就是个老旧的shell脚本在被反复执行。这些场景下我习惯用perf top来定位热点函数。perf top -p 12345perf top会实时显示进程内CPU采样最密集的函数符号。如果符号是16进制地址而不是可读的函数名说明进程的符号表没加载或者被strip了这时候可以尝试用perf record配合perf report做离线分析或者临时装一下debug symbol包。对于Go服务pkill -QUIT拿到goroutine栈或者用Go自带的pprof接口比perf更直接。strace是另一个补充手段。如果怀疑进程在疯狂做系统调用文件读写、网络请求、锁操作可以这样strace -p 12345 -c -f -t -o /tmp/strace.log-c参数会在结束时汇总各类系统调用的次数和耗时-f会跟踪子线程-t记录时间戳。跑个十几秒到几十秒CtrlC结束看统计结果里哪类系统调用最多。之前遇到过一个诡异案例Java进程CPU很高但jstack完全正常后来strace一看进程在疯狂执行futex系统调用是JVM内部的线程同步在做无意义的自旋最后定位到是Linux内核版本和JVM版本的一个已知兼容性问题。4. 根因分析什么样的代码会把CPU打满4.1 业务死循环与正则灾难找到线程栈之后接下来的工作就是解读线程栈判断根因。最常见的根因之一是业务代码里的死循环。我见过最典型的案例是一个while循环里等待某个状态变更但状态变更的写入方因为异常提前返回了这个循环就成了永恒的旋转门。还有一种是for循环里忘记更新循环变量这个属于低级错误但线上真的发生过。判断依据就是jstack里栈顶始终在同一个业务方法里而且多份快照完全一致。比死循环更隐蔽的是正则表达式的灾难性回溯。Java的java.util.regex和很多语言的默认正则引擎一样在匹配某些复杂模式时存在指数级回溯的风险。举个例子一个看似正常的模式在处理异常长的输入时可能从毫秒级膨胀到分钟级把CPU直接打满。这种问题在jstack里看线程栈会卡在java.util.regex.Pattern的匹配方法上但代码逻辑本身没有死循环。排查时看到正则相关栈不要犹豫直接把那个正则拿去做正则回溯测试。顺便说一句日志框架也有类似问题。有些日志框架在拼接日志消息时会做大量的字符串格式化如果业务代码把日志开关关掉了但消息参数已经构造完字符串拼接的开销一样存在。这就是为什么定期清理不用的DEBUG日志、用参数化占位符代替字符串拼接不光是代码洁癖更是线上性能的保命符。4.2 频繁GC与内存分配压力Java服务和多数带垃圾回收机制的运行时CPU 100%的另一大来源是GC。这里的逻辑很多人搞反了以为GC是内存问题其实GC爆掉时CPU一样会被打满。频繁Full GC会让所有业务线程停顿CPU看着就很忙但业务线程几乎没进展。判断GC问题的标准姿势是用jstat看GC状态jstat -gcutil 12345 1000 10每一秒输出一次连续十次看YGC和FGC的频次以及耗时。如果FGC次数飞速上涨或者每次FGC耗时都在几百毫秒以上那基本可以断定是GC风暴。接下来用jmap导出堆dumpjmap -dump:live,formatb,file/tmp/heap.hprof 12345然后用MAT或者VisualVM分析大对象、重复对象、以及存活对象的分布。GC风暴的根因通常是这几种内存泄漏导致堆持续增长、大对象直接进了老年代、或者某个缓存组件无节制地扩张。定位到大对象之后再看业务代码是哪个环节创建了它。这里要强调一个操作顺序先抓jstack再抓jmap dump最后再考虑重启。因为jmap dump时进程会暂停一段时间线上高峰期慎用。如果服务比较重要先在低峰期操作或者用带live参数的dump方式减少停顿。实在没法dump的话至少把GC日志找出来很多情况下GC日志里的信息已经足够定位了。4.3 锁竞争与线程上下文切换锁竞争导致CPU飙升这事经常被误判成业务代码繁忙。大量线程在抢占同一个锁时线程会反复进入阻塞和唤醒状态这个过程中线程上下文切换的开销非常大。Linux里每个线程切换都要几百纳秒到几微秒如果每秒切换几十万次CPU就全耗在这上面了。检查方法有两个一是vmstat看cs列上下文切换次数正常服务器每秒几千次异常时可以到几十万甚至上百万次二是看jstack里大量线程处于BLOCKED状态并且它们等待的锁是同一个。定位到锁之后去分析为什么那么多线程在抢同一个锁——是锁的粒度太大比如把大段业务逻辑都包在synchronized里是锁的持有时间太长比如持锁期间调用了外部接口还是锁的数量太少比如连接池只有10个但并发有500。synchronized之外显式锁比如ReentrantLock、StampedLock还有并发工具类里的自旋比如AtomicLong的CAS重试也可能造成空转。虽然Java的CAS不是无限制自旋但高并发下大量的CAS失败重试仍然会消耗不少CPU。这种问题在jstack里常常表现为线程处于RUNNABLE状态栈顶是Unsafe.compareAndSwapInt这类方法。4.4 外部依赖超时与重试放大效应最后一种高频根因我把它叫做多米诺型CPU飙升。业务代码本身没毛病但它调用的外部依赖HTTP接口、数据库、Redis超时了于是业务代码进入重试逻辑。重试本身没问题但如果重试的退避策略太激进、超时时间设置得太长失败请求会在短时间内指数级放大CPU和线程池都被堆积的任务打满。举个例子一个接口正常响应20ms下游Redis挂了一半节点每次访问卡住2秒才超时。原本每秒能处理5000个请求的线程池现在每个线程要等2秒才能完成一次任务线程池很快被占满队列开始积压调度器忙着把积压的任务分配给CPUCPU使用率直线上升。这种情况下jstack里你会看到大量线程阻塞在SocketInputStream.read或者类似的地方这不是死循环而是等待IO超时。排查这类问题的关键在链路追踪。如果有SkyWalking或Zipkin之类的工具直接看调用链上哪些外部调用耗时异常如果没有就去查数据库慢查询日志、Redis的慢日志、以及上游服务的响应时间监控。找到超时点之后修正超时配置和重试策略才是真正的解药只盯着本服务的代码改来改去问题永远不会消失。5. 实战复盘三个真实案例拆解5.1 案例一日志框架引发的死循环疑云那是个Spring Boot服务某个接口的TPS从峰值直接跌到接近0CPU从20%跳到了100%而且us占比极高。jstack抓下来一看栈顶全是一个自定义的日期格式化方法肉眼看上去就是死循环。但代码翻了三遍逻辑完全正常没有循环没有递归。后来用jstack多抓了几份发现线程栈在同一个方法附近微妙地变化而且大量时间花在SimpleDateFormat的format上。真正的问题在于这个接口每处理一个请求都要格式化一个日期而SimpleDateFormat是线程不安全的。为了避免线程安全问题有人在外层用ThreadLocal做隔离每次请求都创建新的SimpleDateFormat对象然后在高并发下变成一场频繁的短生命周期对象创建风暴。对象创建本身不可怕可怕的是每个SimpleDateFormat内部都会做模式解析这个解析过程的CPU开销被成百上千倍的并发放大。解决方案很简单把日期格式定义成静态常量配合线程安全的DateTimeFormatter来使用或者用Apache Commons Lang的FastDateFormat。CPU占用当天就从100%降到了15%。这个案例告诉我一个道理CPU 100%的热点代码行不一定是死循环也可能是大量线程都在执行一段本不该重复执行的初始化逻辑。5.2 案例二Jackson序列化大对象引发的GC风暴另一回一个消息推送服务突然CPU飙升报警显示FGC频繁大概每两秒一次Full GC每次耗时600毫秒以上。先抓了jstack发现业务线程大多在ObjectMapper.writeValueAsString的方法上。再看jstat老年代占用率一直徘徊在95%以上。当时的场景是业务侧把一个大订单对象放进了缓存然后每次推送消息时从缓存里取出来用Jackson序列化成JSON再发出去。问题出在两个地方一是这个大对象里嵌套了一个list集合这个集合因为历史原因越攒越大达到了几万个元素二是缓存的时间设置过长这个对象在缓存里待了半小时期间list还在不停增长。每次序列化都要遍历整个list分配大量临时对象年轻代装不下全挤进老年代老年代很快被打满于是触发频繁Full GC。处理方式是三层动作第一立刻止损清掉这个缓存keyCPU在几分钟内就回落了第二临时改配置把消息推送的序列化改为异步线程池避免阻塞业务线程第三长期修复给缓存里的对象加了大小上限超过阈值就截断同时把缓存过期时间缩短到五分钟。全程下来GC恢复到每十分钟一次Full GC的正常水平。5.3 案例三连接池超时与重试的放大效应第三个案例是个典型的外部依赖拖垮主服务。一个订单中心的接口CPU持续偏高但jstack里没有明显的死循环也没有GC问题线程大量阻塞在MySQL查询上。当时第一反应是慢SQL查了慢查询日志确实有几条SQL扫描行数过亿但它们的执行频率并不高不足以解释CPU 100%。继续看线程栈发现大多数线程阻塞在获取数据库连接的环节也就是等待连接池分配连接。进一步查连接池状态最大连接数是50但活跃连接数已经打满线程都在排队。为什么活跃连接打满因为那几条慢SQL每一条执行耗时都在十几秒把连接占着不放。一个慢执行的连接占满一个池子里的名额其他正常请求拿不到连接就阻塞等待。而阻塞等待本身不产生太多CPU但业务侧的调用方还配置了超时重试超时后重试的请求又涌进来排队线程数量暴增调度开销和上下文切换把CPU彻底拉满。这个问题的本质不是SQL本身有多慢而是慢SQL的隔离性太差。最终解决方案是三条给慢SQL单独建了一个只读连接池限制它最多占用几个连接给连接获取操作加了超时时间拿不到连接就直接快速失败而不是无限等待优化那几条慢SQL的索引。三层处理完之后CPU稳定在20%左右。6. 排查工具箱与操作禁忌6.1 一套可以直接抄的排查命令组合结合前面讲的各种场景我整理了一套线上CPU 100%的排查命令组合按顺序执行就行。这套命令我已经用了很多年实测在绝大多数Linux环境下都能跑通# 第一步全局视角确认负载与CPU状态 top -n1 -b | head -30 # 第二步定位高CPU进程保存现场 top -n1 -b | sort -k9 -r | head -20 /tmp/cpu_top.log # 第三步对可疑进程做线程级采样Java示例 pidstat -p PID -t 1 10 /tmp/cpu_pidstat.log # 第四步换算线程ID并抓线程栈Java printf %x\n 线程PID jstack -l 进程PID /tmp/jstack1.log sleep 3 jstack -l 进程PID /tmp/jstack2.log # 第五步检查GC状况Java jstat -gcutil 进程PID 1000 10 # 第六步用perf看热点函数通用 perf top -p 进程PID # 第七步检查上下文切换与IO等待 vmstat 1 10这套组合拳跑完大部分场景都能定位到问题层面。注意每一条命令都要保存输出别只看不吃也不要在同一台服务器上同时跑太多重型工具先top和pidstat够了再上jmap到堆dump别一上来就把进程停住。6.2 三个容易犯的错误我全踩过第一个错误是拿生产环境当测试环境乱试工具。jmap dump、重启、甚至升级JDK这些操作都有副作用在低峰期操作和高峰期操作完全不是一回事。有一次我在高峰期执行jmap dump进程STW了将近十秒线上接口超时瞬间翻倍。之后再遇到类似场景我都会先跟团队确认能否在低峰期操作或者确认这个节点是否可以快速摘除流量。第二个错误是只看一个时间点的快照就下结论。CPU 100%有持续型和尖峰型之分持续型好办尖峰型最坑。有些服务的CPU使用率每五分钟就有一个尖峰持续十几秒又降下去恰好报警阈值卡在这个尖峰上。这种问题必须靠pidstat这类定时采样工具拉长时间段看趋势单点top快照完全说明不了问题。第三个错误是忽略系统层面的告警数据。很多公司有监控系统比如Prometheus、Zabbix之类的报警来临时监控面板上已经有不少信息了。但很多开发上了服务器就把监控数据抛在脑后只顾着敲命令。说句实话监控面板上的CPU使用率历史曲线、IO等待曲线、网络包量曲线往往比现场top的输出更有价值。因为它们能告诉你CPU是从哪个时间点开始飙的、和哪个部署行为时间吻合、同时段有没有磁盘或网络异常。7. 常见问题速查表最后把这几年遇到的高频问题按症状—原因—排查突破口—解决方向整理成一张表方便以后遇到问题时快速对照。症状常见原因最直接的排查入口解决方向us高单线程CPU拉满栈稳定业务死循环 / 正则灾难性回溯jstack多份对比看栈顶方法修复循环逻辑替换正则或加超时us高大量线程在创建对象频繁短生命周期对象分配jstat看YGC频率jmap看对象复用对象调整年轻代大小FGC频繁老年代打满内存泄漏 / 大对象入老年代jstat -gcutil、heap dump分析修泄漏点限制缓存大小调堆配置sy高上下文切换频繁锁竞争 / 线程数过多vmstat的cs列jstack看BLOCKED缩小锁粒度调整线程池大小所有线程阻塞在IO等待外部依赖超时 / 慢SQL链路追踪、慢日志、连接池状态优化超时和重试策略修慢查询st高CPU显示满但进程正常云主机宿主机超卖云平台监控、迁移实例联系云厂商调整实例规格进程正常但CPU持续偏高日志框架 / 序列化反复执行线程栈反复采样perf top确认精简日志更换序列化方式进程根本不是自己的服务脚本任务 / 残留僵尸进程top前10个进程逐一确认清理定时任务完善进程管理排查线上CPU问题说到底是两件事的博弈一是抢时间在信息消失前尽可能留证据二是讲逻辑从全局指标分流到进程再细化到线程和调用栈最后落到代码和外部依赖上。这中间没有任何一条捷径但每一步都有章可循。上面这套流程和命令你在自己的环境里多练几次等真出事故的时候就不会手忙脚乱了。我个人最大的体会是CPU排查最值钱的不是把某个进程杀掉的那一刻而是把一根完整的证据链保留下来的那几分钟。服务器随时可能重启监控窗口随时会过期但只要你留住了现场数据事后无论做事故复盘、写报告还是设计改进方案心里都有底。另外一个实用建议平时在测试环境故意制造几次CPU 100%的故障比如写个死循环跑起来然后按这套流程从top到jstack走一遍练到条件反射为止。线上的每一分钟都很贵别把第一次完整练习留给生产事故。