CPU 只有 20%,任务为什么还是慢?先查这 5 件事|大数据智能运维实战(五)

发布时间:2026/10/28 1:50:33
CPU 只有 20%,任务为什么还是慢?先查这 5 件事|大数据智能运维实战(五)

1. 任务还没结束,CPU 却只有 20%

小周盯着迟迟没出结果的门店经营日报,又看了一眼监控:CPU 只有20%。

“机器看着挺闲,任务到底慢在哪里?”

小林指了指卡片下面的时间:“先别急着换机器。这 20%,描述的是哪次采样?”

把窗口拉回前半小时,两人看到:最后一个点确实是 20%,中间却采到过90% 和 95%。同一组样本的均值,约为46.67%。

最后一个低值,不能代表整个任务期间都很空闲。反过来,采到一个高值,也不能直接认定 CPU 就是慢任务的根因。要先对齐同一次运行、实际执行节点和时间,再检查任务当时是在计算,还是在等待。

这篇先给出一份可以照着走的检查清单,再用一个独立的虚构场景,把 CPU 趋势做成 MCP 工具。没有读过前文,也可以先用清单检查自己的监控结论。

案例中的平台、节点、任务和指标均为虚构教学设定。本文只实现 CPU 趋势查询;运行阶段、执行节点关联、队列与日志证据,需要从相应系统另行取得。

先查这 5 件事

检查项具体查什么查完如何继续
① 对象有没有对上记录运行实例、执行尝试、当次执行节点,以及任务所在阶段节点或尝试对不上,先修正对象;不能拿整集群平均值代替目标节点
② 时间有没有对上记录变慢窗口、时区、CPU 样本时间;同时看开始、结束是否包含边界最新点早于故障窗口,或来自另一段历史,就重查对应窗口
③ 看的是什么 CPU确认整机、进程还是容器口径;一起看最新点、样本均值、已观测峰值和原始点只看到一个 20%,还不足以描述趋势;不同分母的百分比不能直接比较
④ 样本有没有缺记录采样间隔、实际点数、预期点数与缺失位置;核对重复点如何处理有缺口就注明未知;不要把缺失补成 0,也不要用点位齐全宣称每秒都已观测
⑤ 下一条证据是什么对齐任务阶段,查队列等待、线程等待或 I/O 等相关记录尚未开始计算,先追等待原因;已在计算,再结合相关指标与日志验证资源瓶颈

拿这份清单排查时,前四项是在确认“监控数字能说明什么”,第五项才开始追“为什么慢”。

可以直接复制的排查记录

运行实例 / 执行尝试: 当次执行节点 / 任务阶段: 变慢窗口 / 时区 / 起止边界: CPU 指标口径:整机、进程或容器;百分比的分母是什么 窗口内最新点:时间、数值 样本均值 / 已观测峰值: 采样间隔 / 实际点数 / 预期点数: 缺失、重复或异常点: 已经确认的事实: 仍待验证的原因: 下一项检查及需要的证据:

没有拿到的字段就写“未取得”。这份记录是人工排查模板,不能把空白补成工具已经返回的事实。

动手部分使用lab-03-06教学工程,技术基线为 Java 17、Spring Boot 4.1.0、Spring AI 2.0.1。

2. 把半小时摊开:最后是 20%,中间却到过 95%

第二篇的pressure场景只提供过 B 节点 09:00 的 86%、09:09 的 88%,不足以展示一个完整趋势。

本篇新增spike,专门用于练习趋势查询。它和pressure是两组独立教学样本,不是我们后来从同一次运行中查到了更多记录。

仍然把观察时刻固定在2026-09-16 09:10 +08:00。这次查看demo-worker-b,查询窗口是:

[08:40, 09:10)

左边的方括号表示包含 08:40,右边的小括号表示不包含 09:10。

我们约定每 5 分钟一个采样点,所以这个窗口应有 6 个点:

采样时间CPU
08:4020%
08:4525%
08:5090%
08:5595%
09:0030%
09:0520%

图 1:画出实际采到的点,便于区分观测与未观测的时间。图中没有把点之间的状态补出来。

小周看完说:“原来中间上去过。”

这句话有依据。但“整个半小时一直很高”“高了整整十分钟”,现有样本都不能严格证明。

3. 三个 CPU 数字,分别能说明什么

先说最容易混淆的“当前值”。

本次结果中,最后一个点在09:05,数值是20%。查询时刻则是 09:10。

因此我们把字段命名为latestInWindow,也就是“窗口内最新样本”,没有直接叫currentCpu。

