系列:Spring AI + MCP------大数据智能运维实战
本篇目标:用一个可以直接运行的 Java 文件,准备节点、组件、指标、任务运行和日志样本,复现四种运维场景。
阅读基础:了解 Java 基础语法;运行示例需要 JDK 17。
案例说明:人物、平台、节点、任务、日志和数据均为虚构教学设定。示例独立设计,不对应任何真实生产系统。
1. 小周以为,这次要先搭一个集群
上一篇,小林和小周把"门店经营日报为什么迟到"拆成了两个问题:启动前等了多久,启动后又发生了什么。
小周觉得思路已经清楚了,接着问:
"那接下来要准备几台机器?是不是得把调度平台、监控和日志服务都装起来?"
小林打开编辑器,新建了一个文件。
"这一步,先把几份记录准备出来就够了。"
我们这一阶段要练习的是:根据任务找到某次运行,再找到相关节点、指标和日志,最后判断这些记录能支持什么结论。
因此,第一套实验环境可以很小:一份固定的教学数据,加上一段能够读取和检查它的程序。
程序运行在本地内存中,不需要模型 Key,也不用先安装数据库或大数据集群。后续接入 MCP 时,工具读取的就是这些教学对象。
本篇结束时,我们希望做到:无论今天运行还是明天运行,选择同一个场景,都能得到相同的记录和计算结果。
这样,当助手说错了话,我们可以回头核对:是查错了对象、算错了时间,还是把一条线索说成了原因。
2. 先把青禾平台的几份记录连起来
继续沿用上一篇的虚构环境:青禾数据平台。
我们准备三台节点,一项每日任务,以及它周围的几类记录。
| 记录 | 本篇放什么 | 用来回答什么 |
|---|---|---|
| 节点 | 节点标识、所属集群 | 有哪些节点 |
| 组件上报 | 节点、组件名称、上报状态、时间 | 哪台节点运行了什么组件,记录有多新 |
| 指标样本 | 节点、CPU 百分比、采样时间 | 某个时刻观测到了什么 |
| 任务定义 | 任务标识、名称 | 用户说的"日报"是哪项任务 |
| 运行记录 | 运行标识、业务日期、状态、执行节点、起止时间 | 哪次运行,在哪个阶段 |
| 日志片段 | 运行标识、尝试标识、时间、内容 | 这条日志属于哪次执行 |
节点命名仍然是 demo-worker-a、demo-worker-b、demo-worker-c。
组件统一叫 demo-executor,表示"教学执行器"。这里不模拟某个真实组件的内部行为。

图 1:先明确记录属于谁,再讨论它能说明什么。图中是教学对象关系,不是生产数据库表结构。
小林特别区分了两个名字:
daily-report是任务标识,表示"门店经营日报"这项任务。run-pressure-20260916是某次教学运行的标识。
同一项任务可以每天运行,所以任务标识不能代替运行标识。
本篇每次运行只有一个执行尝试,记为 attempt-1。日志使用"运行标识 + 尝试标识"关联;到了重试章节,再展开多次尝试的过程。
还有一个容易混淆的字段:业务日期。
本次任务在 2026 年 9 月 16 日运行,但处理的是 9 月 15 日的数据。这两个日期要分别保留,否则一句"查昨天的日报"就可能找错对象。
3. 把实验的时间停在 09:10
小周先写了一个计算:
"当前时间减去启动时间,不就是已经执行多久了吗?"
小林看了一眼:"真实查询时可以使用查询时刻,但我们的实验需要先把这个时刻固定下来。"
如果样本永远写着"9 月 16 日 08:35 启动",程序却每次都读取电脑的当前时间,隔一天再运行,结果就会多出一天。
因此,本篇统一约定:
| 项目 | 教学约定 |
|---|---|
| 观察时刻 | 2026-09-16 09:10:00 |
| 时区偏移 | +08:00,即本文使用的北京时间 |
| 当前运行的业务日期 | 2026-09-15 |
| CPU 单位 | 百分比,例如 88 表示 88% |
| 数据过期阈值 | 数据年龄超过 5 分钟 |
5 分钟只是本篇用于练习的阈值。 真正接入数据源时,需要根据采样频率、上报延迟和查询目的重新确定。
数据年龄的算法很简单:
text
数据年龄 = 固定观察时刻 - 采样时间

