半夜两点,监控告警突然响起:订单接口的 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 -Hp或sar -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 | 上下文切换次数 | 每秒几十万次时,要警惕锁竞争或线程频繁切换 |
| wa | IO 等待占比 | 高说明磁盘或存储成为瓶颈 |
| si / so | swap 换入换出 | 不为 0 说明内存压力已经很大 |
CPU 使用率本身也要拆开看。top默认显示的是一堆数字,但判断问题必须分清楚:
- user:用户态程序占用。高说明业务代码在计算或自旋。
- sys:内核态占用。高可能与系统调用、网络协议栈、锁有关。
- wa:等待 IO。高说明卡在磁盘、网络文件系统等外部设备。
- st:steal 时间。虚拟化环境下被宿主机抢走 CPU 的比例,这个值高了会导致服务变慢但“看起来 CPU 占用很低”。
- id:空闲占比。
如果看到wa或st明显偏高,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 线程阻塞在数据库连接获取
如果线程栈停在类似getConnection、waiting for connection、ConnectionHolder的位置,说明数据库连接池已经耗尽,线程都在排队等待连接。此时需要去数据库侧确认发生了什么:
-- 查看当前正在执行的 SQL SHOW FULL PROCESSLIST; -- 查看慢查询是否开启 SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time';如果慢查询日志显示有大量慢 SQL,或者SHOW FULL PROCESSLIST中看到某个SELECT或UPDATE长时间处于Sending data、Waiting for table metadata lock等状态,那基本就是数据库拖住了整个业务线程池。
此外,还要检查是否有行锁等待。如果大量事务修改同一行记录,数据库写线程互相阻塞,业务线程拿不到连接,耗时自然飙升。此时可以利用information_schema中的事务和锁表信息,但注意生产库查询要使用只读账号,避免影响线上业务。
6.2 线程阻塞在 HTTP 客户端调用
如果线程栈停在socketRead或httpclient.execute,说明业务线程在等待下游 HTTP 接口响应。需要去检查下游服务的 RT 曲线、错误码、GC 情况和连接数。
常见的情况是:下游服务本身 RT 正常低于 100ms,但某一天突然出现大量请求需要 2~3 秒才返回。原因可能是下游服务线程池被打满、数据库慢查询、或者下游服务在做 Full GC。
如果下游接口 RT 没有明显异常,还需要怀疑网络重传和 TCP 连接异常。可以用sar -n EDEV或netstat -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 抓取命令保存下来,遇到类似问题时能少走很多弯路。