大数据智能运维实战(二):准备一个可复现的运维实验环境

系列: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 工具,查询节点与组件状态》

相关推荐
数字新视界3 小时前
机房动环监控助力智能管理
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
数据中心的那点事儿3 小时前
艾特网能:GPU算力服务器冷却系统配套设计介绍
运维·服务器·gpu算力
爱读书的小胖3 小时前
80. 删除有序数组中的重复项 II
java·数据结构·算法
jyOverQ3 小时前
从零开始学 RocketMQ(一):基础概念、环境搭建与简单消息收发【保姆级教程】
java·rocketmq·java-rocketmq
小易老师AI实战3 小时前
LangChain + MCP 协议:Agent 接入联网搜索(Tavily MCP)完整实践
langchain·agent·ai智能体·大模型应用·mcp·联网搜索
用户7771298509123 小时前
封装、继承、多态
java
广州灵眸科技有限公司3 小时前
单芯片同时跑 Linux 与 RT-Thread:RV1126B的AMP异构方案完整拆解
linux·运维·服务器
Ysx3 小时前
比"本地全绿"更可怕的坑:"交付了"不等于"生效了"
java·后端
2301_786756603 小时前
2026 JCR 秋季数据重载解读:影响因子为什么会变?附期刊影响力数据批量追踪方案
java·服务器·前端