线上群在晚上十一点突然热闹起来。监控面板上,核心接口的 p99 延迟从平时的 80 毫秒一路拉到 3 秒,错误率开始抬头。你下意识地切到主机监控页,想看看资源是不是被打满了。CPU 使用率:15%。刷新一次,还是 15%。这时候团队里很容易出现分歧:有人觉得是监控打点出了问题,有人开始怀疑网络被限速,还有人直接想把集群扩容一倍。但在动手之前,需要先把一个基本判断做对:当 CPU 只有 15% 而接口延迟飙升时,问题大概率不是“算不过来”,而是“在等”。
这不是一个反直觉的巧合,而是线上排障里非常经典的一类现象。CPU 指标只描述了一件事:这台机器每秒真正在执行指令的比例。它没有告诉你,你的业务线程此刻有多少比例是阻塞的、等待的、挂起的。延迟是请求从进入到返回的总耗时,它由“在 CPU 上执行的时间”和“不在 CPU 上执行的时间”共同组成。CPU 低,恰恰说明大量线程根本没在跑,而是卡在某个资源的门口排队。
这篇文章不打算给你一个万能排查脚本,而是想把这个问题的排查链路拆完整:低 CPU 高延迟到底意味着什么、最容易藏在哪几个瓶颈里、应该按什么顺序查、查完之后怎么避免下次再来一次。
1. 先别急着加机器:这个问题真正要查的是“线程在等什么”
很多同学拿到“CPU 15%,延迟飙升”这个组合,第一反应是扩容。我见过不止一次,加完机器 P99 不但没降,反而因为并发抬高把下游数据库压得更狠。原因很简单:如果瓶颈不是算力,加的每一台机器都只是给同一个拥堵点增加了更多的排队者。
1.1 一次请求的延迟,大部分时间可能不是在执行
从线程状态的角度看,一个请求从进入到返回,线程只会在三种状态里切换:
- RUNNABLE:正在 CPU 上跑,或者等待被调度到 CPU 上。
- WAITING / TIMED_WAITING:主动或被动地等某个条件,比如等锁、等队列、等 sleep 结束。
- BLOCKED:被锁挡住,拿不到 monitor。
当 CPU 只有 15% 时,意味着系统中还有 85% 的计算资源是空闲的,但大量线程既不 RUNNABLE 也不在跑计算。它们停在那里,唯一的解释是等某个东西。
所以,排查任务的核心就变成了回答一个问题:线程在等什么?
这个问题,CPU 监控答不了,内存监控也答不了。能回答的只有线程转储(thread dump)、连接池指标、GC 日志和下游依赖的耗时画像。
1.2 “CPU 15%”这个数字本身也可能是假象
在深入排查之前,先花三十秒确认这个数字到底是怎么测出来的。我见过多次监控没问题、数据链路出错的情况。
- 多核机器上,15% 可能是“全部核的平均值”。8 核机器 12.5% 就是满打满算跑满了一个核,如果很多任务其实挤在单线程或单核路径上,平均 CPU 就被稀释了。
- 容器场景下,CPU 使用率还要看 cgroup 的 quota 和 throttle。宿主机的 CPU 百分比和容器视角可能不一致,出现过“外部监控 15%,容器内其实一直在被限流”的情况。
- 监控采集的是整机平均值,而你的服务可能只部署在其中一台宿主机上,或者采集间隔太长,把瞬时尖峰抹平了。
所以,看到 CPU 15%,不要直接跳进“资源没问题”的结论。先用top、uptime、pidstat这类命令,确认负载均值(load average)、运行队列(r 列)、以及目标进程的真实 CPU 占用。如果这些也确实很低,再往下走。
排障的第一原则:先确认现象本身是真实的,再开始解释现象。数字没核实清楚,后面每一步都可能是白做。
2. 低 CPU 高延迟的四个常见“隐形瓶颈”
当确认算力确实没打满,接下来要逐一排查的,就是那些能让线程“原地等待”的资源。以我个人经验来看,出现频率最高的是四类:线程池耗尽、连接池耗尽、锁竞争、IO 阻塞(含网络和磁盘)。它们有一个共同点:完全不体现在 CPU 上,只体现在线程状态和等待时长上。
2.1 线程池耗尽:活都排到队列里了,机器却闲着
Web 容器、RPC 框架、异步处理框架,本质上都是一个线程池在接活。常见的情况是:Tomcat 的 max-threads、Dubbo 的 threads、或者业务自定义线程池被打满后,新的请求只能排队。
特征很典型:
- CPU 不高。
- 接口响应时间上升,但错误率未必马上涨,因为请求在排队。
- 如果队列有界,排满后开始抛 RejectedExecutionException 或者连接直接超时。
验证方式,在 JVM 技术栈里非常直接:
jstack <pid> | grep -A 30 "http-nio" | head -50或者:
jcmd <pid> Thread.print > /tmp/threaddump.txt看线程栈中大量线程是不是停在类似ThreadPoolExecutor.getTask()或AbstractQueuedSynchronizer.park()的位置。如果大量业务线程都是WAITING状态,而它们的池子又是同一个,基本可以判断线程池满了。
注意,线程池被打满,往往不是线程池本身配得不对,而是下游变慢导致每个任务执行时间变长。一个原本 100ms 的任务变成 3s,同样的 QPS 下,线程池占用自然飙升。
2.2 连接池耗尽:数据库连接、HTTP 连接、Redis 连接都在排队
线程池满了往往能指向一个更底层的原因:你的线程拿着连接在等一个慢查询,或者等一个慢的第三方接口。这些连接长时间不释放,连接池就被占满。
最常见的现象是日志里出现:
HikariPool-1 - Connection is not available, request timed outJedisConnectionException: Could not get a resource from the poolorg.apache.http.conn.ConnectionPoolTimeoutException
如果用的是数据库连接池,去数据库侧看一眼就知道:
SHOW PROCESSLIST; -- 或者 PG 里查 pg_stat_activity如果看到大量连接处于Sleep但事务一直没提交,或者有Lock wait timeout exceeded的提示,说明瓶颈可能在数据库锁或慢 SQL 上。
这里有个很容易被忽略的点:连接池耗尽和线程池耗尽经常成对出现。线程拿到连接后要执行 SQL,SQL 慢,线程就占着连接不放;连接池满了,其他拿不到连接的线程就排队;线程池被排队任务占满,新请求进不来。表面上看起来是线程池问题,根因却在数据库那一层。
2.3 锁竞争:请求不是先到先得,而是“被锁拦住”
锁引发的高延迟,是一类更隐蔽的问题。因为它在监控图上几乎不留下痕迹,只有线程转储的那一瞬间才能看清楚。
两类锁最值得注意:
- 应用层锁:JVM 内置锁(synchronized)、ReentrantLock、以及各种分布式锁。
- 数据库层锁:行锁、表锁、间隙锁、唯一键冲突。
排查 JVM 锁,同样靠线程转储。如果看到大量线程处于BLOCKED状态,并且栈顶都停在同一个类的方法上,那就是典型的锁竞争:
jstack <pid> | grep -B 10 "locked monitor" | head -50数据库锁则要查:
SELECT * FROM information_schema.innodb_trx; SELECT * FROM information_schema.innodb_lock_waits;分布式锁的竞争有时更隐蔽,因为锁本身在 Redis 或数据库里,线程转储只能看到一段等待锁的代码,看不出锁的实际持有者。这种情况,只能通过业务日志把锁的 key、持有时间、获取时间打出来,再做聚合。
我把这四个维度整理成一张速查表,排障时可以对着看:
| 现象特征 | 最可能瓶颈 | 第一验证手段 |
|---|---|---|
| 大量线程 WAITING,任务排队 | 线程池耗尽 | jstack / jcmd Thread.print |
| 日志报连接获取超时 | 连接池耗尽 | 连接池监控 + 数据库 SHOW PROCESSLIST |
| 大量线程 BLOCKED 在同一方法 | 应用层锁竞争 | jstack 看 monitor 持有者 |
| 偶发超时,CPU、线程、连接都正常 | 网络抖动 / GC / 磁盘 | sar、GC 日志、iostat |
| 容器内 CPU 很高但外部监控低 | cgroup 限流 | 查看 /sys/fs/cgroup/cpu.stat |
2.4 IO 阻塞:磁盘慢、网络丢包、GC 停顿,都能让延迟凭空上涨
还有一类问题,既不占 CPU,也不占线程池,但就是会让接口变慢。
磁盘 IO 是典型。日志落盘是同步写,如果磁盘满了或者云盘出现抖动,写入一段日志都可能要等几百毫秒。更麻烦的是,有些框架在写日志时会同步刷盘,一旦磁盘出问题,整个请求链路都被拖住。排查工具是iostat -x 1,重点看%util、await和svctm,如果await远高于几毫秒,说明磁盘在排队。
网络同样如此。跨机房调用、依赖公网的第三方接口,一旦出现丢包和重传,TCP 重传时间会直接把超时拉满。排查常用:
sar -n DEV 1 5 netstat -s | grep -i retrans ss -sGC 停顿也是低 CPU 高延迟的重要来源。一个 Full GC 导致应用线程完全停止,如果停止时间超过 1 秒,接口延迟必然被拉高。奇怪的地方在于,GC 线程本身只占少数核,整体 CPU 平均值可能依然不高。所以务必看 GC 日志,尤其是GC pause的持续时间,而不是只看 CPU。
3. 一套可复用的排障顺序:从现象到线程状态再到根因
很多团队的现状是:监控大盘很全,但遇到事故时还是靠感觉猜。低 CPU 高延迟这个问题,完全可以用一套固定顺序来缩小范围。我一般建议按下面六步走,每一步都以上一步的结果为依据,不要跳步。
3.1 第一步:确认系统层面的运行队列和负载
先看主机到底是闲还是忙:
uptime vmstat 1 5重点关注三列:
r:运行队列长度。如果远大于 CPU 核数,说明任务在排队等 CPU,但 CPU 百分比不一定立刻打满。b:处于不可中断睡眠(D 态)的进程数。持续不为 0,几乎都是磁盘 IO 在等块设备。si/so:swap 换入换出。如果一直不为 0,内存可能不够,进程在内存和磁盘之间颠簸。
这一步能快速区分“CPU 排队”和“IO 等待”,避免误判方向。
3.2 第二步:看目标进程和线程的 CPU 占用
用top -Hp <pid>看一下进程内部各线程的 CPU。如果所有线程的 CPU 都很低,说明确实不是算力问题;如果某个线程 CPU 高,比如 90%+,说明系统里有热点计算,需要再往那个线程的栈里挖。
这一步也能排除一种情况:CPU 平均值低,但其实是某一个核被打满,其他核闲着。单线程瓶颈和整体算力不足,处理方式完全不一样。
3.3 第三步:抓线程转储,统计线程状态
如果确认不是计算热点,下一步就是抓两次线程转储,间隔 5 到 10 秒,然后统计状态分布:
for i in 1 2; do jstack <pid> > /tmp/threaddump_$i.txt sleep 5 done grep -c "java.lang.Thread.State: WAITING" /tmp/threaddump_1.txt grep -c "java.lang.Thread.State: BLOCKED" /tmp/threaddump_1.txt两次对比的意义在于:如果状态分布变化很大,说明系统还在剧烈波动;如果两次都很一致,说明线程稳定卡在同一个状态,问题很可能是持续性的资源瓶颈。
然后打开文件看栈顶,找同一段代码出现次数的峰值。哪个方法反复出现且大量线程停住,就是当前的拥堵点。
3.4 第四步:查连接池和下游依赖耗时
线程状态只能说明“在等”,不能说明“在等谁”。所以下一步要查连接池指标和下游依赖。
- 数据库连接池:看 active 数是否长期接近 max,看获取连接的平均等待时间。
- Redis / 缓存:看 get/set 的 p99 耗时。
- HTTP / RPC 下游:看每个下游调用的超时、成功率、耗时分布。
如果公司内部已经有 APM 全链路追踪,这一步会非常快。没有的话,至少要在业务日志里把关键调用的耗时打出来,按接口聚合。
3.5 第五步:检查 GC 日志和容器限流
GC 停顿经常被当成“灵异事件”排在最后,其实它应该排在连接池之前。看一眼 GC 日志里的停顿时间,10 分钟之内如果有多次超过 500ms 的停顿,基本可以下一个判断:延迟尖峰和 GC 强相关。
容器限流则要查:
cat /sys/fs/cgroup/cpu/cpu.stat看nr_throttled和throttled_time。如果 throttled_time 在持续增长,说明容器的 CPU 配额撑不住了,外部监控可能看不出,但服务内部确实在被打断。
3.6 第六步:对照日志,锁定首报时间点前后的变化
所有工具都查完之后,回到业务日志,找延迟开始飙升的前后 5 分钟发生了什么:
- 有没有新上线的功能?
- 有没有定时任务在整点触发?
- 有没有下游依赖发布变更?
- 有没有数据量突然增长?
很多低 CPU 高延迟的根因是“平时跑得好好的,某个变更触发了慢路径”。比如一个新的筛选条件导致 SQL 没走索引,或者一次数据订正把某张表锁住了。这类问题工具能帮你缩小范围,最终定位往往要靠日志级的复盘。
4. 一次典型事故的复盘:从 80ms 到 3s,根因在连接池而不是 CPU
说一个我印象很深的修复过程,几乎就是低 CPU 高延迟的教科书案例。
现象是某个订单查询接口在工作日晚高峰突然变慢,P99 从 80ms 涨到 3s,错误率缓慢上升。主机监控显示 CPU 15%,内存正常,网络流量也没有异常。第一轮排查,团队里有人怀疑是数据库慢查询,有人怀疑是新上线的缓存异步化改造出了问题。
我没有急着下结论,而是按上面的顺序走了一遍。
先看vmstat,r列和b列都很低,说明主机层面没有明显的排队。再看进程内线程,top -Hp显示所有线程 CPU 都很低。第三步抓jstack,结果非常明显:大量 Tomcat 工作线程处于WAITING,栈顶停在 HikariCP 的getConnection上,后面跟着Connection is not available, request timed out。
到这里,方向已经清晰:线程池本身没满,但所有线程都在等数据库连接。接下来查数据库侧,SHOW PROCESSLIST显示有几十个连接处于Query状态,执行的是一条看起来很久的UPDATE语句,还有一部分连接处于Sleep状态但事务一直没有提交。
继续追,发现罪魁祸首是一个定时任务:每晚这个时间点会跑一次全量数据订正,对订单表做范围更新。由于更新条件和主索引不匹配,触发了全表扫描,并且对相关行加了写锁。查询接口的SELECT被这些写锁挡住,连接迟迟不释放,连接池被耗光,后面的请求全部排队等连接。
修复手段并不复杂:
- 停掉或者错峰运行这个定时任务。
- 给 UPDATE 语句的 WHERE 条件补上合适的索引。
- 把订正任务改成小批量循环,避免长事务持锁时间过长。
- 给连接池的获取连接设置更短的超时,快速失败而不是无限排队。
复盘的时候大家都在问:为什么 CPU 不高?因为数据库锁等待根本不消耗数据库 CPU,也不消耗应用 CPU,应用线程只是在WAITING。真正消耗资源的是锁竞争背后的长事务,而它在 CPU 监控上完全隐形。
这次事故给我最大的启发是:低 CPU 高延迟不是“没有线索”,而是线索不在一眼能看到的地方。线程状态、连接池水位、慢 SQL、锁等待,这些才是真正需要常看的指标。
建议在排障时不要只盯着 CPU、内存、带宽这些“资源类指标”,还要盯“等待类指标”:线程池活跃数、连接池活跃数、锁等待次数、GC 停顿时长。后者才是判断这类问题的关键。
5. 什么时候才是真的 CPU 问题?别把方向带偏
谈了这么多非 CPU 原因,也要说清楚边界:确实存在一些情况,CPU 指标就是主要线索,只是容易被“15%”这个数字带偏。
5.1 用户态 CPU 高:真的在算
如果top里进程 CPU 常驻 90% 以上,线程栈里热点集中在某个方法,比如大量的 JSON 序列化、加密解密、正则匹配或大对象拷贝,那就是典型的 CPU 密集问题。低 CPU 高延迟这套排查思路用不上,应该直接做热点分析和代码优化。
5.2 内核态 CPU 高:系统调用或上下文切换异常
如果top里%sy占比很高,超过 30%,需要查是不是有大量的系统调用、线程频繁切换、网络包处理异常。pidstat -w 1可以看上下文切换次数,正常情况下每秒几千次以内,如果到了几十万次,说明线程调度出了问题。
5.3 负载高但 CPU 低:D 状态进程在等 IO
这种情况用vmstat的b列一眼就能看出来。进程处于不可中断睡眠,等于“卡在 IO 上”,负载会被拉高,但 CPU 是空闲的。处理方向是磁盘、文件系统、存储挂载,而不是应用代码。
5.4 容器里 CPU 配额被打满:外部监控不敏感
容器化部署后,宿主机层面的 CPU 监控可能一直是低的,但容器内部因为 cgroup 配额不足被打断。体现在服务上就是接口抖动、延迟升高,看宿主机的 CPU 完全没有意义。所谓“CPU 只有 15%”可能只是宿主机很闲,你的容器已经用完了自己的配额。判断方法就是看cpu.stat里的nr_throttled增量。
所以,真正适合“加机器”的低 CPU 高延迟场景其实很少。除非你确认了是容器配额不足、单核热点、或者确实某个中间件资源被打满,否则扩容只是在拖延问题。
6. 把这次经验沉淀成监控、告警和应急预案
一次排障解决的是一次事故,但如果没有沉淀,下个月同一类问题换一层皮会再回来。低 CPU 高延迟这类问题,特别适合提前通过监控避免,因为它的几个主要瓶颈都有明确的量化指标。
6.1 至少要把这些指标纳入监控
- 线程池:活跃线程数、队列深度、拒绝任务数。不要只看线程池最大值,要看它离最大值有多近。
- 数据库连接池:活跃连接数、等待获取连接的次数和平均等待时间。
- 下游依赖:每个依赖的调用量、成功率、P99 耗时。
- GC:GC 次数、单次停顿最长耗时、Full GC 频率。
- 系统层:load average、运行队列长度、D 态进程数、磁盘 await、网络重传率。
6.2 设置合理的告警阈值
阈值要根据自身业务调整,但有几个通用原则:
- 线程池活跃数连续 5 分钟超过 80%,就值得关注,而不是等到 100% 才告警。
- 数据库连接池等待时间持续超过 200ms,大概率是慢 SQL 或长事务。
- GC 单次停顿超过 300ms 就要告警,说明堆或回收策略已经不适合当前流量。
- 容器
nr_throttled持续增长,要提前考虑调整配额。
不要给 CPU 设一个“超过 80% 才告警”的规则就完事。低 CPU 高延迟恰恰说明,很多事故发生时 CPU 都很安静,真正该告警的是线程池和连接池这些“看不见的水位”。
6.3 沉淀一份排障 Runbook
把上面第三节的六步顺序固化成一份文档,写清以下内容:
- 什么现象触发这套流程(低 CPU + 高延迟 + 错误率上升)。
- 每一步要执行的命令,以及预期的输出形态。
- 每一步查完什么结果时,下一步应该往哪走。
- 最后如何从根因回到修复方案。
Runbook 的价值不在于每步都写得多么精细,而在于它把一次事故的“分析过程”从个人经验变成了团队资产。下次有人值班遇到同样现象时,不再是从零开始猜,而是直接按顺序执行。
6.4 复盘时多问一层“为什么”
每次事故复盘,除了“这次怎么修好的”,还要追加两个问题:
- 这个瓶颈为什么没有被更早发现?
- 如果我们不改代码,什么样的流量增长会再次触发类似问题?
连接池耗尽和线程池耗尽这类问题,往往是容量规划的阴影面。CPU 可以按核数估算容量,但连接数、线程数、锁粒度、下游并发上限,这些都需要在压测阶段就暴露出来。否则,线上 CPU 再低,也拦不住一次慢 SQL 引发的雪崩。
7. 回到本质:学会观察“等待”,而不是只观察“计算”
低 CPU 高延迟这个组合,很容易让人产生一种误解:系统很闲但很慢,说明问题很玄。其实它一点都不玄,它只是说明我们平时构建的监控体系偏向了“计算资源”,而忽略了“等待资源”。
计算机系统里,真正影响接口延迟的往往不是你用了多少 CPU,而是你的请求在某些关键路径上等了多少。等数据库连接、等线程池空闲、等一把锁释放、等一次 GC 结束、等磁盘把数据刷下去。这些等待几乎都不产生 CPU 开销,却都能让一个接口从几十毫秒变成几秒。
所以,下一次再遇到“CPU 才 15%,接口延迟却飙升”时,不要急着加机器,也不要先猜网络。按顺序做三件事:确认数字真实、抓线程转储看线程在等什么、然后沿着等待链路往后查连接池、锁、GC 和下游依赖。把“等待”变成可观测、可量化的指标,这类问题才能真正被根治。
线上系统很少会毫无理由地变慢,它只是在提醒你:你还没看到它真正在等的东西。