系列:Spring AI + MCP------大数据智能运维实战
本篇目标:结合运行阶段、调度原因、执行节点槽位和计算队列记录,逐步缩小"任务一直在等"的范围。
阅读基础:沿用
lab-03-06工程;Java 17、Spring Boot 4.1.0、Spring AI 2.0.1。案例说明:平台、调度规则、队列、运行标识及数据均为虚构教学设定,不对应真实平台实现。
1. 小周说"还在等",小林先问"在哪一层等"
上一篇,小周已经知道,不能只看一个 CPU 当前值就判断任务为什么慢。
但新问题马上出现了。
"这个任务一直在等。是机器不够吗?"
小林打开调度记录:"先别急着加机器。这里的'等',可能是调度规则还没放行,也可能是没分到合适的执行节点,还可能已经提交给计算引擎,却在引擎里面排队。"
这几种情况都可能让用户觉得"日报迟迟不出结果",处理方向却不同。
本篇继续用青禾平台和"门店经营日报",但新增几次独立的教学运行。它们用来区分排查分支,不是第一篇那次任务后来查出的真实根因。
观察时刻仍固定在 2026-09-16 09:10 +08:00。
2. 先画清楚任务可能经过哪些环节
可以先把一次运行理解为:
text
进入调度等待
↓
调度条件允许,并获得合适的执行位置
↓
执行器开始处理任务
↓
必要时提交到计算引擎
↓
计算引擎准入、分配资源并执行
有的任务直接在执行器内完成,不会进入独立计算引擎。这里讨论的是需要继续提交计算任务的教学场景。
本篇重点区分三种排查方向:
| 方向 | 常见表现 | 应该优先查什么 |
|---|---|---|
| 调度规则等待 | 仍未启动,依赖或并发条件未满足 | 本次调度决策、依赖状态、并发限制 |
| 合适的执行资源不足 | 仍未启动,找不到符合条件的空闲槽位 | 标签、执行池、槽位、节点上报时间 |
| 计算队列等待 | 执行器已经启动,引擎应用仍在队列中 | 应用标识、队列状态、准入与资源分配记录 |
前两项都可能发生在调度侧,不一定是两个先后独立的队列。不同平台的调度模型会有区别,不能仅凭一个通用流程图套用内部状态机。

图 1:先定位等待发生在哪一层,再解释这一层的限制。
还有一个关键提醒:调度平台里的 RUNNING,可能只表示执行器已开始处理。执行器可能还在准备参数、上传文件、等待提交结果,或者等待引擎完成。
调度侧 RUNNING,不能自动翻译成"计算引擎正在计算"。
3. 先查准确的一次运行,不要只拿任务名拼关联
小周输入"门店经营日报"。
小林先确认了三项身份:
taskId=daily-report:是哪项任务。runId:是哪一次运行。attemptId=attempt-1:这次运行里的哪次执行尝试。
同一项任务可能每天都运行,也可能在同一天重跑。只有任务名,无法保证查到的队列记录和日志属于同一次执行。
本篇所有样本只有一个执行尝试。为了让实验可以直接复现,提供以下精确标识:
| 运行标识 | 场景 |
|---|---|
run-policy-20260916 |
任务并发限制 |
run-slots-20260916 |
匹配节点没有空闲槽位 |
run-engine-20260916 |
已提交,计算队列等待 |
run-unknown-20260916 |
执行器已启动,但缺少引擎关联标识 |
run-engine-stale-20260916 |
引擎记录已经过期 |
读者在实验里可以直接复制这些标识。真实助手应先通过运行列表找到候选,再使用返回的准确标识查询,不能按名称猜一个 runId。
引擎标识也一样。本例 demo-app-42 来自该次运行的教学提交记录,并不是把任务名换个前缀拼出来的。
4. CPU 明明很低,为什么还是分不到执行节点
先看 run-slots-20260916。
这次运行 08:55 进入等待,09:10 还没有执行器启动记录。09:09 的调度记录给出原因:NO_MATCHING_SLOT。
本次任务要求执行节点带有 batch 标签。09:09 的节点快照是:
| 节点 | 标签 | CPU | 已用槽位 / 总槽位 | 能否提供匹配的空闲槽位 |
|---|---|---|---|---|
| A | batch | 34% | 2/2 | 标签匹配,但槽位已满 |
| B | realtime | 22% | 0/2 | 槽位空闲,但标签不匹配 |
| C | batch | 17% | 2/2 | 标签匹配,但槽位已满 |
这里的槽位是教学调度器设置的并发执行名额,不等于 CPU 核数。
一个任务占着槽位时,可能在等数据或远程响应,CPU 不一定高。因此"槽位占满"和"CPU 占满"是两个不同事实。