如果把窗口改成早上八点,窗口内最新值仍然属于那段历史,不能因为它排在返回列表最后,就把它当成当前值。

再看平均值:

(20 + 25 + 90 + 95 + 30 + 20) / 6 = 46.6666... ≈ 46.67%

这里求的是6 个已采样数值的算术平均,所以字段叫sampleMean。

它是方便概括这组样本的数字,不等于已经知道每一秒的 CPU,也不代表把整段时间的真实利用率做了精确积分。

最后看峰值:

max(20, 25, 90, 95, 30, 20) = 95%

我们称它为observedPeak,即“已观测峰值”。它证明采到过 95%,但不能排除采样间隔内出现过更高的瞬时值。

数字本篇结果能说明什么
窗口内最新值09:05 的 20%最后一次采样的观测
样本均值46.67%已采到的 6 个数值的平均
已观测峰值95%已采到的最大数值
采样间隔300 秒计划多久采一个点
点位覆盖率6/6,100%这个固定网格上的预期点是否齐全

小林把最后一行圈了出来:“点位覆盖率 100%,也不意味着每一秒都被观测过。”

4. 两个高点没采到,平均值会立刻变得好看

接下来,小林切换到gaps样本。

这组样本保留同一个窗口和采样计划,但故意移除 08:50、08:55 的两个点。

现在剩下:

20、25、30、20

工具算出的结果变成:

样本均值 = 95 / 4 = 23.75% 已观测峰值 = 30% 点位覆盖率 = 4 / 6 ≈ 66.67%

图 2:在这个专门构造的案例里,恰好缺掉了两个高值。真实缺失时段的数值通常未知,不能从已有点反推出去。

小周说:“如果只返回均值和峰值,助手会觉得这段时间很平稳。”

正是如此。

所以,统计值应该和窗口、实际点数、预期点数一起返回。

没有采到的两个位置也不能补成 0。补零以后,均值会变成95/6≈15.83%,它只是在计算中凭空加入了两次“CPU 为零”的观测。

有些数据源会使用插值或保持前值,但那也必须是明确的统计规则,并标识哪些值经过填补。本篇保持最简单的约定:只统计实际样本,缺失就是缺失。

如果整个窗口都没有样本,就返回sampleMean=null、observedPeak=null、latestInWindow=null,而不是三个零。

5. 给趋势查询加上边界,避免一句话拉出无限数据

小林给新工具起名ops_cpu_trend。参数包括节点、场景、开始时间和结束时间。

参数示例或限制
nodeId本节只准备了demo-worker-b的趋势
scenespike、gaps、empty
start带时区偏移的 ISO-8601 时间,包含该时刻
end带时区偏移的 ISO-8601 时间,不包含该时刻
窗口长度大于 0,不超过 60 分钟
时间对齐起止都按 5 分钟网格对齐
观察边界结束时间不能晚于固定的 09:10

为什么要限制时间对齐?

因为本篇要用“窗口时长 / 采样间隔”计算期望点数。这个算法依赖固定网格,不是任意监控数据都能照抄的公式。

如果允许 08:42 到 09:07,是否应有 08:45、08:50 等点,就必须另外说明采样网格从哪里开始。这里先把输入限制在已经定义清楚的情况。

真实查询还要明确指标口径。例如整机 CPU 利用率、某个进程 CPU 百分比和某个容器的配额使用率,分母可能不同,不能只看它们都叫 CPU 就混在一起。

本篇的cpuPercent是约定好的节点 CPU 百分比教学值,88表示 88%,没有从原始计数器推导利用率的过程。

6. 让服务计算数字,让助手解释数字

趋势服务做四件事:检查输入、取窗口样本、按时间排序、计算统计量。

完整实现如下:

