news 2026/9/9 16:59:27

CPU不高却延迟飙升?从线程池到连接池的线上排障实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CPU不高却延迟飙升?从线程池到连接池的线上排障实战

半夜两点,监控告警突然响起:订单接口的 P99 延迟从 50ms 一路涨到 4.2s,错误率也在缓慢爬升。你打开服务器面板,第一眼看到的是 CPU 利用率只有 15%,内存还剩一大半,load average 也不算离谱。你第一反应是网络出问题了,于是开始查带宽、查防火墙、查交换机端口,折腾半小时却一无所获。

这种“CPU 不高,接口却慢得离谱”的场景,很多后端开发都遇到过,而且特别容易把人带偏。因为直觉告诉我们,服务变慢通常意味着资源被打满,但 CPU 15% 直接推翻了这条判断。于是有人开始怀疑机器被入侵、虚拟化层抖动、甚至监控数据本身出错。

这篇文章想用一个非常典型的线上排查路径,把这层窗户纸捅破:CPU 利用率低,恰恰说明“卡点”不在算力,而在等待。接口延迟飙升,不等于 CPU 必须打满,它可能意味着线程池排队、数据库连接等待、下游依赖拖慢、公共锁竞争,甚至是 CPU 被虚拟机调度策略限流。只有把视角从“资源利用率”切换到“请求流转链路”,你才不会被 15% 这个数字困住。

本文会讲清楚这类问题的底层机制、排查步骤、核心命令与脚本、根因定位方法,以及修复后的验证方式。内容面向需要参与线上排障的后端开发、运维和 SRE,也适合正在学习服务治理的初中级工程师收藏备用。

1. 为什么 CPU 不高,接口延迟却飙升

先看一个最基本的延迟模型:接口延迟 = 排队等待 + 处理耗时 + 网络传输。CPU 利用率低,说明“处理耗时”没有把 CPU 压到极限,问题大概率出在“排队等待”或者“线程阻塞”上。这就是为什么只盯 CPU 会误判方向。

打个比方:一家餐厅有 20 个厨师,但厨房只有 5 个灶台。顾客看起来坐在餐厅里等了很久,菜却一直不上,这时你去看厨师,发现他们都是闲着的,因为他们在等灶台空出来。CPU 就是厨师,灶台就是数据库连接、下游接口、锁资源、线程池队列。CPU 15%,只说明厨师们不忙,但顾客依然被堵在排队环节。

具体来说,导致“CPU 不高但接口慢”的常见根因有以下几类:

根因方向CPU 表现接口延迟表现典型信号
线程池耗尽用户态低大量请求排队jstack 大量 WAITING
数据库连接池占满用户态低,wa 不一定高请求阻塞在获取连接连接获取超时
下游依赖服务变慢接口响应长尾严重下游 RT 上涨
公共锁竞争激烈user 可能偏低或中等关键路径卡住jstack 大量 BLOCKED
CPU steal / cgroup 限流物理机有负载但容器不高调度延迟/proc/stat 中 steal 高
GC 或内存问题低,但有 GC 线程活跃周期性停顿GC 日志频繁 Full GC

从这里可以提炼出一个核心判断:CPU 利用率是“算力消耗”指标,不是“服务健康”指标。CPU 低不代表系统闲着,更不代表用户体验正常。真正要关注的是请求是否在某个环节被堵住了。

还有一个容易被忽视的细节:单核打满会被整体利用率平均稀释。比如一台 2 核机器,一个线程跑满一个核,整体 CPU 也只有 50%。如果你只盯总利用率,很容易漏掉单核热点。排查时建议同时看单核维度,例如top -Hpsar -P ALL,避免被平均数误导。

2. 排障第一原则:先看指标,再动代码

线上排障最怕的不是问题复杂,而是心态着急。CPU 不高、接口延迟高这种问题,往往需要多类指标交叉验证,一上来就重启服务或者盲目回滚,很可能把第一现场破坏掉。正确做法是先保留现场,再逐步缩小范围。

