CPU只有15%接口延迟却飙升。这个组合在线上排障里很经典也很容易把人带偏。很多人第一反应是“CPU不够加配置”但实际加完机器还是慢的情况并不少见。先说我的判断CPU低不代表系统有大量空闲资源。它更像一辆车停在收费口发动机没在运转但前面的栏杆一直不放行。车没动不代表路上顺畅而是它在等某样东西释放。这篇文章要解决的就是当CPU指标看起来“不忙”时线上接口延迟高到底该往哪个方向查。适合正在做服务端开发、运维、SRE或者第一次接触线上性能问题的同学。我会按我自己排障时执行的顺序来写先校准问题再看链路数据然后逐层收紧到应用内部最后补上容器配额、GC、数据库锁等待这类容易漏掉的角落。1. 把问题定性CPU 15%时系统到底在忙什么1.1 低 CPU 高延迟本质是线程在等待CPU利用率低说明计算任务少。接口延迟高说明请求没能按时返回。这两个现象拼在一起最常见的解释就是大量请求线程没有在“计算”而是阻塞在某个地方排队。阻塞点通常只有几类数据库连接池满、线程池满、Redis 或外部 RPC 慢、锁竞争、GC 暂停、I/O 等待、容器 CPU 配额被打满。这些场景下CPU 使用率可能都不高但每个请求都卡在中间环节延迟自然就飙升了。所以排障的第一步不要着急下“扩容”结论而是去找“请求到底在等哪个资源”。一旦找到了等待点问题基本就定位了。1.2 先用一张分类表降低炸错方向我在实际排障时开头不会直接抓日志而是先问五个问题第一受影响的是单个接口还是所有接口。单个接口慢大概率问题在下游依赖或者这个接口自身逻辑所有接口都慢更多像线程池、连接池、GC 或者数据库层的全局问题。第二是平均延迟上升还是 P99、P999 上升。平均值容易被少部分极慢请求拖高P99 上升往往意味着部分请求遇到了排队或重试。第三是持续上涨还是周期波动。周期波动经常和定时任务、缓存过期、日志刷盘、GC 周期绑定在一起。第四延迟升高有没有伴随错误率同步变化。如果错误率也涨说明某个环节已经不可用客户端在反复重试。第五这个时间点之前有没有发布、配置变更、数据库迁移、缓存 Key 过期策略调整。线上好多“诡异问题”最后都能在变更记录里找到答案。我把这些问题当成一张快速分类表。答案不同后面要走进的方向完全不一样。观察项可能结论单接口延迟高所有接口正常下游 RPC、SQL、该接口逻辑所有接口延迟都高线程池、连接池、GC、全局锁P999 高平均 RT 一般少数请求排队、超时重试错误率同步上升依赖故障触发熔断或重试周期性抖动定时任务、缓存过期、GC 频率发布后开始出现代码变更、配置变更、依赖版本变化2. 排障前先把监控指标和依赖链路校准清楚2.1 先排除监控口径和采样周期造成的误判看到 CPU 15% 这个数字不要马上信。先确认监控平台采集的是什么口径。如果是 1 分钟采一次样并且用平均值展示那么一个持续 2 秒的 CPU 飙高在分钟级监控里可能只显示为 20%。如果只按整机 CPU 看又没拆分进程级、容器级监控那也可能错过某个进程把单核打满的情况。我一般会做三步校准第一步把时间范围缩短看细粒度数据。监控平台有 10 秒或 1 秒粒度就先切到最小粒度观察延迟最高的时间窗口里 CPU 的真实走势。第二步区分整机 CPU、容器 CPU、进程 CPU。三者数值可能差异很大。尤其有多个应用实例混部在同一台机器时整机 CPU 很低某个进程的 CPU 已经打满也是常见的。第三步确认 CPU 指标统计的是用户态还是含内核态。部分低端监控只统计用户态统计中断和内核开销时会漏掉一部分。遇到网络软中断密集的场景这个误差会导致你误以为系统很闲。2.2 从客户端到数据库把依赖耗时串起来监控没问题之后下一步是把一条完整链路的时间拆开。不要只看服务端平均耗时要把入口网关、服务A、服务B、数据库、Redis 每段的耗时单独拉出来看。如果没有链路追踪系统那就先用最朴素的方式在日志里给每个关键依赖打印耗时。比如调用数据库前记录时间执行完再记录一次差值就是数据库耗时。外部 RPC 同理。这一步不需要很复杂的工具但能快速把“接口总耗时”拆成几段。如果链路追踪是现成的直接看哪个 span 耗时明显高于历史基线。找到耗时最大的那一段再进入下一轮排查。这里有一个容易踩的坑只看服务自身监控不看下游。很多接口慢其实慢在下游。上游应用 CPU 低很正常线程都在等下游返回。你在上游反复看线程、看代码实际上是白费力气。3. 第一轮定位网关、网络入口和重试放大3.1 从入口侧看延迟到底消耗在哪一段到这一步我先从入口侧做一次黑盒验证。拿一个出问题的接口在客户端发起请求把耗时拆开看。可以用 curl 的-w参数来输出连接耗时、TLS 握手耗时、首字节耗时和总耗时。也可以直接抓包看 TCP 握手、传输、重传情况。目的是回答两个问题慢发生在建连阶段还是服务端处理阶段还是响应回传阶段。如果发现 TCP 重传多网络链路有丢包。如果连接建立很快但服务端迟迟不给首字节问题基本在服务端内部处理。如果前几个阶段都快但整体总耗时高可能是响应体传输慢也可能是客户端和服务端之间的链路带宽受限。网络层排查有个明显特点只要 CPU、内存、磁盘都正常错误率又不高延迟却很高那优先怀疑网络链路质量而不是服务代码。3.2 网关和代理层的连接排队服务入口前面通常有 Nginx、网关或者负载均衡器。这些组件的连接排队也会让接口延迟上升而业务服务本身的 CPU 不一定高。请求到达网关后如果后端实例的健康检查没有及时剔除异常节点网关就会继续把请求转发给一个已经“僵住”的实例。这个实例可能 TCP 能连通但业务线程已经全都阻塞了。新请求只能不断排队直到超时。判断办法很简单看网关日志里 upstream 响应时间再看每个后端实例的连接数。正常情况下连接数应该比较均匀如果某一台实例的连接数明显很多并且响应时间明显高于其他实例那说明负载均衡没把它摘掉。这种情况下业务服务单台 CPU 只有 15%完全合理因为大部分线程不是忙而是排队等前面的请求放行。3.3 重试超时放大了接口尾延迟还有一个常见放大器客户端重试。客户端设置超时时间过短服务端处理超过这个阈值后客户端立刻发起第二次请求甚至短时间内连续重试多次。结果服务端原来只需要处理 1 个请求现在要处理 3 到 5 个线程池被快速占满。表现上服务端 CPU 也不算高因为每个请求可能都在等数据库锁或者外部 RPC但接口延迟持续上涨客户端错误率也会跟着涨。这个场景有个细节值得注意如果看 QPS可能会发现某个接口 QPS 不符合业务规律地翻倍。这就是重试风暴的典型信号。我建议排查时把服务端 QPS 和客户端发起量放在一起对比差异一大基本就是重试或者秒杀类流量误判的问题。4. 深入应用线程池、连接池、锁和 GC4.1 用线程转储找“等待点”入口侧排查过之后如果确认问题出在应用自身下一步我会抓线程转储。这是定位“线程到底在等什么”最直接的方式。Java 应用一般用jstack连续抓两到三次间隔 5 秒左右或者使用支持线程分析的工具比如 arthas 的thread -n 3。抓完不要只看一次要多看几次否则容易把正常波动当成异常。线程转储出来之后看这些状态RUNNABLE 不等于在计算也可能是在执行网络读写时处于可运行状态。WAITING 或 TIMED_WAITING 的线程如果在同一个锁对象或同一个连接池上大量堆积说明资源等待已经形成。BLOCKED 状态大量出现要重点关注锁竞争。比如看到很多线程阻塞在java.sql.Connection获取上基本可以确定数据库连接池耗尽。看到大量阻塞在ReentrantLock上那要考虑热点代码和锁粒度的问题。线程转储的黄金用法是先建立“哪些线程是请求线程”的认知再观察它们停在哪一行。这个位置就是整条链路的真正瓶颈。4.2 连接池被打满是低 CPU 延迟高的高发原因我自己处理过的线上接口延迟问题里数据库连接池耗尽占的比例很高。现象非常典型CPU 波动不大接口延迟慢慢上涨中间伴随少量连接超时异常。过程是这样的某个慢 SQL 或者长时间事务占住了连接池里的连接新请求拿不到连接只能排队等。连接池排队的线程不消耗 CPU但请求的延迟全部被拉长。连接池一旦被耗光后续请求开始报错错误率同步上升。排查时我会分三步第一步看连接池监控。比如活动连接数、等待获取连接数、最大连接数。活动连接数长时间在最大值附近基本已经耗尽。第二步看是哪类 SQL 占住了连接。可以从慢查询日志、数据库连接统计、全链路追踪里找执行时间很长的语句。第三步看连接的获取时长。如果获取连接本身耗时几十毫秒甚至几百毫秒说明问题已经从“SQL 慢”变成了“连接资源枯竭”。这种情况直接加连接池大小不一定有用甚至可能把数据库压垮。真正要做的是先杀掉慢查询再把长时间事务拆短最后才是评估连接池参数。4.3 别漏掉 GC 暂停对延迟的影响CPU 只有 15%不代表 GC 没有问题。Full GC 的 Stop-The-World 阶段目标 CPU 使用率低但所有应用线程都会暂停接口延迟瞬间飙高。尤其频繁 Full GC 时整个服务处于“间歇性暂停”的状态平均 CPU 不高延迟却很难看。我建议遇到这类问题时不只看 CPU要同步看 GC 日志。重点关注 Young GC 频率、Full GC 次数、单次暂停时间以及 GC 后内存回收比例。如果 GC 频繁先看内存里是不是有大量对象无法回收比如用数组或 List 缓存了大量数据或者框架内部把请求上下文错误地放到了静态变量里。GC 线程不是业务线程所以它消耗的 CPU 在业务侧的进程 CPU 统计里容易显得不突出但还是能通过日志确认。如果想快速验证 GC 对延迟的影响可以把延迟最高的时间窗口和 GC 暂停时间窗口对齐。如果两个时间点高度重合方向基本确定。5. 下游依赖才是“看起来没事实际拖垮你”的地方5.1 数据库慢查询、锁等待和连接堆积数据库层是低 CPU 高延迟最常出现的下游环节。应用 CPU 低但所有请求线程都在等数据库这在单服务视角里几乎看不出异常。进入数据库排查时我会按这个顺序看。先看慢查询。找到执行时间超过阈值并且当时 QPS 突然下降的语句。慢查询日志能给出最直接的执行时间。再看锁等待。MySQL 里可以执行show processlist观察有没有大量连接处于Waiting for table metadata lock、Waiting for row lock、Waiting for next key lock。这些状态出现时后面再多的线程也只能等着。再看事务长度。一个事务长时间不提交持有锁不释放就会让后续对该表、该行的操作全部排队。这时候数据库 CPU 不一定高甚至很低但接口已经卡住。最后看连接数。比如数据库最大连接数是 500现在活跃连接已经 480而里面大部分连接都在执行同一条慢 SQL那就是典型的“慢 SQL 占据了所有连接资源”。5.2 缓存和外部 RPC 的超时与重试如果数据库没问题下一个要怀疑的是缓存和外部 RPC。Redis 虽然快但也会因为某些操作变慢。比如在线下执行KEYS *、超大HGETALL或者某个热点 Key 在热点时段被大量请求同时读取都会让 Redis 单实例延迟上升。Redis 变慢后应用线程同样会阻塞在等待响应上CPU 使用率不高接口延迟却明显上升。外部 RPC 比 Redis 更容易出问题。我见过很多场景第三方接口本身 P99 延迟已经很高但客户端没有做合理的超时控制导致上游服务线程大量阻塞在等待第三方响应上。排查 RPC 类问题时我会看这些指标外部服务调用耗时、超时时间配置、失败率、是否开启了重试、重试次数。尤其是重试一旦打开一个慢请求可能在内部多次调用进一步消耗连接资源和线程资源。5.3 单看服务 CPU 为什么会漏判现在可以回答标题里的核心困惑了为什么 CPU 才 15%接口延迟却飙升因为 CPU 只代表计算资源的使用情况。一个请求从进入服务到返回中间大量时间可能花在等待数据库返回、等待 Redis 返回、等待外部接口返回、等待锁释放、等待连接池分配连接。这些等待过程几乎不消耗 CPU但会占用线程会增加延迟。如果你只盯着 CPU 看很容易得出“资源充足”的错误结论。真正要盯的是两个点线程在哪里等待依赖消耗了多少时间。所以在排障时我建议把“CPU 低”当成一个现象而不是结论。它真正告诉你的是系统没有在密集计算可能卡在某个等待环节。6. 资源侧的特殊情况容器配额、磁盘 I/O 和发布变更6.1 容器 cgroup 配额导致 CPU 假空闲容器化部署之后CPU 指标多了一层“配额”问题。有时候进程 CPU 显示不高但它已经撞上了容器的 CPU 配额上限线程在等待 CPU 调度请求就是快不起来。这个现象的原理是容器通过 cgroup 限制 CPU 使用。如果监控平台按照宿主机逻辑核总数计算 CPU 百分比而容器的 CPU 配额可能只有 0.5 核或者只能使用部分时间片那显示值很容易偏低。但容器实际已经通过配额上限线程被反复暂停延迟自然上去。判断方法也比较明确。查看容器的 CPU 配额和 throttle 统计尤其看nr_throttled和throttled_time是否持续增长。如果增长明显说明有 CPU 调度等待应用在“排队等 CPU 配额”。这种情况加代码优化没用需要调整容器 CPU 配额或者减少单实例上的业务线程数、连接数让有限配额够用。6.2 磁盘 I/O、内存回收和文件句柄系统资源层还有一个容易被忽略的点磁盘 I/O。应用可能要写日志、写临时文件、读写本地缓存。如果磁盘 I/O 延迟高线程在写文件时会被卡住表现出来也是 CPU 低、延迟高。可以通过iostat看磁盘利用率、I/O 等待时间。如果磁盘利用率很高优先看是否有日志刷盘过于频繁、是否有大批量临时文件写入、是否有人在做全量导出。内存回收也需要关注。如果物理内存不足系统会频繁使用 swap内存页换出换入会让整个服务卡顿CPU 却不一定很高。观察 swap 使用率和内存回收相关指标能快速确认。文件句柄不足是另一个临界问题。文件描述符耗尽时服务无法接受新连接或者无法打开新文件。此时接口会持续等待或直接报错而 CPU 几乎没有任何明显波动。6.3 先问发布窗口和配置变更排障的最后一定要把视线拉回到变更上。这也是很多新人最容易漏的一步。延迟问题往往不是无缘无故出现的。它大概率对应某个变更代码发布、配置调整、数据库迁移、依赖版本升级、缓存 Key 逻辑调整、网关路由变更、第三方服务负责人调整。我在排障时有个习惯接到工单后先看最近 1 小时到 24 小时内有没有变更记录。有时候花几十秒就能确定方向比从监控里翻半天更高效。如果问题出现的关键时间点正好有发布那下一步就是把发布前后的监控数据对拍CPU、内存、线程数、GC、延迟、错误率都要对比。只要发布是主要原因对比图一般一眼就能看出来。7. 一套可直接落地的排查顺序和结论模板7.1 推荐排查顺序遇到“CPU 不高但延迟高”的线上问题我建议直接按这个顺序走能省掉很多弯路。第一确认监控口径看细粒度时序数据。同时确认整机、容器、进程三层的 CPU 使用率。第二找到受影响的接口、实例和时间窗口。对比错误率、QPS、RT 的关系。第三看全链路追踪里的最耗时节点。没有链路就靠日志分段计时。第四检查线程状态和线程转储。找大量线程停在哪个方法、哪个锁、哪个依赖上。第五检查数据库连接池、慢查询、锁等待和事务长度。这一步十次里面有五次能命中。第六检查 Redis、外部 RPC 耗时和客户端的超时重试策略。第七看容器配额、磁盘 I/O、内存回收、文件句柄。第八回到变更记录确认时间点是否和发布重合。这个顺序不是死的。如果链路追踪里已经明确是数据库慢那可以直接跳过前面的网络层排查节省时间。但如果还不知道问题在哪一层按顺序走最稳妥。7.2 排障结论要写清楚哪些证据排障最后一定要写结论而且结论不能是“系统负载高”这种口水话。我建议结论至少包含四个要素影响范围、根因、判断依据、验证结果。比如这样写服务 A 的接口 /order/list 从 14:30 开始 P99 延迟从 30ms 升到 300ms影响所有访问该接口的用户。根因是某条 SQL 未命中索引单次执行耗时 2 秒占满了数据库连接池。判断依据是数据库慢查询日志中该 SQL 出现 200 次/分钟连接池活跃连接数保持在 40 个上限附近。验证结果是优化索引后P99 延迟在 1 分钟内回落到 35ms连接池活跃数恢复正常。带着证据写结论不仅能让你自己在后续复盘时快速回溯也能让其他人少踩同一个坑。延迟问题最怕的不是现场复杂而是排查过程没有记录下次遇到完全类似的场景又要从头查一遍。我处理过很多次“看起来 CPU 很闲、实际接口很慢”的问题最后的根因千奇百怪有数据库连接池被打满的有容器 CPU 配额撞上限的有 GC 暂停导致的有外部 RPC 超时配置太长的有网络重传严重的。但它们的共同点是CPU 这个指标并不能回答“系统为什么不快”它只能告诉你“系统当前没有在密集计算”。真正有价值的是把延迟、错误率、线程状态、依赖耗时、资源配额串在一起看。只要把等待点找到问题其实已经解决了一大半。