图 2:恰好 5 分钟仍算新鲜;超过 5 分钟才算过期。时间异常和没有样本分别处理。
这里还有两个小细节。
第一,过期不等于数值无效。08:20 的 17% 可以描述 08:20 的观测,但不能拿来证明 09:10 仍然空闲。
第二,FRESH 只说明样本符合这条时效规则,不代表机器一定健康。即使样本来自一分钟前,它也只是一个采样点。
本篇直接使用固定的 OffsetDateTime。以后把时间提供方式抽成可替换对象,也可以使用 Java 的 Clock.fixed 支持测试。Java Clock 官方说明
4. 准备四组样本,每组都有明确的预期
小周把正常数据填好后,准备收工。
小林又加了三组:高 CPU、任务失败、上报过期。
"只用正常样本,我们只能知道程序在顺利的时候能不能跑。"
这四组场景分别叫 normal、pressure、failed 和 stale。它们是四次独立实验,每次只加载一组,不是同一次任务依次经历的四个阶段。

图 3:先准备能核对的预期结果,后面接入工具和模型时才能检查有没有解释错。
normal 场景中,任务 08:00 入队,08:02 启动,08:32 成功结束。等待 2 分钟,执行 30 分钟。
pressure 继续复现上一篇:08:00 入队,08:35 启动,09:10 仍在运行。节点 B 在 09:09 的 CPU 为 88%,节点 C 最近的样本还停留在 08:20。
我们给 B 额外增加一个 09:00 的 86% 样本,供后面练习指标查询。两个观测点都偏高,但不能说明两点之间持续处于高位,也不能解释 08:00---08:35 的排队。这个场景名只是方便选择样本,不是已经确认的根因。
failed 场景中,任务 08:35 启动,08:43 失败,08:42 有一条虚构的输入缺失日志。这能提供排查线索,至于输入为什么不可用,还要继续查。
stale 场景中,任务仍在运行,但三台节点的组件与 CPU 记录都停在 08:20。程序需要保留旧记录,并说明它们不能代表当前状态。
另外,每组场景都带上相同的 7 次历史成功运行,执行耗时分别为:
text
28、30、31、29、32、30、27 分钟
排序以后,中间那个数是 30。这就补齐了上一篇使用的"历史 7 次成功执行耗时中位数"。这些历史记录同样是构造出来的教学样本。
5. 用一个 Java 文件把实验跑起来
在一个空目录里创建 OpsLab.java,保存为 UTF-8。
下面是完整代码,可以整体复制。它主要做三件事:定义记录、按场景准备数据、打印可核对的结果。
record 在这里用来装一组相关字段。看到 Metric,可以先把它理解为"节点、CPU 数值和采样时间组成的一条记录"。
java
import java.time.*;
import java.util.*;
/** 独立教学样本:固定时刻读取四种运维场景,不访问外部服务。 */
public class OpsLab {
static final OffsetDateTime NOW = at("09:10");
static final Duration MAX_AGE = Duration.ofMinutes(5);
record Node(String id, String cluster) {}
record Component(String nodeId, String name, String state, OffsetDateTime at) {}
record Metric(String nodeId, double cpuPercent, OffsetDateTime at) {}
record Task(String id, String name) {}
record Run(String id, String taskId, String attemptId, LocalDate businessDate,
String nodeId, String state, OffsetDateTime queuedAt,
OffsetDateTime startedAt, OffsetDateTime finishedAt) {}
record LogEntry(String runId, String attemptId, OffsetDateTime at, String text) {}
record Fixture(Task task, List<Node> nodes, List<Component> components,
List<Metric> metrics, Run run, List<Run> history,
List<LogEntry> logs, boolean logsComplete) {}
static OffsetDateTime at(String time) {
return OffsetDateTime.parse("2026-09-16T" + time + ":00+08:00");
}
static long minutes(OffsetDateTime from, OffsetDateTime to) {
return Duration.between(from, to).toMinutes();
}
/** 超过 5 分钟才算过期;未来时间单独标记,避免误当成新鲜数据。 */
static String freshness(OffsetDateTime sampledAt) {
Duration age = Duration.between(sampledAt, NOW);
if (age.isNegative()) return "INVALID_TIME";
return age.compareTo(MAX_AGE) > 0 ? "STALE" : "FRESH";
}
/** 场景之间互不叠加,每次调用都从固定样本重新构造。 */
static Fixture load(String scene) {
if (!Set.of("normal", "pressure", "failed", "stale").contains(scene)) {
throw new IllegalArgumentException("场景仅支持 normal/pressure/failed/stale");
}
boolean normal = scene.equals("normal");
boolean failed = scene.equals("failed");
boolean stale = scene.equals("stale");
Task task = new Task("daily-report", "门店经营日报");
List<Node> nodes = List.of(
new Node("demo-worker-a", "qinghe-demo"),
new Node("demo-worker-b", "qinghe-demo"),
new Node("demo-worker-c", "qinghe-demo"));
List<Component> components = new ArrayList<>();
List<Metric> metrics = new ArrayList<>();
for (int i = 0; i < nodes.size(); i++) {
String node = nodes.get(i).id();
boolean old = stale || (scene.equals("pressure") && i == 2);
OffsetDateTime sampledAt = at(old ? "08:20" : "09:09");
components.add(new Component(node, "demo-executor", "RUNNING", sampledAt));
double cpu = normal || failed ? 42 : 88;
if (i == 0) cpu = 34;
if (i == 2) cpu = 17;
metrics.add(new Metric(node, cpu, sampledAt));
}
if (scene.equals("pressure")) {
metrics.add(new Metric("demo-worker-b", 86, at("09:00")));
}
Run run = new Run("run-" + scene + "-20260916", task.id(), "attempt-1",
LocalDate.parse("2026-09-15"), "demo-worker-b",
normal ? "SUCCESS" : failed ? "FAILED" : "RUNNING",
at("08:00"), at(normal ? "08:02" : "08:35"),
normal ? at("08:32") : failed ? at("08:43") : null);
List<Run> history = new ArrayList<>();
int[] durations = {28, 30, 31, 29, 32, 30, 27};
for (int i = 0; i < durations.length; i++) {
OffsetDateTime start = at("08:02").minusDays(7 - i);
history.add(new Run("history-" + start.toLocalDate(), task.id(), "attempt-1",
start.toLocalDate().minusDays(1), "demo-worker-b", "SUCCESS",
start.minusMinutes(2), start, start.plusMinutes(durations[i])));
}
List<LogEntry> logs = List.of(new LogEntry(run.id(), run.attemptId(),
failed ? at("08:42") : run.startedAt(),
failed ? "ERROR DEMO_INPUT_MISSING: input batch unavailable"
: "INFO DEMO_ATTEMPT_STARTED"));
return new Fixture(task, List.copyOf(nodes), List.copyOf(components),
List.copyOf(metrics), run, List.copyOf(history), logs, false);
}
/** 直接打印观测事实,不将场景名称或 CPU 数值转换为根因结论。 */
static void print(Fixture fixture) {
Run run = fixture.run();
OffsetDateTime end = run.finishedAt() == null ? NOW : run.finishedAt();
List<Long> durations = fixture.history().stream()
.map(r -> minutes(r.startedAt(), r.finishedAt())).sorted().toList();
System.out.println("观察时刻=" + NOW);
System.out.println("任务=" + fixture.task().name() + ",状态=" + run.state());
System.out.println("业务日期=" + run.businessDate() + ",运行=" + run.id());
System.out.println("调度等待分钟=" + minutes(run.queuedAt(), run.startedAt()));
System.out.println((run.finishedAt() == null ? "启动后已过分钟=" : "本次执行分钟=")
+ minutes(run.startedAt(), end));
System.out.println("历史7次成功执行中位数分钟=" + durations.get(3));
for (Node node : fixture.nodes()) {
Optional<Metric> latest = fixture.metrics().stream()
.filter(m -> m.nodeId().equals(node.id()))
.max(Comparator.comparing(Metric::at));
if (latest.isEmpty()) {
System.out.println(node.id() + " CPU=UNKNOWN(缺少样本)");
continue;
}
Metric metric = latest.get();
System.out.printf(Locale.ROOT, "%s CPU=%.0f%% 采样=%s 时效=%s%n",
node.id(), metric.cpuPercent(), metric.at().toLocalTime(),
freshness(metric.at()));
}
System.out.println("组件记录=" + fixture.components().size()
+ ",指标样本=" + fixture.metrics().size());
System.out.println("日志片段=" + fixture.logs().size()
+ ",是否完整=" + fixture.logsComplete());
fixture.logs().forEach(log -> System.out.println(log.at().toLocalTime()
+ " " + log.runId() + "/" + log.attemptId() + " " + log.text()));
}
/** 用法:java OpsLab.java [normal|pressure|failed|stale]。 */
public static void main(String[] args) {
if (args.length > 1) throw new IllegalArgumentException("一次只选择一个场景");
print(load(args.length == 0 ? "pressure" : args[0]));
}
}
代码里的样本数量很少,所以直接使用内存列表。后续访问真实数据源时,按节点、时间范围等条件筛选应由查询层完成,不应先把全部监控数据读进内存。
在文件所在目录打开终端,先确认 Java 版本,再运行:
bash
java -version
java OpsLab.java pressure
本篇使用 JDK 17。java 文件名.java 是 Java 的单文件源码运行方式,适合这个没有第三方依赖的小实验。Java 启动命令官方说明
如果终端仍显示 Java 8,需要先切换到 JDK 17;编辑器配置的 JDK 和终端使用的 java 可能不同。
运行结果如下。这是固定教学数据经本地程序计算的输出,不是模型生成的诊断:
text
观察时刻=2026-09-16T09:10+08:00
任务=门店经营日报,状态=RUNNING
业务日期=2026-09-15,运行=run-pressure-20260916
调度等待分钟=35
启动后已过分钟=35
历史7次成功执行中位数分钟=30
demo-worker-a CPU=34% 采样=09:09 时效=FRESH
demo-worker-b CPU=88% 采样=09:09 时效=FRESH
demo-worker-c CPU=17% 采样=08:20 时效=STALE
组件记录=3,指标样本=4
日志片段=1,是否完整=false
08:35 run-pressure-20260916/attempt-1 INFO DEMO_ATTEMPT_STARTED
小周看到熟悉的两个 35,点了点头:
"上一篇手算的结果,现在有程序可以核对了。"
再运行其他场景,只需要替换最后一个参数:
bash
java OpsLab.java normal
java OpsLab.java failed
java OpsLab.java stale
不传参数时,默认使用 pressure。传入未定义的场景会直接报错,避免拼错名称后悄悄读取另一组数据。
6. 代码能运行,还要看看有没有算对
四条命令都能运行,并不意味着数据已经准备好了。
小林拿出一张核对表:
| 场景 | 应当看到的结果 | 不能据此得出的结论 |
|---|---|---|
normal |
成功;等待 2 分钟;执行 30 分钟;三台节点最新指标均未过期 | 一次成功就说明整个集群没有问题 |
pressure |
运行中;等待 35 分钟;启动后已过 35 分钟;B 最新 CPU 为 88%;C 的样本已过期 | 因为 09:09 CPU 高,所以 08:00 就在缺资源 |
failed |
失败;等待 35 分钟;执行 8 分钟;日志出现模拟输入缺失信息 | 已经知道输入缺失的上游原因 |
stale |
三台节点 CPU 样本均过期,不能代表当前情况 | 原值低就说明机器现在空闲 |
这里最容易写错的是已经结束的任务。
normal 在 08:32 已经结束,执行耗时应当用:
text
08:32 - 08:02 = 30 分钟
不能因为查询时刻是 09:10,就继续算到 09:10。失败任务也一样,failed 应当在 08:43 停止累计本次执行耗时。
代码中的这一行,就是在处理这个区别:
java
OffsetDateTime end = run.finishedAt() == null ? NOW : run.finishedAt();
运行中的任务使用观察时刻;已经结束的任务使用结束时刻。这里计算的仍是调度侧启动后的经过时间,不承诺全部时间都在进行计算。
还有一个不太显眼的地方:pressure 的 09:00 样本是在 09:09 样本之后添加的。
如果直接取列表最后一条,会错误地拿到 86%。代码按采样时间取最大值,因此最新值仍然是 09:09 的 88%。
数据的排列顺序,不一定等于事件发生顺序。 后面查日志、查重试记录时,也要记住这一点。
发布前,我用 JDK 17 实际运行了四组场景,并检查了记录关联、时间计算、历史中位数、重复构造一致性,以及"恰好 5 分钟""超过 5 分钟""未来时间"等边界。相关 45 项校验通过;本篇验证的是样本与计算,尚未接入模型。
7. 这些记录里,为什么还要保留"不知道"
小周看着输出的最后几行,有点疑惑:
"日志都准备好了,为什么还写着 是否完整=false?"
因为我们只放了一条日志片段。
没有准备完整日志,就不能让后面的工具声称"已经检查全部日志"。logsComplete=false 把这个边界一起带给调用方。
这个小实验还有另外两处刻意保留的空白。
第一,没有排队期间的资源证据。
我们知道任务等了 35 分钟,也看到启动后一段时间的高 CPU,但暂时没有 08:00---08:35 的调度队列和资源分配记录。正确的结果应当停在"等待原因尚未确认"。
第二,组件写着 RUNNING,也可能只是旧的上报。
stale 场景里的组件记录仍保留 RUNNING,对应时间却是 08:20。下一篇把它返回给助手时,必须连同上报时间一起返回。健康判断还需要考虑时效。
程序也保留了"找不到节点指标"的处理:输出 UNKNOWN,而不是把 CPU 填成 0。四组基础样本都提供了节点指标,读者可以另外拿掉一台节点的指标记录,检查这个分支。
小林准备这些空白,是为了让后面的实验能回答一个问题:
"证据少的时候,助手能不能把话说得恰当?"
8. 为什么这篇还没有启动 Spring Boot
写到这里,可能有读者想问:这个系列不是 Spring AI + MCP 吗,怎么这一篇只运行了一个 Java 文件?
因为后面的工具,需要先有一套稳定的输入。
这份代码里的职责已经分开了:
load准备教学记录。freshness和minutes负责确定的计算。print让我们在终端里检查结果。
下一篇会围绕节点与组件查询,增加服务和 MCP 工具入口。教学数据仍然可以复用,打印代码则不承担工具返回结构的职责。
到那时,如果工具返回的节点数量不对,或者组件上报时间丢了,我们可以拿这一篇的样本逐项核对。
这套实验没有模拟真实资源争用、网络故障或引擎执行过程。它验证的是"读到了什么,以及如何理解这些记录"。以后要验证性能和真实故障恢复,还需要相应的运行环境。
9. 下一步,让助手能查到这些节点
小周把四组场景各跑了一遍,又回到 pressure:
"现在这次日报为什么迟到,还是没完全查出来。"
小林说:"但我们已经知道,后面每增加一个查询工具,应该从哪里取数据,哪些结论能核对,哪些证据还缺着。"
目前,我们有一个虚构集群、三台节点、组件上报、带时间的 CPU 样本、当前与历史运行,以及能关联到本次执行的日志片段。
更关键的是,同一个场景可以重复运行,结果保持一致。
接下来,就可以把第一项查询能力交给助手了:
"青禾平台有哪些节点?每台节点上报了什么组件状态?"
上一篇:机器和任务数据都有了,如何用 MCP 搭建运维助手?
下一篇:《大数据智能运维实战(三):实现第一个运维 MCP 工具,查询节点与组件状态》