在动手改代码之前,至少先跑一遍以下命令做系统级健康检查:

# 查看进程内线程维度 CPU 占用 top -Hp <pid> # 每 1 秒输出一次系统核心指标 vmstat 1 10 # 查看线程状态和上下文切换 pidstat -wt 1 10 # 查看网络设备吞吐和错误 sar -n DEV 1 5 # 查看内核日志,确认是否有 OOM、硬件错误 dmesg -T

很多人拿到top只知道看 CPU 百分比,其实信息量不够。更值得看的是vmstat输出中的几列:

字段含义关注点
r运行队列长度明显大于 CPU 核数时,说明线程在排队等待调度
cs上下文切换次数每秒几十万次时,要警惕锁竞争或线程频繁切换
waIO 等待占比高说明磁盘或存储成为瓶颈
si / soswap 换入换出不为 0 说明内存压力已经很大

CPU 使用率本身也要拆开看。top默认显示的是一堆数字,但判断问题必须分清楚:

  • user:用户态程序占用。高说明业务代码在计算或自旋。
  • sys:内核态占用。高可能与系统调用、网络协议栈、锁有关。
  • wa:等待 IO。高说明卡在磁盘、网络文件系统等外部设备。
  • st:steal 时间。虚拟化环境下被宿主机抢走 CPU 的比例,这个值高了会导致服务变慢但“看起来 CPU 占用很低”。
  • id:空闲占比。

如果看到wast明显偏高,CPU 利用率再低也不能说明系统健康。很多时候真正的问题就藏在这几个小数字里。

3. 典型排查路径:把问题缩小到调用链

系统级指标只能告诉你“哪里不对劲”,不能告诉你“哪个接口、哪段代码出了问题”。所以下一步要快速定位请求在调用链中的哪个环节变慢了。

一个常规的请求链路是:接入层(Nginx/网关) -> 业务服务 -> 中间件 -> 数据库 / 下游服务。排查时要先判断慢的现象是全局性的,还是局部性的:

  • 所有接口都慢:问题大概率出在公共资源上,比如 JVM 全局锁、线程池、数据库连接池、机器本身。
  • 只有某一个接口慢:向下游排查,看数据库慢 SQL 还是外部依赖接口 RT 升高。

此时链路追踪工具非常关键。如果项目里已经接入了 SkyWalking、Zipkin、Jaeger 或云厂商的 APM 产品,可以直接看该接口的 Span 耗时分布。一个请求中耗时占比最大的 Span,往往就是问题所在。

没有全链路追踪的话,退而求其次,在服务入口处给每个请求打点,记录三段耗时:Controller 前耗时、Service 内耗时、远程调用或数据库访问耗时。通过对比能很快判断耗时集中在哪一段。

比如你发现接口慢的时候,Service 内部有大量时间花在getConnection()上,那就重点查数据库连接池。如果时间花在socketRead上,重点查下游接口和网络。如果时间花在线程池提交任务后的Future.get()上,重点查任务内部到底阻塞在哪里。

这样一层层往下钻,基本能把问题从“服务整体变慢”收敛到“某一个具体依赖变慢”。注意,这一步不要凭空猜测,要用数据说话。可以先切一小部分流量到测试环境或灰度环境复现,但要在业务低峰期操作,并且保留好线程快照和 GC 日志。

4. 深入线程层:jstack 抓取等待与锁的现场

当系统级指标无法解释问题,调用链又定位到某个服务内部时,下一步就是直接看线程在干什么。

最有效的命令就是jstack。它可以把 JVM 内所有线程的当前栈信息打出来,相当于拍了一张“线程集体照”。配合top -Hp可以反向定位到具体线程。