图 2:B 有两个空闲槽位,但这次运行要求 batch 标签,不能据此认为它能被分配过去。
全池空闲槽位是 2 个,符合本次标签条件的空闲槽位却是 0 个。
这个结果可以解释:按 09:09 的调度记录与槽位快照,这次运行尚未拿到匹配的执行槽位。
但我们只有 09:09 的这组证据,不能因此声称从 08:55 到 09:10 的每一分钟都满足同样条件。要解释完整等待过程,还需要等待期间的调度决策和槽位释放事件。
节点 C 在这个独立样本中的上报是 09:09,和前文 pressure 的 08:20 旧记录不同。场景不同,不能混用两组时间。
5. 有空闲资源,也可能被调度规则拦住
接着看 run-policy-20260916。
它同样从 08:55 开始等待,但调度记录中的原因是:
text
schedulerReason = MAX_CONCURRENT_RUNS
activeRunsForTask = 1
maxConcurrentRuns = 1
这组教学规则规定:同一任务同时最多运行一个实例,09:09 已经有一个运行中的实例。
小周问:"那多加一台机器有用吗?"
单从这条规则看,增加机器不会自动解除同一任务的并发上限。应该先核对正在运行的实例、它是否正常推进,以及为什么要设置这个限制。
这不等于建议立即修改限制。并发上限可能用于避免重复处理或资源竞争,是否调整需要结合业务约束。
在助手报告里,应把这组证据描述为"调度器报告并发上限已达到",不要换成"集群资源不足"。后者把一个明确的调度条件,改写成了另一种没有证据的解释。
6. 调度侧已经 RUNNING,计算引擎仍可能在排队
第三次查询是 run-engine-20260916。
这组数据给出了一条更细的时间线:
| 时刻 | 记录 |
|---|---|
| 08:55 | 进入调度等待 |
| 09:00 | 执行器开始处理,调度状态变为 RUNNING |
| 09:01 | 提交到计算引擎,记录到 demo-app-42 |
| 09:09 | 引擎上报该应用为 QUEUED |
| 09:10 | 本次实验观察时刻 |
09:09 的引擎队列记录同时给出:
text
queueName = demo-batch
queueRunning = 8
queueLimit = 8
engineReason = QUEUE_RUNNING_LIMIT
在这个简化的教学规则里,该队列同时最多准入 8 个运行中的应用,目前已经达到上限。原始原因也明确指向队列运行数量限制。
这组证据支持"应继续检查计算队列的准入条件"。它不能推出所有节点 CPU 都高,也不能推出整个集群都没有可用内存。
小周又算了一遍耗时:"调度等了 5 分钟,启动后 10 分钟,提交后 9 分钟,总共 24 分钟?"
小林摇头:"后面的 9 分钟,已经包含在启动后的 10 分钟里。"