packageexample.qinghe;importio.swagger.v3.oas.annotations.media.Schema;importjava.time.Duration;importjava.time.OffsetDateTime;importjava.time.format.DateTimeParseException;importjava.util.Comparator;importjava.util.List;importjava.util.Set;importjava.util.stream.IntStream;importorg.springframework.stereotype.Service;/** 固定 5 分钟采样网格的 CPU 教学统计,不插值、不补零。 */@ServicepublicclassTrendService{publicrecordPoint(OffsetDateTimesampledAt,doublecpuPercent){}publicrecordTrendResult(StringnodeId,Stringscene,Stringunit,OffsetDateTimeobservedAt,OffsetDateTimestart,OffsetDateTimeend,Stringinterval,intsampleIntervalSeconds,intexpectedPoints,intactualPoints,doublecoveragePercent,@Schema(nullable=true)DoublesampleMean,@Schema(nullable=true)DoubleobservedPeak,@Schema(nullable=true)PointlatestInWindow,StringlatestFreshness,List<Point>points){}publicTrendResultquery(StringnodeId,Stringscene,Stringstart,Stringend){if(!"demo-worker-b".equals(nodeId)){thrownewIllegalArgumentException("本节趋势样本仅提供 demo-worker-b");}if(scene==null||!Set.of("spike","gaps","empty").contains(scene)){thrownewIllegalArgumentException("scene 仅支持 spike/gaps/empty");}OffsetDateTimefrom=parse(start),to=parse(end);longseconds=Duration.between(from,to).getSeconds();if(seconds<=0||seconds>3600||to.isAfter(DemoData.NOW)||!aligned(from)||!aligned(to)){thrownewIllegalArgumentException("窗口须为 5 分钟对齐的 (0,60] 分钟,end 不晚于实验观察时刻");}List<Point>points=fixture(scene).stream().filter(p->!p.sampledAt().isBefore(from)&&p.sampledAt().isBefore(to)).sorted(Comparator.comparing(Point::sampledAt)).toList();intexpected=(int)seconds/300;Doublemean=points.isEmpty()?null:round(points.stream().mapToDouble(Point::cpuPercent).average().orElseThrow());Doublepeak=points.isEmpty()?null:points.stream().mapToDouble(Point::cpuPercent).max().orElseThrow();Pointlatest=points.isEmpty()?null:points.get(points.size()-1);returnnewTrendResult(nodeId,scene,"PERCENT",DemoData.NOW,from,to,"[start,end)",300,expected,points.size(),round(100.0*points.size()/expected),mean,peak,latest,DemoData.freshness(latest==null?null:latest.sampledAt()),points);}staticList<Point>fixture(Stringscene){if(scene.equals("empty"))returnList.of();double[]values={20,25,90,95,30,20};returnIntStream.range(0,values.length).filter(i->!scene.equals("gaps")||(i!=2&&i!=3)).mapToObj(i->newPoint(DemoData.at("08:40").plusMinutes(5L*i),values[i])).toList();}privatestaticOffsetDateTimeparse(Stringinput){if(input==null||input.length()>40){thrownewIllegalArgumentException("时间必须为带偏移的 ISO-8601,例如 2026-09-16T08:40:00+08:00");}try{returnOffsetDateTime.parse(input);}catch(DateTimeParseExceptionex){thrownewIllegalArgumentException("时间必须为带偏移的 ISO-8601",ex);}}privatestaticbooleanaligned(OffsetDateTimetime){returntime.getNano()==0&&Math.floorMod(time.toEpochSecond(),300)==0;}privatestaticdoubleround(doublevalue){returnMath.round(value*100)/100.0;}}

这里的两个条件:

!p.sampledAt().isBefore(from)&&p.sampledAt().isBefore(to)

对应的就是[start,end)。这样查询相邻窗口时,边界上的一个采样点不会同时落入前后两个窗口。

样本均值和峰值在 Java 中计算,助手直接使用计算结果。这样可以测试和复核,不需要每次让模型重新心算。

当前样本是内存中构造的有限列表,时间戳唯一、CPU 在 0~100 之间。接入真实数据源时,应把过滤与聚合尽量下推,并先约定重复时间戳、异常数值和采样中断的处理方式。本文代码没有实现这些外部数据清洗流程。

工具入口如下:

packageexample.qinghe;importorg.springframework.ai.mcp.annotation.McpTool;importorg.springframework.ai.mcp.annotation.McpToolParam;importorg.springframework.context.annotation.Profile;importorg.springframework.stereotype.Component;/** 第 05 篇工具入口;业务校验与计算由服务完成。 */@Component@Profile({"ch05","ch06"})publicclassTrendTools{privatefinalTrendServiceservice;publicTrendTools(TrendServiceservice){this.service=service;}@McpTool(name="ops_cpu_trend",description="查询固定教学时钟下的 CPU 样本;返回窗口、间隔、样本均值、已观测峰值和缺失覆盖率;不推断慢任务根因。",generateOutputSchema=true,annotations=@McpTool.McpAnnotations(readOnlyHint=true,destructiveHint=false))publicTrendService.TrendResultquery(@McpToolParam(description="本节只提供 demo-worker-b 的趋势样本",required=true)StringnodeId,@McpToolParam(description="spike/gaps/empty,与前文 pressure 是独立样本",required=true)Stringscene,@McpToolParam(description="左闭起点,带偏移 ISO-8601;5 分钟对齐",required=true)Stringstart,@McpToolParam(description="右开终点,带偏移 ISO-8601;5 分钟对齐;窗口最长 60 分钟且不晚于 2026-09-16T09:10:00+08:00",required=true)Stringend){returnservice.query(nodeId,scene,start,end);}}