# 1. 先找到 Java 进程 pid jps -l # 2. 抓取线程栈 jstack <pid> > /tmp/jstack_$(date +%s).txt # 3. 如果某个线程 CPU 高,用 top -Hp 找到这个线程的十进制 pid top -Hp <pid> # 4. 把十进制 pid 转成十六进制 printf '%x\n' <十进制线程id>

拿到线程 id 后,在 jstack 文件里搜索对应的nid=0x...,就能看到这个线程当时正在执行什么代码。

排查“CPU 低但接口慢”时,重点看业务线程的状态分布:

线程状态含义容易对应的问题
RUNNABLE正在运行或等待 CPU看栈内代码具体位置
BLOCKED等待进入 synchronized 锁公共锁竞争
WAITING无限期等待线程池队列、Future.get、Object.wait
TIMED_WAITING限时等待sleep、带超时锁、网络读
D 状态不可中断睡眠IO 阻塞,比如磁盘或网络卡住

假设抓到的栈中,大量业务线程都停在同一个位置,比如connection.prepareStatement或者socketRead,那问题就非常明确了:线程不是在“算”,而是在“等”。这就是 CPU 利用率始终上不去的核心原因。

实际 jstack 输出可能长这样(示意,具体类名以自己项目为准):

"http-nio-8080-exec-12" #12 daemon prio=5 os_prio=0 cpu=8.13ms elapsed=752.36s tid=0x00007f5b2400e800 nid=0x7f3b in Object.wait() [0x00007f5b0bffa000] java.lang.Thread.State: WAITING (parking) at jdk.internal.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:444) at java.util.concurrent.FutureTask.get(FutureTask.java:201) at java.util.concurrent.ThreadPoolExecutor.get(ThreadPoolExecutor.java:1050) at com.example.OrderServiceImpl.submit(OrderServiceImpl.java:88)

看到这类栈,说明线程都卡在等待某个异步任务完成,而这个异步任务又迟迟不返回。抓完 jstack 后一定要保存文件,不要急着释放现场。后面定位根因、复盘复盘,都需要这份原始快照。

5. 完整示例代码与排查脚本

为了更直观地演示“CPU 低但线程池耗尽”的现象,这里写一个最小 Java 模拟程序。它模拟一个固定 10 线程的线程池,任务内部调用一个“慢下游”,每个任务固定等待 800ms。当请求量超过线程池处理能力时,大量任务在队列里排队,但 CPU 消耗非常低。