图 3:提交后经过时间是启动后经过时间的一部分,不能重复相加。
正确的口径是:
text
总经过时间:09:10 - 08:55 = 15 分钟
调度等待:09:00 - 08:55 = 5 分钟
执行器启动后经过:09:10 - 09:00 = 10 分钟
提交后经过:09:10 - 09:01 = 9 分钟
因此,总经过时间是 5 + 10 = 15 分钟。
这里特意使用"提交后经过",没有把 9 分钟命名为精确的"引擎排队时长"。最后一次引擎观测是 09:09,只有状态快照,没有完整的阶段事件序列;09:09 到观察时刻之间是否变化,也没有新的观测。
更不能把这 9 分钟写成实际计算时间。
7. 工具返回证据和排查方向,不替我们修改调度
本篇新增 ops_inspect_wait。
小林没有把所有输入设计成自由组合。调用者只提供一个精确 runId,工具读取这次运行相关的固定教学记录。
返回结构保留原始 run、阶段 stage、原因 reason、几个口径明确的耗时,以及 nextChecks。
下面是服务里的返回对象定义。可能不存在的启动时间、引擎关联和队列字段都明确允许 null:
java
public record Executor(String nodeId, String tag, int totalSlots, int usedSlots,
double cpuPercent, OffsetDateTime reportedAt) {}
public record Run(String runId, String taskId, String attemptId, String requiredExecutorTag,
String schedulerState,
OffsetDateTime queuedAt, @Schema(nullable=true) OffsetDateTime executorStartedAt,
@Schema(nullable=true) String schedulerReason, OffsetDateTime schedulerReportedAt,
@Schema(nullable=true) Integer activeRunsForTask,
@Schema(nullable=true) Integer maxConcurrentRuns,
@Schema(nullable=true) String engineId, @Schema(nullable=true) OffsetDateTime submittedAt, @Schema(nullable=true) String engineState,
@Schema(nullable=true) OffsetDateTime engineReportedAt, @Schema(nullable=true) String engineReason,
@Schema(nullable=true) String queueName,
@Schema(nullable=true) Integer queueRunning, @Schema(nullable=true) Integer queueLimit, List<Executor> executors) {}
public record WaitResult(OffsetDateTime observedAt, Run run, String stage,
long totalElapsedMinutes, long schedulerWaitMinutes,
@Schema(nullable=true) Long afterExecutorStartMinutes, @Schema(nullable=true) Long afterSubmitMinutes,
String reason, List<String> nextChecks) {}
判断过程的核心方法如下,完整的固定样本与辅助方法在配套工程的 WaitService.java 中:
java
public WaitResult inspect(String runId) {
Run run = fixture(runId);
OffsetDateTime started = run.executorStartedAt();
long schedulerWait = minutes(run.queuedAt(), started == null ? DemoData.NOW : started);
Long afterStart = started == null ? null : minutes(started, DemoData.NOW);
Long afterSubmit = run.submittedAt() == null ? null : minutes(run.submittedAt(), DemoData.NOW);
String stage;
String reason;
List<String> next;
if (started == null && "FRESH".equals(DemoData.freshness(run.schedulerReportedAt()))) {
stage = "SCHEDULER_WAIT";
reason = run.schedulerReason();
next = reason.equals("NO_MATCHING_SLOT")
? List.of("核对 batch 标签和可用槽位", "查看等待期间槽位释放事件")
: List.of("核对任务并发上限与正在运行的实例", "查看调度决策事件");
} else if (run.engineId() == null) {
stage = "UNKNOWN";
reason = "ENGINE_MAPPING_MISSING";
next = List.of("按 runId + attemptId 查提交日志", "确认引擎应用标识与提交结果");
} else if (!"FRESH".equals(DemoData.freshness(run.engineReportedAt()))) {
stage = "UNKNOWN";
reason = "ENGINE_REPORT_STALE";
next = List.of("补查该 engineId 的最新状态", "保留旧记录时间,不宣称当前仍在排队");
} else if ("QUEUED".equals(run.engineState())) {
stage = "ENGINE_QUEUE";
reason = run.engineReason();
next = List.of("查看该计算队列的准入与资源分配记录", "核对请求资源与队列限制");
} else {
stage = "UNKNOWN";
reason = "UNSUPPORTED_ENGINE_STATE";
next = List.of("补充引擎执行阶段记录");
}
return new WaitResult(DemoData.NOW, run, stage, minutes(run.queuedAt(), DemoData.NOW),
schedulerWait, afterStart, afterSubmit, reason, next);
}
这里没有用 CPU 大小决定等待阶段,而是先看执行器是否启动、是否有引擎关联、引擎记录是否足够新,再读取该层报告的原因。
nextChecks 只是下一步只读排查建议。工具不会取消任务、调整并发、切换队列或重启组件。
固定样本已经保证关联和时间顺序合法。真实接入时,数据适配层还要校验运行身份、时区和时间先后关系;如果出现"先启动、后入队"之类冲突,应该保留数据异常,而不是继续算出负耗时。
注册入口仍然很薄:
java
package example.qinghe;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/** 第 06 篇工具入口;业务校验与计算由服务完成。 */
@Component
@Profile({"ch06"})
public class WaitTools {
private final WaitService service;
public WaitTools(WaitService service) { this.service = service; }
@McpTool(name="ops_inspect_wait", description="按准确运行标识查询教学等待证据,区分调度侧等待和引擎队列;不重试、不取消、不修改资源。",
generateOutputSchema=true,
annotations=@McpTool.McpAnnotations(readOnlyHint=true, destructiveHint=false))
public WaitService.WaitResult inspect(
@McpToolParam(description="精确运行标识:run-slots-20260916、run-policy-20260916、run-engine-20260916、run-unknown-20260916 或 run-engine-stale-20260916", required=true) String runId) {
return service.inspect(runId);
}
}
8. 把五个分支各跑一次
切换前先结束自己启动的旧章节服务。在 lab-03-06 目录执行:
bash
mvn test package
java -jar target/qinghe-ops-lab-1.0.0.jar --spring.profiles.active=ch06
新开终端,先查执行槽位:
bash
python3 mcp_call.py ops_inspect_wait \
'{"runId":"run-slots-20260916"}'
再查计算队列:
bash
python3 mcp_call.py ops_inspect_wait \
'{"runId":"run-engine-20260916"}'
第二次调用的实际结构化结果中,以下是顶层判断与耗时摘录。run 原始记录另行保留,包含前文时间线、引擎应用和队列数字:
json
{
"observedAt": "2026-09-16T09:10:00+08:00",
"stage": "ENGINE_QUEUE",
"totalElapsedMinutes": 15,
"schedulerWaitMinutes": 5,
"afterExecutorStartMinutes": 10,
"afterSubmitMinutes": 9,
"reason": "QUEUE_RUNNING_LIMIT",
"nextChecks": [
"查看该计算队列的准入与资源分配记录",
"核对请求资源与队列限制"
]
}
五组样本应分别得到:
| 运行 | 阶段 | 原因 |
|---|---|---|
| policy | SCHEDULER_WAIT | MAX_CONCURRENT_RUNS |
| slots | SCHEDULER_WAIT | NO_MATCHING_SLOT |
| engine | ENGINE_QUEUE | QUEUE_RUNNING_LIMIT |
| unknown | UNKNOWN | ENGINE_MAPPING_MISSING |
| engine-stale | UNKNOWN | ENGINE_REPORT_STALE |
最后两个分支不是为了凑测试数量。
缺少引擎标识时,我们只知道执行器已经开始处理,不能断言计算已经提交成功,更不能随便找一条名称相近的引擎应用来解释。
引擎记录过期时,也不能照搬它最后一次 QUEUED 的状态,宣称当前仍在排队。engine-stale 的记录停在 09:02,距观察时刻 8 分钟,超过本例 5 分钟规则,因此保留 UNKNOWN。
配套测试验证了五个分支、非法运行标识、匹配槽位计算,以及耗时字段的包含关系;MCP 调用验证使用真实教学服务,没有连接任何真实调度平台或计算引擎。
9. 一份等待分析,应该能带着人继续往下查
对于 run-engine-20260916,可以组织成下面的预期回答。它依据教学数据编写,未经过真实模型调用:
本次查询针对
daily-report的运行run-engine-20260916、尝试attempt-1。该运行 08:55 入队,09:00 启动执行器,调度等待为 5 分钟。09:01 已记录向计算引擎提交,关联应用为
demo-app-42。引擎最新记录来自 09:09,状态为 QUEUED,报告原因为
QUEUE_RUNNING_LIMIT;对应demo-batch队列运行数为 8,上限为 8。按本例时效规则,这条记录可用于当前排查,应优先核对计算队列的准入与资源分配记录。到观察时刻 09:10,执行器启动后经过 10 分钟,其中提交后经过 9 分钟。提交后经过时间不等于实际计算时间,也不能直接当成精确排队时长。
如果是 slots,报告就换成匹配条件和槽位证据;如果是 policy,就说明任务并发限制;如果缺少关联或记录过期,就明确指出缺什么。
小周终于不再把所有等待都叫成"机器忙"。
"现在至少知道应该去看哪一层了。"
小林说:"这一步就很有价值。先缩小范围,再补证据,后面的查询才不会东一条、西一条。"
到这里,我们的运维工具已经能列出节点、解释组件上报、描述 CPU 趋势,并区分几种等待方向。后面继续分析失败和重试时,同样要守住这条线:围绕准确的一次执行,让每个结论都能回到对应的记录。