小周注意到描述里写了“不推断慢任务根因”。

小林解释:“工具提供的是一段 CPU 观测。就算我们采到了 95%,也要先确认这是任务相关节点、相关时间,还要看任务有没有真正进入计算。”

7. 跑一下完整、缺失和空窗口

在lab-03-06目录执行,切换前先结束自己启动的旧章节服务:

mvntestpackagejava-jartarget/qinghe-ops-lab-1.0.0.jar--spring.profiles.active=ch05

新开终端查询:

python3 mcp_call.py ops_cpu_trend\'{"nodeId":"demo-worker-b","scene":"spike","start":"2026-09-16T08:40:00+08:00","end":"2026-09-16T09:10:00+08:00"}'

实际structuredContent的统计字段如下。为便于阅读,这里省略points数组,六个原始点就是第二节的表格:

{"nodeId":"demo-worker-b","scene":"spike","unit":"PERCENT","observedAt":"2026-09-16T09:10:00+08:00","start":"2026-09-16T08:40:00+08:00","end":"2026-09-16T09:10:00+08:00","interval":"[start,end)","sampleIntervalSeconds":300,"expectedPoints":6,"actualPoints":6,"coveragePercent":100.0,"sampleMean":46.67,"observedPeak":95.0,"latestInWindow":{"sampledAt":"2026-09-16T09:05:00+08:00","cpuPercent":20.0},"latestFreshness":"FRESH"}

再分别查询gaps和empty:

场景实际/预期点数样本均值已观测峰值窗口内最新值
spike6/646.67%95%09:05 的 20%
gaps4/623.75%30%09:05 的 20%
empty0/6nullnullnull

最后把结束时间改为08:50。在[08:40,08:50)中,只应返回 08:40、08:45 两个点,峰值是 25%,08:50 的 90% 不属于这个窗口。

该窗口内最新点已经距离观察时刻很久,因此latestFreshness=STALE。这不妨碍它描述历史窗口;它只是提醒我们不要拿历史值回答“现在怎么样”。

配套测试覆盖了这些统计结果,以及倒置窗口、零长度窗口、超过一小时、不带时区、未来终点和网格未对齐等输入。

8. 采样间隔不规则时,不要悄悄把均值换个含义

小周又想到了另一种情况:

“如果有的点隔一分钟,有的点隔二十分钟,直接平均还成立吗?”

算术平均当然可以算,但它依旧只表示“这些样本的平均”,每个点的权重相同。采样密集的时段会贡献更多点。

如果想回答“整段时间平均占用了多少”,就要先明确每个值代表瞬时观测,还是前一个采样周期的平均;再决定能否按持续时长加权、缺失区间如何处理。

没有这些前提,直接换成加权公式也不一定更正确。

所以本篇刻意把字段叫作sampleMean,把采样间隔和缺失比例放在旁边。先把正在计算的东西说准确,比给一个看起来专业的数字更有用。

9. 回到最初的问题,CPU 低为什么仍可能慢

现在可以给小周一段更可靠的回答。

下面是依据spike教学数据编写的预期回答,不是真实模型实测:

在 08:40 至 09:10 的左闭右开窗口中,节点 B 有 6 个 CPU 样本,按本例 5 分钟网格,点位覆盖率为 100%。

窗口内最新点是 09:05 的 20%,样本均值约为 46.67%,已观测峰值为 95%。08:50 和 08:55 两次采样分别为 90% 和 95%,所以不能仅凭最后一个低值,认为整个窗口都很空闲。

这些点不足以证明高 CPU 持续了多久,也不能单独确定它造成任务变慢。下一步应确认任务在该窗口所处的阶段、实际关联节点,以及调度或计算引擎的等待状态。

如果返回的是gaps,回答还必须增加一句:缺失两次预期采样,均值和峰值仅来自已有四个点,缺失时段未知。

CPU 低的时候,任务可能在等待调度、等待 I/O,也可能还没获得计算资源。这些只是接下来要验证的方向,当前这组 CPU 样本没有替我们证明其中任何一个。

小周想了想:“那我得先知道它到底在等什么。”

下一篇,我们就沿着同一次运行,区分调度等待、合适的执行资源不足,以及计算队列等待。