import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; /** * 文件路径:FakeSlowService.java * * 模拟场景: * 1. 固定 10 线程的线程池处理业务任务 * 2. 每个任务都要调用一个慢下游,固定 sleep 800ms * 3. 大量任务进入队列排队,CPU 消耗极低,但请求延迟飙升 */ public class FakeSlowService { public static String callDownstream(String orderId) { try { // 模拟慢 SQL / 慢外部接口 Thread.sleep(800); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return "ok:" + orderId; } public static void main(String[] args) throws InterruptedException { ExecutorService pool = Executors.newFixedThreadPool(10); for (int i = 0; i < 1000; i++) { final String orderId = "order-" + i; pool.submit(() -> { callDownstream(orderId); return null; }); } pool.shutdown(); // 无论任务是否执行完,最多等 1 分钟就退出 pool.awaitTermination(1, TimeUnit.MINUTES); } }

运行:

javac FakeSlowService.java java FakeSlowService

运行期间打开另一个终端,用下面的脚本统计线程状态分布:

#!/bin/bash # 文件路径:thread_stat.sh # 用法:./thread_stat.sh <pid> pid=$1 # 抓取线程栈 jstack "$pid" > "/tmp/jstack_${pid}_$(date +%s).txt" # 统计线程状态 grep "java.lang.Thread.State" "/tmp/jstack_${pid}_$(date +%s).txt" \ | sort \ | uniq -c

预期会看到大量线程处于TIMED_WAITING,因为Thread.sleep是限时等待。如果把这个 sleep 换成数据库连接等待或Future.get(),就会出现更多WAITING。而系统层面的 CPU 很可能只有几个百分点。

再增加一个查看线程阻塞在哪里的命令:

# 结合 top -Hp 找到高 CPU 线程,再在 jstack 文件中定位 top -Hp <pid> # 假设看到线程 pid 为 12345,转换为十六进制 printf '%x\n' 12345 # 在 jstack 文件中搜索 grep -n "nid=0x3039" /tmp/jstack_<pid>_*.txt

这套脚本在真实排障中很实用。遇到“CPU 不高但接口延迟高”,先抓一次线程状态分布,能过滤掉一大半的可能性。

6. 定位根因:慢 SQL、下游依赖还是锁竞争

拿到线程栈之后,下一步是根据栈上停靠位置继续向下游排查。这里分三种比较常见的场景。

6.1 线程阻塞在数据库连接获取

如果线程栈停在类似getConnectionwaiting for connectionConnectionHolder的位置,说明数据库连接池已经耗尽,线程都在排队等待连接。此时需要去数据库侧确认发生了什么:

-- 查看当前正在执行的 SQL SHOW FULL PROCESSLIST; -- 查看慢查询是否开启 SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time';

如果慢查询日志显示有大量慢 SQL,或者SHOW FULL PROCESSLIST中看到某个SELECTUPDATE长时间处于Sending dataWaiting for table metadata lock等状态,那基本就是数据库拖住了整个业务线程池。

此外,还要检查是否有行锁等待。如果大量事务修改同一行记录,数据库写线程互相阻塞,业务线程拿不到连接,耗时自然飙升。此时可以利用information_schema中的事务和锁表信息,但注意生产库查询要使用只读账号,避免影响线上业务。

6.2 线程阻塞在 HTTP 客户端调用

如果线程栈停在socketReadhttpclient.execute,说明业务线程在等待下游 HTTP 接口响应。需要去检查下游服务的 RT 曲线、错误码、GC 情况和连接数。

常见的情况是:下游服务本身 RT 正常低于 100ms,但某一天突然出现大量请求需要 2~3 秒才返回。原因可能是下游服务线程池被打满、数据库慢查询、或者下游服务在做 Full GC。

如果下游接口 RT 没有明显异常,还需要怀疑网络重传和 TCP 连接异常。可以用sar -n EDEVnetstat -s查看重传率。重传率高,意味着网络链路上出现了丢包,请求迟迟收不到响应。

6.3 线程大量 BLOCKED

如果 jstack 中有大量java.lang.Thread.State: BLOCKED,说明锁竞争已经非常严重。这时需要进一步分析锁对象,比如是不是某个静态 map、某个单例对象、某个序列化工具类,甚至是一个分布式锁。

定位这类问题,最理想的办法是抓两份 jstack,间隔 3~5 秒,对比哪些线程两次都阻塞在同一个锁对象上。如果锁的持有者始终不变,就很可能是死锁或长时间持锁。如果持有者一直在换,说明是高并发下的锁争抢。

定位到根因之后,下一个问题才是怎么改。

7. 修复与验证:改哪里,改完如何确认

修复方向取决于根因,不能一刀切。以下列表是常见场景的修复思路:

根因修复方向
数据库慢 SQL加索引、改写 SQL、分库分表、读写分离
数据库连接池耗尽定位慢 SQL 和长事务,必要时调整连接池上限
下游服务慢设置超时、熔断降级、异步化、结果缓存
公共锁竞争缩小锁粒度、读写分离锁、无锁化、分布式锁改分级锁
CPU steal / 资源争抢调整部署密度、更换宿主机、增加资源规格
GC 停顿调整堆参数、排查大对象、优化代码

这里要特别提醒:线程池扩容不是万能的。很多人在看到线程池打满后,第一反应是把线程数从 10 改成 100。如果瓶颈在下游服务或数据库,线程数扩大只会让下游压力更大,甚至引发雪崩。正确的做法是先解决慢调用,让线程能够快速释放,然后才考虑是否需要调整线程池参数。

下面是一个典型的线程池参数调整示例。需要注意,参数必须结合自己服务的 QPS、RT、硬件配置来确定,不能盲目照搬:

import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; // 示例:核心线程 20,最大线程 40,队列长度 200 // 拒绝策略:当队列和最大线程都满时,在调用者线程中执行 ThreadPoolExecutor orderExecutor = new ThreadPoolExecutor( 20, 40, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), new ThreadPoolExecutor.CallerRunsPolicy() );

CallerRunsPolicy适合业务方希望优先保证任务不丢失、同时愿意接受调用线程背压的场景。高并发下会阻塞 Web 容器线程,但比直接丢弃订单要好。实际使用时要权衡任务的重要性和可见性,配套做好监控和告警。

验证时不要只看平均延迟,要看 P99 和 P999。平均延迟非常容易把长尾问题掩盖掉。建议根据下面维度逐项确认:

# 压测或线上观察期间,每 5 秒抓一次线程状态分布 ./thread_stat.sh <pid> # 观察线程池指标:活跃线程数、队列长度、拒绝任务数 # 如果有 Micrometer / Spring Boot Actuator,可以直接暴露为监控指标

修复完成后,先在一台机器或一个分组灰度验证,观察一段时间后确认 P99 降到预期范围、线程池活跃线程数回到合理水位,再逐步扩大发布范围。如果出现异常,直接回滚配置或版本。

8. 线上排障的监控与预防:别只盯 CPU

“CPU 不高但接口延迟高”这类问题之所以难排查,很大程度上是因为日常监控只覆盖了系统资源指标,缺少对请求排队和线程等待的可观测性。

建议在服务端建立一套组合监控,至少包含:

  • 线程池指标:活跃线程数、队列大小、排队任务数、拒绝任务数。
  • Web 容器线程:如 Tomcat/Undertow 的当前线程数、忙线程数。
  • 数据库连接池指标:活跃连接数、获取连接等待时间。
  • JVM 指标:GC 次数、GC 耗时、堆内和堆外内存。
  • 下游依赖指标:每个下游接口的 RT 分位数、错误率、超时次数。
  • 系统层补充指标:CPU steal、上下文切换、磁盘 IO、网络重传率。

告警阈值不能只设 CPU。更合理的做法是设置“延迟升高 + 线程池排队增加”的组合告警。比如当接口 P99 超过基线 1.5 倍,且线程池活跃线程数超过最大线程数的 70% 时,触发 P2 告警。

日常团队还可以做一次故障演练:人为在下游服务中加一个 2 秒延迟,看当前监控是否能快速定位到问题。这样操作的成本很低,却能有效检验团队排障能力和监控完善度。

9. 常见问题与排查思路

问题现象可能原因排查方式解决方案
CPU 15%,但接口整体 RT 飙升线程池耗尽,请求排队看线程池活跃线程数、队列长度定位慢调用,加超时、降级、异步化
大量线程 Waiting for connection数据库连接池耗尽SHOW FULL PROCESSLIST,慢查询日志优化慢 SQL,排查长事务,谨慎扩容连接池
jstack 中大量 BLOCKED公共锁竞争两次 jstack 对比锁持有者缩小锁粒度、无锁化、避免持锁调用外部服务
接口每隔一段时间卡顿Full GC 或长时间 Young GC查看 GC 日志调整堆参数、排查大对象和内存泄漏
容器 CPU 不高,但 st 值很高宿主机 CPU 争抢sar -P ALL 看 steal调整部署密度,更换宿主机或资源规格
接口偶发超时,重试后成功TCP 重传或下游抖动sar -n EDEV,netstat -s 看重传率与网络团队协同排查,客户端增加超时重试策略
线程都是 RUNNABLE 但接口慢锁竞争或自旋结合栈内代码分析看是否 CPU 密集操作,定位热点方法

每次排障结束后,建议把根因、排查过程、最终结论整理成一篇内部故障报告。这类文档的价值不亚于代码,因为它是团队共同经验的沉淀。

10. 后续可以继续深入的方向

“CPU 不高但接口延迟高”本质上是一个分布式系统中的可观测性问题。它考验的不是你会不会写某个 API,而是你能不能通过系统指标、线程快照、链路追踪、日志等多维信息还原一次请求的完整旅程。

后续如果想继续深入,可以从几个方向展开:

  • 线程池资源隔离:核心业务和边缘业务使用独立线程池,避免互相影响。
  • 异步化改造:对于非关键路径的调用,使用 MQ 或响应式编程削峰填谷。
  • 全链路压测:在压力下提前发现线程池耗尽、连接池瓶颈和慢 SQL。
  • 更细粒度的监控:结合业务维度拆解接口耗时,建立每个下游调用的 RT 基线。

线上排障最重要的一点,是不要被单一指标困住。CPU 不高、接口延迟高、内存充足、负载正常,这些指标组合在一起,往往意味着请求正在某个“看不见”的地方排队等待。先抓线程栈,再往下游查,最后修复验证,才是成熟的排障思路。

建议把本文中的thread_stat.sh脚本和 jstack 抓取命令保存下来,遇到类似问题时能少走很多弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 16:58:31

基于伴随灵敏度分析的肿瘤放疗时空优化:Matlab实现与实战

最近在整理一个和肿瘤生长模型相关的 Matlab 项目时&#xff0c;我把伴随灵敏度分析&#xff08;Adjoint Sensitivity Analysis&#xff09;完整跑通了一遍。整个过程最大的感受是&#xff1a;这个技术在国内的医学物理和计算生物领域讨论得不算多&#xff0c;但它在时空放射治…

作者头像 李华
网站建设 2026/9/9 16:58:28

深入剖析ConcurrentHashMap:从JDK7到JDK8的实现与实战避坑指南

从一次线上事故说起&#xff1a;为什么并发场景必须拥抱ConcurrentHashMap大概两年前&#xff0c;我负责的一个订单服务在大促期间突然出现CPU飙升&#xff0c;紧接着一批请求超时。刚开始大家都以为又是数据库连接池被打满了&#xff0c;结果一查线程栈&#xff0c;发现大量线…

作者头像 李华
网站建设 2026/9/9 16:55:32

AD7888BRZ,8通道12位125kSPS低功耗SAR模数转换器

AD7888BRZ是ADI推出的微功耗多通道SAR型ADC&#xff0c;专为工业多通道传感巡检、便携式采集设备、电池供电测控系统、低速精密数据采集场景设计。芯片集成8路单端模拟输入、片内2.5V基准源、采样保持电路&#xff0c;支持12位精准采样、125kSPS高速吞吐&#xff0c;搭配宽电压…

作者头像 李华
网站建设 2026/9/9 16:55:04

彻底解决Cursor试用限制:1条命令重置机器码,免费额度立刻回来

彻底解决Cursor试用限制&#xff1a;1条命令重置机器码&#xff0c;免费额度立刻回来 【免费下载链接】go-cursor-help 解决Cursor在免费订阅期间出现以下提示的问题: Your request has been blocked as our system has detected suspicious activity / Youve reached your tri…

作者头像 李华
网站建设 2026/9/9 16:50:05

全体目光向我看起,2026到底线上教务系统哪家好呢!

全体目光向我看起&#xff0c;2026到底线上教务系统哪家好呢&#xff01; 网经社《2026教培SaaS与小程序化运营洞察》里有个挺直的白话&#xff1a;中小教培机构里&#xff0c;已用系统管排课的大概七成&#xff0c;但能跑通“排课—授课—作业—测评—续费”全链路的&#xff…

作者头像 李华