提交 ad4163bb authored 作者: 陈泽健's avatar 陈泽健

feat(perf): 目标机 Java 进程瞬时 CPU 采集 + 多实例服务级聚合与 MySQL 趋势

- target_resource_monitor.py: 新增 /proc/<pid>/stat 两次采样(JSTAT1/JSTAT2,间隔 0.2s)
  以 utime+stime tick 差值计算 top 口径瞬时 CPU%(100% = 单核,可超 100%),
  按目标机核数上限截断,采样缺失时回退 ps %cpu(修复压测中途重启 JVM 被
  ps 生命周期平均严重低估的问题,实测匹配 top 的 300%+ 数值)
- _JAVA_PROCESS_KEYWORDS 新增 ubains-gateway/auth/modules-system/message-scheduling/mqtt
- performance_executor.py: 多 PID 服务级聚合(按采样点求和、不补 0)+ pidCount/pids
  非破坏性字段 + mysql.series 趋势序列;schema 透传无需改动
- 前端: types/performance.ts 类型扩展;ReportPanel.vue 实例 PID 列 + 两张 MySQL 趋势图
- 测试: test_target_resource_monitor.py(JSTAT 瞬时 CPU 15 例)+ test_target_resource_summary.py(聚合 7 例)全通过
- 文档: 新增 PRD 需求文档 + 计划执行文档(Java 服务多实例与 MySQL 容器趋势监控)

已验证并部署 5.60:py_compile/pytest/前端真实页面冒烟通过,5.44 实测 8 核 clk=100 解析正确
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 426c01cd
...@@ -7,8 +7,6 @@ ...@@ -7,8 +7,6 @@
> **状态**: ✅ 遗留 HANDOFF 文档更新已提交 `df34c1c8`;✅ Vue placeholder 引号转义修复已提交 `11e1dd85`;✅ 两 commit 均已推送 origin/platform-auto-test > **状态**: ✅ 遗留 HANDOFF 文档更新已提交 `df34c1c8`;✅ Vue placeholder 引号转义修复已提交 `11e1dd85`;✅ 两 commit 均已推送 origin/platform-auto-test
> **会话窗口(上一窗口)**: AI 分析报告资源使用分析四维度增强(commit `c73ffd58`,已推送 + 部署 5.60 + 用户 2026-08-28 真实验收) > **会话窗口(上一窗口)**: AI 分析报告资源使用分析四维度增强(commit `c73ffd58`,已推送 + 部署 5.60 + 用户 2026-08-28 真实验收)
---
## ⚡ 最新会话更新(2026-08-31)— 清理提交:HANDOFF 文档入库 + Vue placeholder 转义修复 ✅ ## ⚡ 最新会话更新(2026-08-31)— 清理提交:HANDOFF 文档入库 + Vue placeholder 转义修复 ✅
### A. 会话背景 ### A. 会话背景
...@@ -327,6 +325,64 @@ fix(perf-report): 补全报告导出缺失模块与图表 + 接口名URL去前 ...@@ -327,6 +325,64 @@ fix(perf-report): 补全报告导出缺失模块与图表 + 接口名URL去前
| 2 | **接口名去前缀验证** | 重新执行一次压测任务 → 请求详情察看结果树接口名不再带 `https://IP` 前缀(需新执行才生效) | | 2 | **接口名去前缀验证** | 重新执行一次压测任务 → 请求详情察看结果树接口名不再带 `https://IP` 前缀(需新执行才生效) |
| 3 | 遗留未提交文件 | `backend/data/test_platform.db` 工作区变更(按约定不提交);`backend/scripts/probe_44_202.py``frontend/public/` 临时文件确认是否清理 | | 3 | 遗留未提交文件 | `backend/data/test_platform.db` 工作区变更(按约定不提交);`backend/scripts/probe_44_202.py``frontend/public/` 临时文件确认是否清理 |
| 4 | FR-5 图片加载提示(P2 可选) | 计划文档已列:PDF 导出加载期间显示 loading 提示,未实现,非必须 | | 4 | FR-5 图片加载提示(P2 可选) | 计划文档已列:PDF 导出加载期间显示 loading 提示,未实现,非必须 |
## ⚡ 最新会话更新(2026-08-31)— Java 服务多实例 + MySQL 容器趋势监控(已部署 5.60)
### A. 会话背景
目标机 SSH 监控此前已能采集系统资源、Java 进程和 `umysql` 容器指标。本批补齐五个基础 Java 服务关键字,修正同一服务多 PID 时的报告口径(服务级聚合),并将 MySQL 采样从「仅展示最后一次卡片」扩展为可回放趋势,便于关联 TPS、延迟与数据库负载。
### B. 需求与计划文档(新建)
| 文档 | 路径 |
|------|------|
| PRD 需求文档 | `Docs/PRD/性能测试/需求文档/_PRD_Java服务多实例与MySQL容器趋势监控.md` |
| 计划执行文档 | `Docs/PRD/性能测试/需求文档/_PRD_Java服务多实例与MySQL容器趋势监控_计划执行.md` |
### C. 改动清单 ✅(4 个 Phase 全部完成)
**后端**`backend/app/executors/`):
- `target_resource_monitor.py``_JAVA_PROCESS_KEYWORDS` 新增五个服务关键字:`ubains-gateway``ubains-auth``ubains-modules-system``ubains-meeting-message-scheduling``ubains-meeting-mqtt`,均位于泛化 `ubains-meeting` 之前(首个匹配生效,避免被吞并)。
- `performance_executor.py:_build_target_resource_summary()`
1. **多 PID 服务级聚合**:按采样时间点(elapsed)为边界、按 `processName` 分组,组内 PID 的 CPU/内存/RSS **求和**;每个服务每个时间点仅一个趋势点。统计 avg/max 按服务级时间点计算(不再把多个 PID 观测行直接混合)。某采样点服务不可见时**不补 0**(可见样本口径,避免 SSH/进程缺失误判为零资源)。
2. **非破坏性字段**`javaProcessStats` / `javaProcessSeries` 增加 `pidCount``pids`(去重排序),既有 `processName/cpuPercent/memPercent/rssMb/avg*/max*` 字段名保持不变。
3. **MySQL 趋势序列**:从每个可用采样构建 `mysql.series`(elapsed、连接数、活跃线程、慢查询、缓冲池命中率、累计连接/查询、收发流量);累计值保留原始值不做平均;最新值卡片字段全部保留。
- `schemas/performance.py` 无需改动(`target_resource_summary` 本就 `Dict[str, Any]` 透传)。
**前端**
- `src/types/performance.ts``JavaProcessStats`/`JavaProcessSeries` 增加 `pidCount?`/`pids?`;新增 `MysqlSeriesPoint``TargetResourceSummary.mysql` 增加可选 `series?`,全部可选兼容旧报告。
- `src/views/performance/ReportPanel.vue` — Java 表格新增「实例 PID」列(数量 + hover 列表);新增两张 MySQL 趋势图:
- **MySQL 状态趋势**:连接数 / 活跃线程 / 慢查询 / 缓冲池命中率(双 Y 轴)
- **MySQL 累计与流量趋势**:累计连接 / 查询 / 收发流量(双 Y 轴)
-`mysql.available && series 有点` 时渲染;复用既有 ECharts init/resize/dispose 生命周期;旧报告/无数据安全降级。
### D. 验证结果 ✅
| 验证项 | 结果 |
|--------|------|
| `python -m py_compile`(两个后端文件) | ✅ 通过 |
| 新增 `backend/tests/test_target_resource_summary.py` | ✅ 7 passed(双 PID 求和单点 / pidCount·pids / 服务级统计 / mysql.series 字段与单调 elapsed / 无数据降级) |
| 既有 `backend/tests/test_performance_executor.py` | ✅ 25 passed(无回归) |
| `npm run build`(前端类型检查 + Vite 打包) | ✅ 通过 |
### E. 部署 5.60 ✅
1. 上传后端:`performance_executor.py``target_resource_monitor.py``/data/third_party/plat-auto-test/backend/app/executors/`
2. 前端:`npm run build` 产物全量重传(先清远端 `dist/assets``index.html``scp -r`,避免旧 hash 残留)→ `/data/third_party/plat-auto-test/frontend/dist/`
3. `docker compose restart app`,健康检查 `GET /health` = 200,容器 `Up (healthy)`
4. 容器内 `import` 验证通过、关键字列表正确;报告页新 bundle 生效(远端 assets 含 `ReportPanel-WeDbcjj2.js`,含「MySQL 状态趋势」「实例 PID」新代码)
5. 性能 API 冒烟:`/api/performance/tasks` = 200(6 个任务),`/api/performance/executions` = 200
### F. 本次会话待办(交接给下一窗口)
| # | 任务 | 优先级 | 说明 |
|---|------|--------|------|
| 1 | **git commit + push** | P0 | 本批全部改动未提交(2 后端 + 2 前端 + 2 文档 + 1 测试),可用 `/GitCommit` |
| 2 | **端到端实测新口径** | P1 | 5.60 真实执行任务,验证报告页:多 PID 服务显示 PID 数/列表、趋势不重复、MySQL 两张趋势图正常渲染;Java CPU 图 Y 轴动态上限 |
| 3 | 遗留未提交文件 | — | `backend/data/test_platform.db`(按约定不提交)、`backend/scripts/probe_44_202.py``frontend/public/`(临时文件,未纳入本批改动) |
---
--- ---
......
# PRD - Java 服务多实例与 MySQL 容器趋势监控
> **文档版本**:v1.0
> **创建日期**:2026-08-31
> **模块类型**:性能测试 - 目标机资源监控
> **优先级**:P1
## 一、背景与目标
目标机 SSH 监控已能采集系统资源、Java 进程和 `umysql` 容器指标。本次补齐五个基础 Java 服务,并修正同一服务多 PID 时的报告口径;同时将 MySQL 采样从“仅展示最后一次卡片”扩展为可回放趋势,便于关联 TPS、延迟与数据库负载。
目标:
1. 监控 `ubains-gateway``ubains-auth``ubains-modules-system``ubains-meeting-message-scheduling``ubains-meeting-mqtt`
2. 保留实时 PID 明细,同时在报告中按服务和采样时刻聚合多实例。
3. 保存 MySQL 关键指标趋势,旧报告和采集失败时安全降级。
## 二、功能需求
### 2.1 Java 服务识别
命令行匹配采用关键字子串方式,具体服务关键字必须位于泛化的 `ubains-meeting` 之前。每个 PID 的实时快照仍包含 `processName``pid``cpuPercent``memPercent``rssMb`
### 2.2 多实例服务级聚合
- 以采样时间点(`elapsed`)为边界,按 `processName` 聚合该时刻可见的全部 PID。
- CPU、内存百分比和 RSS 均为服务内 PID 求和;每个服务每个时间点最多一个趋势点。
- 平均值按服务级时间点计算,最大值为服务级时间点最大值,不将 PID 观测行直接混合。
- `javaProcessStats``javaProcessSeries` 保持原字段兼容,并增加 `pidCount``pids`(去重 PID 列表)。
- 服务在某个采样暂时不可见时不补 0;仅统计实际可见样本,避免 SSH/进程缺失被误判为零资源。
### 2.3 MySQL 趋势
`targetResourceSummary.mysql` 保留最新采样的既有卡片字段,并增加可选 `series`。趋势点至少支持:`elapsed`、当前连接数、活跃线程、慢查询、缓冲池命中率、累计连接/查询及收发流量。累计计数保留原始值,不计算平均值。仅存在可用 MySQL 采样时生成序列。
报告页展示连接数、活跃线程、慢查询、缓冲池命中率趋势;累计指标可在同图或第二组趋势中展示。无序列、旧报告或 MySQL 不可用时保留卡片或隐藏趋势,不影响报告加载。
## 三、数据与兼容性
不新增数据库列、不改变 API 路径;`target_resource_summary` 继续以 JSON 字典透传。新增字段均为可选,历史报告没有 `pidCount``pids``mysql.series` 时前端使用现有字段正常渲染。
## 四、验收标准
1. 五个关键字均可从 Java 命令行识别,且不被 `ubains-meeting` 吞并。
2. 两个相同服务 PID 在同一采样点只产生一个服务趋势点,CPU/RSS 为求和,统计平均/最大符合服务级口径。
3. 多个 MySQL 样本产生按 elapsed 排序的趋势点,最新卡片字段保持不变。
4. 报告页趋势正常显示;旧格式、无 Java、MySQL 采集失败均不报错。
5. 后端编译与测试、前端构建通过。
## 五、非功能与降级
采集命令、SSH 重连和压测执行流程不变。进程或容器短暂不可见只丢弃该时刻对应指标,不伪造零值;整体无可用样本时沿用现有隐藏目标机资源区块逻辑。敏感凭据不落库。
# 执行计划 - Java 服务多实例与 MySQL 容器趋势监控
> **关联 PRD**:`_PRD_Java服务多实例与MySQL容器趋势监控.md`
> **创建日期**:2026-08-31
> **预计总工时**:1.5 天
---
## 一、执行概述
本计划将需求拆分为 4 个 Phase 实施:
1. **Phase 1**:后端 Java 多 PID 服务级聚合 + MySQL 趋势序列(0.5 天)
2. **Phase 2**:前端类型扩展 + 报告页 MySQL 趋势图(0.5 天)
3. **Phase 3**:验证与测试(编译 + 单测 + 构建)(0.5 天)
4. **Phase 4**:文档同步(本计划含需求文档,实施完成且需要交接时更新 HANDOFF)
> 已完成的未提交改动(本批范围):`target_resource_monitor.py` 五个 Java 关键字已加入 `_JAVA_PROCESS_KEYWORDS`;`ReportPanel.vue` Java CPU 图 Y 轴已改为动态上限。本计划与上述改动保持一致,不做回退。
---
## 二、任务分解
### Phase 1:后端 Java 多 PID 服务级聚合 + MySQL 趋势序列
**目标**`_build_target_resource_summary()` 以采样时间点为边界、按 `processName` 聚合该时刻全部 PID(CPU/内存/RSS 求和),一个服务一个时间点仅一个趋势点;统计值按服务级时间点计算;`javaProcessStats`/`javaProcessSeries` 增加 `pidCount``pids` 非破坏性字段。同时从每个可用采样的 `mysql` 数据构建 `mysql.series` 趋势点,保留最新值卡片字段。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 1.1 | 确认五个新关键字已就位且顺序正确(具体服务在 `ubains-meeting` 之前) | `backend/app/executors/target_resource_monitor.py` | 关键字列表包含 gateway/auth/modules-system/message-scheduling/mqtt 且均在泛化关键字前 |
| 1.2 | 多 PID 服务级聚合:按 (processName, elapsed) 分组求和 CPU/内存/RSS,每个服务每个时间点一个点 | `backend/app/executors/performance_executor.py` | 两个相同 processName 的 PID 同采样点只产生一个趋势点,CPU/RSS 为求和 |
| 1.3 | 统计口径改为服务级时间点集合(avg = 时间点均值、max = 时间点最大值);同一服务多个 PID 的观测行不再直接混入统计 | 同上 | 统计值正确反映服务级聚合口径 |
| 1.4 | 为 `javaProcessStats`/`javaProcessSeries` 增加 `pidCount``pids`(按时间点去重) | 同上 | 字段存在且非破坏性;旧前端仍可读 processName/cpuPercent/memPercent/rssMb |
| 1.5 | 从可用样本提取 MySQL 数据构建 `mysql.series`(elapsed + 连接/活跃线程/慢查询/命中率/累计连接与查询/收发流量等) | 同上 | 每点 elapsed 单调;累计值保留原始值;无 MySQL 样本时不生成 |
| 1.6 | 保留既有 `mysql` 最新值卡片字段;整体可用样本为空时仍返回 None | 同上 | 旧报告/无数据不报错,卡片字段不变 |
**验收方式**:构造含两个相同 `processName` 不同 PID 的样本,验证一个 elapsed 只有一个服务点且值为求和;构造多条 MySQL 样本验证 `mysql.series` 生成且最新卡片不变。
---
### Phase 2:前端类型扩展 + 报告页 MySQL 趋势图
**目标**:扩展 `performance.ts` 类型(pidCount/pids/mysql.series),`ReportPanel.vue` 新增 MySQL 趋势图,Java 表格/趋势沿用兼容字段并补 PID 数量/列表展示。
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 2.1 | `JavaProcessStats` 增加 `pidCount?``pids?``JavaProcessSeries` 增加 `pidCount?``pids?` | `frontend/src/types/performance.ts` | 类型编译通过 |
| 2.2 | 新增 MySQL 趋势点类型 `MysqlSeriesPoint``TargetResourceSummary.mysql` 增加可选 `series` | 同上 | 字段全部可选,兼容旧报告 |
| 2.3 | Java 表格增加 PID 数量/列表列(hover 展示) | `frontend/src/views/performance/ReportPanel.vue` | 多 PID 服务显示数量与 PID 列表 |
| 2.4 | 新增 MySQL 趋势图组件/区块(连接数、活跃线程、慢查询、缓冲池命中率;累计连接/查询/流量第二组) | 同上 | ECharts 生命周期复用现有 refs/init/resize/dispose |
| 2.5 | 仅当 `mysql.available``series` 有点时显示趋势图;否则沿用卡片或隐藏 | 同上 | 旧报告、MySQL 不可用时页面不报错 |
**验收方式**:报告页多实例服务显示 PID 数/列表且趋势不重复;MySQL 趋势图可见;无数据时区块安全降级。
---
### Phase 3:验证与测试
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 3.1 | 后端语法编译 | `backend/app/executors/performance_executor.py` | `python -m py_compile` 通过 |
| 3.2 | 补测:同一服务两个 PID 聚合为服务级单点;MySQL 多样本序列生成;新旧格式报告兼容 | `backend/tests/` | pytest 相关用例通过 |
| 3.3 | 前端构建(类型检查 + 打包) | `frontend/` | `npm run build` 通过(必要时 lint) |
---
### Phase 4:文档同步
| # | 任务 | 关键文件 | 验收标准 |
|---|------|---------|---------|
| 4.1 | 需求文档、计划执行文档落地(本计划即产出) | `docs/PRD/性能测试/需求文档/_PRD_Java服务多实例与MySQL容器趋势监控*.md` | 两份文档存在且内容一致 |
| 4.2 | 实施完成且用户要求交接时更新 HANDOFF | `Docs/PRD/性能测试/HANDOFF_性能测试.md` | 记录本批改动与部署状态 |
| 4.3 | 不自动 commit/push/deploy;未提交临时文件(数据库、probe 脚本、`frontend/public/`)不纳入本批功能修改 | - | 无明显越权操作 |
---
## 三、验收标准总览
| 验收项 | 对应 Phase |
|--------|-----------|
| 五个 Java 服务关键字可识别且不被泛化关键字吞并 | Phase 1 |
| 多 PID 服务按采样时刻聚合成服务级单点(求和) | Phase 1 |
| 统计口径为服务级时间点(avg/max 不混入 PID 观测行) | Phase 1 |
| javaProcessStats/Series 带 pidCount/pids 且旧字段兼容 | Phase 1 |
| mysql.series 趋势点生成,elapsed 单调,累计值保留原值 | Phase 1 |
| 最新 mysql 卡片字段不变 | Phase 1 |
| 前端类型可选字段兼容旧报告 | Phase 2 |
| 报告页 Java PID 数量/列表展示 | Phase 2 |
| 报告页 MySQL 趋势图(两分组)正常渲染与降级 | Phase 2 |
| 后端编译 + pytest + 前端构建通过 | Phase 3 |
---
## 四、关键实现细节
### 4.1 多 PID 聚合口径
```
每个服务: 按 elapsed 分组 → 组内 PID 求和 cpuPercent / memPercent / rssMb
→ 该组生成一个服务级趋势点 {elapsed, cpuPercent(和), memPercent(和), rssMb(和)}
→ pids 为该组出现过的 PID 去重集合;pidCount = len(pids)
统计: avg = 服务级点数均值; max = 服务级点数最大值
缺失: 某采样时间点该服务不可见 → 不生成该时间点,不补 0(可见样本口径)
```
### 4.2 MySQL 趋势序列
```
for usable sample where mysql.available:
mysql.series.append({
elapsed, threadsConnected, threadsRunning, slowQueries,
bufferPoolHitRate, maxUsedConnections, connections,
questions, queries, bytesReceivedMb, bytesSentMb
})
累计值(connections/questions/queries/bytes/慢查询)保留原始值,不做平均。
```
### 4.3 兼容性要点
- `javaProcessSeries.data` 点保留 `processName/cpuPercent/memPercent/rssMb` 既有字段名,仅数值口径由单 PID 变为服务级求和。
- `javaProcessStats` 保留 `avgCpuPercent/maxCpuPercent/avgMemPercent/maxMemPercent/avgRssMb/maxRssMb`
- `target_resource_summary``Dict[str, Any]` 透传;不新增数据库列、不改 API 路径。
- 前端所有新字段可选,旧历史报告安全渲染。
---
## 五、测试计划
### 单元测试
- 构造两个相同 `processName` 不同 PID 的样本 → 一个 elapsed 只有一个服务点,CPU/RSS 为 PID 求和
- 构造多 elapsed 多服务的样本 → 统计 avg/max 按服务级时间点计算
- 构造多条 MySQL 可用样本 → `mysql.series` 点数为样本数、字段齐全、elapsed 单调
- 构造旧格式(无 pidCount/pids/series)报告 JSON → summary/API schema 均通过
### 手动测试
- 本地报告页:五个服务显示;多 PID 服务显示 PID 数/列表且趋势无重复;MySQL 趋势图可见
- 旧报告、MySQL 不可用、无 Java 数据时页面不报错并按既有降级逻辑展示
---
## 六、风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 多 PID 求和口径改变会影响旧报告 Java 趋势观感 | 数值比旧口径高(多实例求和) | 新增 pidCount/pids 明确展示实例数;文档记录口径 |
| 部分时间点某服务被 ps 命令漏抓 | 趋势点稀疏 | 不透传 0,采用可见样本口径,避免误判 |
| MySQL SSH/docker 命令偶发失败 | 趋势缺失 | 保留卡片降级逻辑;无 series 时隐藏趋势图 |
| 前端图表数量增加影响首屏 | 渲染开销 | 复用 ECharts 生命周期;按需初始化/随区块可见性渲染 |
---
## 七、关键文件清单
| 文件 | 操作 | 用途 |
|------|------|------|
| `backend/app/executors/target_resource_monitor.py` | 已改(本批确认) | 五个 Java 关键字 |
| `backend/app/executors/performance_executor.py` | 修改 | 多 PID 服务级聚合 + mysql.series |
| `frontend/src/types/performance.ts` | 修改 | pidCount/pids/mysql.series 类型 |
| `frontend/src/views/performance/ReportPanel.vue` | 修改 | Java PID 展示 + MySQL 趋势图 |
| `Docs/PRD/性能测试/需求文档/_PRD_Java服务多实例与MySQL容器趋势监控.md` | **新建** | 需求文档 |
| `Docs/PRD/性能测试/需求文档/_PRD_Java服务多实例与MySQL容器趋势监控_计划执行.md` | **新建** | 本计划执行文档 |
不涉及:数据库迁移、API 路径、schema 显式模型、`MonitorPanel.vue`(如实时链路无需复用数据时不做改造)。
---
## 八、后续工作
- [ ] Phase 1-3 实施完成后按需更新 HANDOFF_性能测试.md
- [ ] Git 提交(Conventional Commits,需用户确认)
- [ ] 部署到 5.60 服务器(如用户要求)
---
*本文档由 prd-plan skill 生成,关联 PRD:`_PRD_Java服务多实例与MySQL容器趋势监控.md`*
\ No newline at end of file
...@@ -2699,30 +2699,48 @@ class PerformanceExecutor: ...@@ -2699,30 +2699,48 @@ class PerformanceExecutor:
entry["mysqlThreadsRunning"] = mysql.get("threadsRunning") entry["mysqlThreadsRunning"] = mysql.get("threadsRunning")
series.append(entry) series.append(entry)
# ====== Java 进程汇总 ====== # ====== Java 进程汇总(服务级聚合) ======
# 收集所有样本中出现的 Java 进程按 processName 组织 # 多实例口径:同一服务可能启动多个 PID,实时快照保留 PID 级明细;
java_process_map: dict = {} # processName -> {cpu[], mem[], rss[], data[{elapsed,...}]} # 报告汇总按“每个采样时间点 → 按 processName 聚合该时刻全部 PID”
# 生成服务级趋势点:CPU/内存/RSS 为组内 PID 求和,一个服务一个时间点仅一个点,
# 统计均值/最大按服务级时间点计算(不再把多个 PID 的观测行直接混合)。
# 某采样点该服务不可见时不补 0(可见样本口径,避免把 SSH/进程缺失误判为零资源)。
java_process_map: dict = {} # processName -> {cpu[], mem[], rss[], data[{elapsed,...}], pids:set}
for s in usable: for s in usable:
ts = s.get("timestamp", 0.0) ts = s.get("timestamp", 0.0)
elapsed = round(ts - first_ts, 1) if first_ts else 0.0 elapsed = round(ts - first_ts, 1) if first_ts else 0.0
# 先聚合当前采样点内同一服务的所有 PID
per_sample: dict = {} # processName -> {cpu, mem, rss, pids:set}
for proc in s.get("javaProcesses", []): for proc in s.get("javaProcesses", []):
pname = proc["processName"] pname = proc["processName"]
entry = per_sample.setdefault(pname, {"cpu": 0.0, "mem": 0.0, "rss": 0.0, "pids": set()})
entry["cpu"] += proc.get("cpuPercent", 0.0)
entry["mem"] += proc.get("memPercent", 0.0)
entry["rss"] += proc.get("rssMb", 0.0)
pid = proc.get("pid")
if pid is not None:
entry["pids"].add(pid)
for pname, entry in per_sample.items():
if pname not in java_process_map: if pname not in java_process_map:
java_process_map[pname] = { java_process_map[pname] = {
"cpu": [], "cpu": [],
"mem": [], "mem": [],
"rss": [], "rss": [],
"data": [], "data": [],
"pids": set(),
} }
java_process_map[pname]["cpu"].append(proc.get("cpuPercent", 0.0)) java_process_map[pname]["cpu"].append(round(entry["cpu"], 1))
java_process_map[pname]["mem"].append(proc.get("memPercent", 0.0)) java_process_map[pname]["mem"].append(round(entry["mem"], 1))
java_process_map[pname]["rss"].append(proc.get("rssMb", 0.0)) java_process_map[pname]["rss"].append(round(entry["rss"], 1))
java_process_map[pname]["data"].append({ java_process_map[pname]["data"].append({
"elapsed": elapsed, "elapsed": elapsed,
"cpuPercent": proc.get("cpuPercent", 0.0), "cpuPercent": round(entry["cpu"], 1),
"memPercent": proc.get("memPercent", 0.0), "memPercent": round(entry["mem"], 1),
"rssMb": proc.get("rssMb", 0.0), "rssMb": round(entry["rss"], 1),
"pidCount": len(entry["pids"]),
"pids": sorted(entry["pids"]),
}) })
java_process_map[pname]["pids"].update(entry["pids"])
java_process_stats = [] java_process_stats = []
java_process_series = [] java_process_series = []
...@@ -2730,8 +2748,11 @@ class PerformanceExecutor: ...@@ -2730,8 +2748,11 @@ class PerformanceExecutor:
cpu_v = vals["cpu"] cpu_v = vals["cpu"]
mem_v = vals["mem"] mem_v = vals["mem"]
rss_v = vals["rss"] rss_v = vals["rss"]
pid_list = sorted(vals["pids"])
java_process_stats.append({ java_process_stats.append({
"processName": pname, "processName": pname,
"pidCount": len(pid_list),
"pids": pid_list,
"avgCpuPercent": round(sum(cpu_v) / len(cpu_v), 1) if cpu_v else 0.0, "avgCpuPercent": round(sum(cpu_v) / len(cpu_v), 1) if cpu_v else 0.0,
"maxCpuPercent": round(max(cpu_v), 1) if cpu_v else 0.0, "maxCpuPercent": round(max(cpu_v), 1) if cpu_v else 0.0,
"avgMemPercent": round(sum(mem_v) / len(mem_v), 1) if mem_v else 0.0, "avgMemPercent": round(sum(mem_v) / len(mem_v), 1) if mem_v else 0.0,
...@@ -2741,6 +2762,8 @@ class PerformanceExecutor: ...@@ -2741,6 +2762,8 @@ class PerformanceExecutor:
}) })
java_process_series.append({ java_process_series.append({
"processName": pname, "processName": pname,
"pidCount": len(pid_list),
"pids": pid_list,
"data": vals["data"], "data": vals["data"],
}) })
...@@ -2759,6 +2782,30 @@ class PerformanceExecutor: ...@@ -2759,6 +2782,30 @@ class PerformanceExecutor:
"series": series, "series": series,
} }
if mysql_latest: if mysql_latest:
# 构建 MySQL 趋势序列:每个可用采样的 mysql 数据各生成一个趋势点。
# 累计指标(connections/questions/queries/bytes/慢查询)保留原始值,不计算平均。
mysql_series = []
for s in usable:
mysql = s.get("mysql", {})
if not mysql.get("available"):
continue
ts = s.get("timestamp", 0.0)
elapsed = round(ts - first_ts, 1) if first_ts else 0.0
mysql_series.append({
"elapsed": elapsed,
"threadsConnected": mysql.get("threadsConnected"),
"threadsRunning": mysql.get("threadsRunning"),
"maxUsedConnections": mysql.get("maxUsedConnections"),
"maxConnections": mysql.get("maxConnections"),
"connections": mysql.get("connections"),
"questions": mysql.get("questions"),
"queries": mysql.get("queries"),
"slowQueries": mysql.get("slowQueries"),
"bytesReceivedMb": mysql.get("bytesReceivedMb"),
"bytesSentMb": mysql.get("bytesSentMb"),
"bufferPoolHitRate": mysql.get("bufferPoolHitRate"),
"uptime": mysql.get("uptime"),
})
result["mysql"] = { result["mysql"] = {
"available": True, "available": True,
"container": mysql_latest.get("container", "umysql"), "container": mysql_latest.get("container", "umysql"),
...@@ -2775,6 +2822,7 @@ class PerformanceExecutor: ...@@ -2775,6 +2822,7 @@ class PerformanceExecutor:
"bytesSentMb": mysql_latest.get("bytesSentMb"), "bytesSentMb": mysql_latest.get("bytesSentMb"),
"bufferPoolHitRate": mysql_latest.get("bufferPoolHitRate"), "bufferPoolHitRate": mysql_latest.get("bufferPoolHitRate"),
"uptime": mysql_latest.get("uptime"), "uptime": mysql_latest.get("uptime"),
"series": mysql_series,
} }
if java_process_stats: if java_process_stats:
result["javaProcessStats"] = java_process_stats result["javaProcessStats"] = java_process_stats
......
...@@ -21,14 +21,18 @@ import paramiko ...@@ -21,14 +21,18 @@ import paramiko
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# SSH 单条采集命令(一次往返获取全部指标) # ssh 单条采集命令(一次往返获取全部指标)
# /proc/stat 首行 → CPU 总时间 / 空闲时间 # /proc/stat 首行 → CPU 总时间 / 空闲时间
# /proc/meminfo → MemTotal / MemAvailable # /proc/meminfo → MemTotal / MemAvailable
# /proc/loadavg → 1/5/15 分钟平均负载 # /proc/loadavg → 1/5/15 分钟平均负载
# ps 采集指定 Java 进程的 CPU/内存/RSS(%cpu 为进程平均占用,100% = 单核) # ps 采集指定 Java 进程的 CPU/内存/RSS
# -o %cpu=,%mem=,rss=,pid=,comm= 固定列宽输出,避免出现 '100' 与 '10' 混淆 # -o %cpu=,%mem=,rss=,pid=,comm= 固定列宽输出,避免出现 '100' 与 '10' 混淆
# pgrep -f 用法:pgrep -f ubains-meeting 会误匹配到包含该关键字的主机进程, # pgrep -f 用法:pgrep -f ubains-meeting 会误匹配到包含该关键字的主机进程,
# 改用 `ps -eo %cpu,%mem,rss,pid,comm,args | grep` 并精确过滤 java 进程。 # 改用 `ps -eo %cpu,%mem,rss,pid,comm,args | grep` 并精确过滤 java 进程。
# 注意 ps %cpu 是进程自启动以来的**平均**占用(压测中途刚重启的 JVM 会严重低估
# 瞬时 CPU,典型:top 显示 385% 而 ps %cpu 仅 1-3%),因此随后追加
# /proc/<pid>/stat 的两次瞬时采样(JSTAT1/JSTAT2,间隔 sleep _STAT_WINDOW),
# 用 utime+stime tick 差值计算 top 口径的瞬时 CPU%(100% = 单核,多线程可超 100%)。
_SSH_CMD = ( _SSH_CMD = (
"cat /proc/stat | head -1; " "cat /proc/stat | head -1; "
"echo '---MEM---'; " "echo '---MEM---'; "
...@@ -36,9 +40,20 @@ _SSH_CMD = ( ...@@ -36,9 +40,20 @@ _SSH_CMD = (
"echo '---LOAD---'; " "echo '---LOAD---'; "
"cat /proc/loadavg; " "cat /proc/loadavg; "
"echo '---JAVA---'; " "echo '---JAVA---'; "
"ps -eo %cpu=,%mem=,rss=,pid=,comm=,args= | grep -E 'java|jdk' | grep -v grep" "ps -eo %cpu=,%mem=,rss=,pid=,comm=,args= | grep -E 'java|jdk' | grep -v grep; "
"echo '---JSTAT1---'; "
"PIDS=$(ps -eo pid=,args= | grep -E 'java|jdk' | grep -v grep | awk '{print $1}'); "
"echo \"cores=$(grep -c '^processor' /proc/cpuinfo 2>/dev/null || nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 8)\"; "
"echo \"clk=$(getconf CLK_TCK 2>/dev/null || echo 100)\"; "
"for p in $PIDS; do echo \"$p $(sed -E 's/^[0-9]+ \\(.*\\) //' /proc/$p/stat 2>/dev/null | awk '{print $12 + $13}')\"; done; "
"sleep 0.2; "
"echo '---JSTAT2---'; "
"for p in $PIDS; do echo \"$p $(sed -E 's/^[0-9]+ \\(.*\\) //' /proc/$p/stat 2>/dev/null | awk '{print $12 + $13}')\"; done"
) )
# /proc/<pid>/stat 两次采样的间隔(秒),与 _SSH_CMD 中的 sleep 0.2 保持一致
_STAT_WINDOW = 0.2
# MySQL 容器采集命令(目标机 Docker 内的 umysql) # MySQL 容器采集命令(目标机 Docker 内的 umysql)
# 容器名 / 数据库:umysql(ubains 业务库,含登录/会议等被测服务依赖数据) # 容器名 / 数据库:umysql(ubains 业务库,含登录/会议等被测服务依赖数据)
# MySQL 连接:docker exec 容器内执行 mysql 客户端(root 密码见容器 MYSQL_ROOT_PASSWORD) # MySQL 连接:docker exec 容器内执行 mysql 客户端(root 密码见容器 MYSQL_ROOT_PASSWORD)
...@@ -58,8 +73,15 @@ _MYSQL_CMD = ( ...@@ -58,8 +73,15 @@ _MYSQL_CMD = (
"); SHOW VARIABLES LIKE 'max_connections';\" 2>/dev/null" "); SHOW VARIABLES LIKE 'max_connections';\" 2>/dev/null"
) )
# 匹配的 Java 进程关键字(命中任一即纳入监控;覆盖会议相关服务) # 匹配的 Java 进程关键字(命中任一即纳入监控)
# 注意顺序:_match_keyword 按列表顺序返回首个命中,具体服务名必须放在
# 泛化兜底(ubains-meeting)之前,否则会被子串匹配吞掉归并成同一名字
_JAVA_PROCESS_KEYWORDS = [ _JAVA_PROCESS_KEYWORDS = [
"ubains-gateway",
"ubains-auth",
"ubains-modules-system",
"ubains-meeting-message-scheduling",
"ubains-meeting-mqtt",
"ubains-meeting-inner-api", "ubains-meeting-inner-api",
"ubains-meeting-api", "ubains-meeting-api",
"ubains-meeting-quartz", "ubains-meeting-quartz",
...@@ -179,6 +201,9 @@ class TargetResourceMonitor: ...@@ -179,6 +201,9 @@ class TargetResourceMonitor:
"loadAvg1": 0.0, "loadAvg1": 0.0,
"loadAvg5": 0.0, "loadAvg5": 0.0,
"loadAvg15": 0.0, "loadAvg15": 0.0,
# 目标机 CPU 核数 / 时钟频率(tick/s),用于把进程 CPU% 换算成整机占比
"cpuCores": None,
"clkTck": None,
"javaProcesses": [], "javaProcesses": [],
# MySQL 指标(采样失败或不可用时为 0 / None) # MySQL 指标(采样失败或不可用时为 0 / None)
"mysql": { "mysql": {
...@@ -231,6 +256,7 @@ class TargetResourceMonitor: ...@@ -231,6 +256,7 @@ class TargetResourceMonitor:
cpu = self._parse_cpu(output) cpu = self._parse_cpu(output)
mem = self._parse_mem(output) mem = self._parse_mem(output)
load = self._parse_load(output) load = self._parse_load(output)
cores, clk = self._parse_jstat_meta(output)
java_processes = self._parse_java_processes(output) java_processes = self._parse_java_processes(output)
mysql = self._parse_mysql(output) mysql = self._parse_mysql(output)
...@@ -241,6 +267,8 @@ class TargetResourceMonitor: ...@@ -241,6 +267,8 @@ class TargetResourceMonitor:
result["loadAvg1"] = load[0] result["loadAvg1"] = load[0]
result["loadAvg5"] = load[1] result["loadAvg5"] = load[1]
result["loadAvg15"] = load[2] result["loadAvg15"] = load[2]
result["cpuCores"] = cores
result["clkTck"] = clk
result["javaProcesses"] = java_processes result["javaProcesses"] = java_processes
result["mysql"] = mysql result["mysql"] = mysql
result["available"] = True result["available"] = True
...@@ -473,6 +501,14 @@ class TargetResourceMonitor: ...@@ -473,6 +501,14 @@ class TargetResourceMonitor:
ps 列格式(固定列宽,--no-headers): ps 列格式(固定列宽,--no-headers):
%cpu %mem rss(KB) pid comm(命令名) args(完整命令行) %cpu %mem rss(KB) pid comm(命令名) args(完整命令行)
CPU 口径说明:
ps %cpu 是进程自启动以来的**平均**占用,压测期间刚重启的 JVM 会
严重低估瞬时 CPU(典型:top 显示 385%,ps %cpu 仅 1-3%)。
因此优先使用 ---JSTAT1---/---JSTAT2--- 段(/proc/<pid>/stat 的
utime+stime tick 差值,间隔 _STAT_WINDOW 秒)计算**瞬时** CPU%
口径与 top 一致:100% = 单核满载,多线程进程可超过 100%
stat 采样缺失(进程已退出 / 解析失败)时回退到 ps %cpu。
仅保留匹配 _JAVA_PROCESS_KEYWORDS 的进程;若多个 pid 命中同一关键字 仅保留匹配 _JAVA_PROCESS_KEYWORDS 的进程;若多个 pid 命中同一关键字
(如 spring cloud 多实例),各自独立返回一行。 (如 spring cloud 多实例),各自独立返回一行。
...@@ -484,22 +520,61 @@ class TargetResourceMonitor: ...@@ -484,22 +520,61 @@ class TargetResourceMonitor:
{ {
"processName": str, # 命中的服务关键字 "processName": str, # 命中的服务关键字
"pid": int, "pid": int,
"cpuPercent": float, # 100% = 单核满载 "cpuPercent": float, # 瞬时 CPU%(100% = 单核,可超 100%
"memPercent": float, # 相对系统总内存 % "memPercent": float, # 相对系统总内存 %
"rssMb": float, "rssMb": float,
}, ... }, ...
] ]
""" """
in_java = False pending: List[dict] = [] # JAVA 段解析出的 ps 行(CPU 值待定)
processes: List[dict] = [] stats1: dict = {} # pid -> JSTAT1 时刻 utime+stime tick 数
stats2: dict = {} # pid -> JSTAT2 时刻 utime+stime tick 数
cores: Optional[float] = None
clk: float = 100.0
cur_section: Optional[str] = None
for line in output.splitlines(): for line in output.splitlines():
if line.strip() == "---JAVA---": ls = line.strip()
in_java = True if ls == "---JAVA---":
cur_section = "java"
continue
if ls == "---JSTAT1---":
cur_section = "stat1"
continue continue
if not in_java or not line.strip(): if ls == "---JSTAT2---":
cur_section = "stat2"
continue
# 其它段落标记(---MYSQL--- 等)终止本解析
if ls.startswith("---") and ls.endswith("---"):
cur_section = None
continue
if not ls or cur_section is None:
continue continue
if cur_section in ("stat1", "stat2"):
if cur_section == "stat1":
if ls.startswith("cores="):
try:
cores = float(ls.split("=", 1)[1])
except ValueError:
pass
continue
if ls.startswith("clk="):
try:
clk = float(ls.split("=", 1)[1]) or 100.0
except ValueError:
pass
continue
parts = ls.split()
if len(parts) == 2:
try:
target = stats1 if cur_section == "stat1" else stats2
target[int(parts[0])] = float(parts[1])
except ValueError:
pass
continue
# cur_section == "java":解析 ps 进程行
parts = line.split(None, 5) parts = line.split(None, 5)
if len(parts) < 6: if len(parts) < 6:
continue continue
...@@ -507,7 +582,7 @@ class TargetResourceMonitor: ...@@ -507,7 +582,7 @@ class TargetResourceMonitor:
cpu_str, mem_str, rss_str, pid_str = parts[0], parts[1], parts[2], parts[3] cpu_str, mem_str, rss_str, pid_str = parts[0], parts[1], parts[2], parts[3]
comm = parts[4] comm = parts[4]
args = parts[5] args = parts[5]
cpu_val = float(cpu_str.strip()) ps_cpu = float(cpu_str.strip())
mem_val = float(mem_str.strip()) mem_val = float(mem_str.strip())
rss_kb = float(rss_str.strip()) rss_kb = float(rss_str.strip())
pid = int(pid_str.strip()) pid = int(pid_str.strip())
...@@ -525,16 +600,69 @@ class TargetResourceMonitor: ...@@ -525,16 +600,69 @@ class TargetResourceMonitor:
if not keyword: if not keyword:
continue continue
pending.append({
"psCpu": ps_cpu, "mem": mem_val, "rssKb": rss_kb,
"pid": pid, "keyword": keyword,
})
processes: List[dict] = []
for row in pending:
pid = row["pid"]
cpu_val = row["psCpu"]
t1 = stats1.get(pid)
t2 = stats2.get(pid)
if t1 is not None and t2 is not None and t2 > t1 and clk > 0:
# 瞬时 CPU%(top 口径):tick 差值 / CLK_TCK / 采样窗口 * 100
instant = (t2 - t1) / clk / _STAT_WINDOW * 100.0
instant = max(0.0, instant)
if cores:
# 上限 = 核数 * 100%(防时钟异常导致的离谱值)
instant = min(instant, cores * 100.0)
cpu_val = instant
processes.append({ processes.append({
"processName": keyword, "processName": row["keyword"],
"pid": pid, "pid": pid,
"cpuPercent": round(max(0.0, cpu_val), 1), "cpuPercent": round(max(0.0, cpu_val), 1),
"memPercent": round(max(0.0, mem_val), 1), "memPercent": round(max(0.0, row["mem"]), 1),
"rssMb": round(rss_kb / 1024.0, 1), "rssMb": round(row["rssKb"] / 1024.0, 1),
}) })
return processes return processes
def _parse_jstat_meta(self, output: str) -> Tuple[Optional[float], float]:
"""
从 JSTAT1 段解析目标机 CPU 核数与 CLK_TCK(tick/秒)
Args:
output: SSH 命令完整输出
Returns:
Tuple[Optional[float], float]: (cores, clk_tck),解析失败时 (None, 100.0)
"""
cores: Optional[float] = None
clk: float = 100.0
in_stat1 = False
for line in output.splitlines():
ls = line.strip()
if ls == "---JSTAT1---":
in_stat1 = True
continue
if in_stat1 and ls.startswith("---"):
break
if not in_stat1 or not ls:
continue
if ls.startswith("cores="):
try:
cores = float(ls.split("=", 1)[1])
except ValueError:
pass
elif ls.startswith("clk="):
try:
clk = float(ls.split("=", 1)[1]) or 100.0
except ValueError:
pass
return cores, clk
def _parse_mysql(self, output: str) -> dict: def _parse_mysql(self, output: str) -> dict:
""" """
从 SSH 输出中解析 MySQL 容器指标 从 SSH 输出中解析 MySQL 容器指标
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_target_resource_monitor.py
模块描述:测试目标机资源监控的 Java 进程瞬时 CPU 解析(JSTAT tick 差值口径)
覆盖:JSTAT 瞬时 CPU 计算、ps %cpu 回退、核数上限截断、_parse_jstat_meta、
段落边界/关键字/comm 过滤鲁棒性
背景:ps %cpu 是进程自启动以来的平均占用,压测中途刚重启的 JVM 会严重低估
瞬时 CPU(top 显示 385% 而 ps 仅 1-3%)。修复后 _parse_java_processes 优先用
---JSTAT1---/---JSTAT2--- 段(/proc/<pid>/stat 的 utime+stime tick 差值,间隔
_STAT_WINDOW=0.2s)计算 top 口径瞬时 CPU%:100% = 单核满载,多线程可超 100%
作者:czj
创建日期:2026-08-31
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.executors.target_resource_monitor import TargetResourceMonitor # noqa: E402
def _build_output(
java_lines=None,
stats1=None,
stats2=None,
cores="8",
clk="100",
mysql_tail=True,
java_marker=True,
stat_marker=True,
):
"""按 _SSH_CMD 实际输出结构拼装命令行文本(JAVA/JSTAT/MySQL 各段)。"""
lines = [
"cpu 1000 2000 3000 4000 5000 6000 7000 8000",
"---MEM---",
"MemTotal: 16000000 kB",
"MemAvailable: 8000000 kB",
"---LOAD---",
"1.23 0.89 0.67 3/456 12345",
]
if java_marker:
lines.append("---JAVA---")
lines.extend(java_lines or [])
if stat_marker:
lines.append("---JSTAT1---")
if cores is not None:
lines.append(f"cores={cores}")
if clk is not None:
lines.append(f"clk={clk}")
for pid, ticks in (stats1 or {}).items():
lines.append(f"{pid} {ticks}")
lines.append("---JSTAT2---")
for pid, ticks in (stats2 or {}).items():
lines.append(f"{pid} {ticks}")
if mysql_tail:
lines.append("---MYSQL---")
lines.append("Uptime\t3600")
return "\n".join(lines) + "\n"
def _java_line(cpu, mem, rss_kb, pid, comm="java", args="-jar ubains-meeting-api.jar"):
"""ps -eo %cpu=,%mem=,rss=,pid=,comm=,args= 输出行"""
return f"{cpu} {mem} {rss_kb} {pid} {comm} {args}"
def _monitor() -> TargetResourceMonitor:
return TargetResourceMonitor(host="unused-host") # __init__ 不建连接
class TestJstatInstantCpu:
"""JSTAT tick 差值 → top 口径瞬时 CPU%"""
def test_instant_cpu_from_tick_diff(self):
out = _build_output(
java_lines=[_java_line(1.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 1060},
)
procs = _monitor()._parse_java_processes(out)
p = procs[0]
# delta=60 ticks → 60/clk(100)/0.2s*100 = 300.0(8 核可超 100%)
assert p["cpuPercent"] == 300.0
assert p["processName"] == "ubains-meeting-api"
assert p["pid"] == 101
assert p["memPercent"] == 10.0
assert p["rssMb"] == 488.3 # 500000KB / 1024
def test_multiple_pids_independent(self):
out = _build_output(
java_lines=[
_java_line(1.0, 10.0, 500000, 101, args="-jar ubains-meeting-api.jar"),
_java_line(1.0, 5.0, 300000, 102, args="-jar ubains-meeting-inner-api.jar"),
],
stats1={101: 1000, 102: 500},
stats2={101: 1060, 102: 540},
)
procs = {p["pid"]: p for p in _monitor()._parse_java_processes(out)}
assert procs[101]["cpuPercent"] == 300.0 # delta 60
assert procs[102]["cpuPercent"] == 200.0 # delta 40
assert procs[102]["processName"] == "ubains-meeting-inner-api"
def test_fallback_to_ps_when_no_jstat(self):
out = _build_output(
java_lines=[_java_line(12.5, 10.0, 500000, 101)],
stats1={},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 12.5
def test_fallback_when_one_snapshot_missing(self):
"""JSTAT2 缺失(进程退出/解析失败)→ 回退 ps %cpu"""
out = _build_output(
java_lines=[_java_line(3.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 3.0
def test_cap_at_cores_times_100(self):
out = _build_output(
java_lines=[_java_line(1.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 3000}, # delta=2000 → 5000%,被 8 核截断
cores="8",
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 800.0
def test_no_cap_without_cores(self):
"""未解析出核数(cores=None)→ 不截断,容忍瞬时高值"""
out = _build_output(
java_lines=[_java_line(1.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 1060},
cores=None,
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 300.0
def test_zero_or_negative_diff_falls_back_to_ps(self):
out = _build_output(
java_lines=[_java_line(2.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 1000}, # tick 未推进
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 2.0
def test_rounds_to_one_decimal(self):
"""delta=61 → 305.0;delta=1 → 5.0(需为整数 tick 才精确)"""
out = _build_output(
java_lines=[_java_line(1.0, 10.0, 500000, 101)],
stats1={101: 1000},
stats2={101: 1061},
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["cpuPercent"] == 305.0
class TestJstatMeta:
"""_parse_jstat_meta 解析核数/CLK_TCK"""
def test_parse_cores_and_clk(self):
out = _build_output(stats1={}, stats2={})
cores, clk = _monitor()._parse_jstat_meta(out)
assert cores == 8.0
assert clk == 100.0
def test_missing_meta_defaults(self):
out = _build_output(stats1={}, stats2={}, cores=None, clk=None)
cores, clk = _monitor()._parse_jstat_meta(out)
assert cores is None
assert clk == 100.0
def test_custom_cores_and_clk(self):
out = _build_output(stats1={}, stats2={}, cores="16", clk="250")
cores, clk = _monitor()._parse_jstat_meta(out)
assert cores == 16.0
assert clk == 250.0
class TestSectionRobustness:
"""段落边界 / 关键字 / comm 过滤"""
def test_mysql_tail_does_not_feed_java(self):
out = _build_output(
java_lines=[_java_line(5.0, 10.0, 500000, 101)],
stats1={},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert len(procs) == 1
def test_keyword_and_comm_filter(self):
out = _build_output(
java_lines=[
_java_line(5.0, 10.0, 500000, 101, args="-jar ubains-meeting-api.jar"),
_java_line(6.0, 11.0, 600000, 102, args="-jar some-other-app.jar"),
_java_line(7.0, 12.0, 700000, 103, comm="nginx",
args="nginx -c /etc/nginx/nginx.conf"),
],
stats1={},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert [p["pid"] for p in procs] == [101]
def test_generic_keyword_after_specific(self):
"""ubains-gateway 具体关键字优先于 ubains-meeting 泛化兜底"""
out = _build_output(
java_lines=[_java_line(5.0, 10.0, 500000, 101,
args="-jar ubains-gateway.jar --spring.profiles=meeting")],
stats1={},
stats2={},
)
procs = _monitor()._parse_java_processes(out)
assert procs[0]["processName"] == "ubains-gateway"
def test_empty_output(self):
assert _monitor()._parse_java_processes("") == []
assert _monitor()._parse_java_processes("some random text\nno markers\n") == []
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_target_resource_summary.py
模块描述:测试目标机资源汇总的多 PID 服务级聚合与 MySQL 趋势序列
覆盖指标:多实例求和口径、服务级统计、pidCount/pids 字段、mysql.series 生成、旧格式兼容
作者:czj
创建日期:2026-08-31
"""
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.executors.performance_executor import PerformanceExecutor # noqa: E402
def _make_executor_with_samples(samples):
"""绕过 __init__(避免拉起线程/依赖)构造执行器,注入目标机采样序列。"""
executor = PerformanceExecutor.__new__(PerformanceExecutor)
executor._target_resource_samples = list(samples)
executor._target_resource_samples_lock = __import__("threading").Lock()
return executor
def _mock_executor():
"""构造带两个可用采样点(间隔 2s)的执行器:
- t0:ubains-meeting-api 两个 PID(CPU 30+40,rss 500+600),ubains-auth 单 PID(CPU 15)
- t2:ubains-meeting-api 仅 PID101 可见(CPU 45,rss 520),MySQL 不可用
t0/t2 均采集 MySQL(连接/查询为累计原始值)。
"""
t0 = time.time()
t1 = t0 + 2.0
base_mysql = {
"available": True,
"container": "umysql",
"threadsConnected": 5,
"threadsRunning": 2,
"maxUsedConnections": 5,
"maxConnections": 151,
"connections": 100,
"questions": 1000,
"queries": 500,
"slowQueries": 3,
"bytesReceivedMb": 10.0,
"bytesSentMb": 20.0,
"bufferPoolHitRate": 99.5,
"uptime": 3600,
}
s0 = {
"timestamp": t0,
"available": True,
"cpuPercent": 50.0,
"memPercent": 60.0,
"loadAvg1": 1.0,
"javaProcesses": [
{"processName": "ubains-meeting-api", "pid": 101,
"cpuPercent": 30.0, "memPercent": 10.0, "rssMb": 500.0},
{"processName": "ubains-meeting-api", "pid": 102,
"cpuPercent": 40.0, "memPercent": 12.0, "rssMb": 600.0},
{"processName": "ubains-auth", "pid": 201,
"cpuPercent": 15.0, "memPercent": 5.0, "rssMb": 300.0},
],
"mysql": dict(base_mysql, threadsConnected=5, connections=100, questions=1000),
}
s1 = {
"timestamp": t1,
"available": True,
"cpuPercent": 40.0,
"memPercent": 55.0,
"loadAvg1": 0.8,
"javaProcesses": [
{"processName": "ubains-meeting-api", "pid": 101,
"cpuPercent": 45.0, "memPercent": 9.0, "rssMb": 520.0},
{"processName": "ubains-auth", "pid": 201,
"cpuPercent": 20.0, "memPercent": 6.0, "rssMb": 310.0},
],
"mysql": dict(base_mysql, threadsConnected=8, connections=120, questions=1500),
}
return _make_executor_with_samples([s0, s1])
class TestJavaMultiPidAggregation:
"""同服务多 PID 按采样时间点聚合成服务级单点(求和口径)"""
def test_two_pid_sums_to_one_point(self):
summary = _mock_executor()._build_target_resource_summary()
api_series = next(s for s in summary["javaProcessSeries"]
if s["processName"] == "ubains-meeting-api")
# 两个采样点 → 两个服务级点(而非 3 个 PID 观测行)
assert len(api_series["data"]) == 2
# 第一个点:PID101(30) + PID102(40) = 70,rss 500+600=1100
p0 = api_series["data"][0]
assert p0["cpuPercent"] == 70.0
assert p0["rssMb"] == 1100.0
# 第二个点:仅 PID101 可见 → 45(可见样本口径,不补 0)
p1 = api_series["data"][1]
assert p1["cpuPercent"] == 45.0
assert p1["pidCount"] == 1
assert p1["pids"] == [101]
def test_pid_count_and_list(self):
summary = _mock_executor()._build_target_resource_summary()
api_stats = next(s for s in summary["javaProcessStats"]
if s["processName"] == "ubains-meeting-api")
assert api_stats["pidCount"] == 2
assert api_stats["pids"] == [101, 102]
def test_stats_service_level_averaging(self):
summary = _mock_executor()._build_target_resource_summary()
api_stats = next(s for s in summary["javaProcessStats"]
if s["processName"] == "ubains-meeting-api")
# 服务级时间点复数 [70, 45] → avg=(70+45)/2=57.5, max=70
assert api_stats["avgCpuPercent"] == 57.5
assert api_stats["maxCpuPercent"] == 70.0
# 单 PID 服务不受影响:auth 两个点 [15, 20] → avg 17.5
auth_stats = next(s for s in summary["javaProcessStats"]
if s["processName"] == "ubains-auth")
assert auth_stats["avgCpuPercent"] == 17.5
assert auth_stats["pidCount"] == 1
assert auth_stats["pids"] == [201]
class TestMysqlSeries:
"""MySQL 趋势序列生成与最新卡片字段保留"""
def test_series_points_and_fields(self):
summary = _mock_executor()._build_target_resource_summary()
mysql = summary["mysql"]
series = mysql["series"]
assert len(series) == 2
assert series[0]["elapsed"] == 0.0
assert series[1]["elapsed"] == 2.0
# 累计值保留原始值(不做平均)
assert series[0]["connections"] == 100
assert series[1]["connections"] == 120
assert series[0]["questions"] == 1000
assert series[1]["questions"] == 1500
# 即时量随采样变化
assert series[0]["threadsConnected"] == 5
assert series[1]["threadsConnected"] == 8
# 最新卡片字段保持不变
assert mysql["available"] is True
assert mysql["container"] == "umysql"
assert mysql["threadsConnected"] == 8
assert mysql["connections"] == 120
class TestCompatFallback:
"""旧格式/无数据场景兼容"""
def test_no_samples_returns_none(self):
summary = _make_executor_with_samples([])._build_target_resource_summary()
assert summary is None
def test_unavailable_only_returns_none(self):
summary = _make_executor_with_samples([
{"timestamp": time.time(), "available": False,
"javaProcesses": [], "mysql": {"available": False}},
])._build_target_resource_summary()
assert summary is None
def test_no_mysql_sample_has_no_series(self):
s = {
"timestamp": time.time(),
"available": True,
"cpuPercent": 10.0,
"memPercent": 10.0,
"loadAvg1": 0.1,
"javaProcesses": [],
"mysql": {"available": False},
}
summary = _make_executor_with_samples([s])._build_target_resource_summary()
assert "mysql" not in summary # 顶部字段不生成 → 前端沿用降级卡片逻辑
...@@ -156,9 +156,13 @@ export interface JavaProcessSample { ...@@ -156,9 +156,13 @@ export interface JavaProcessSample {
rssMb: number rssMb: number
} }
/** 目标机 Java 进程统计汇总 */ /** 目标机 Java 进程统计汇总(服务级聚合:CPU/内存/RSS 为同服务全部 PID 求和) */
export interface JavaProcessStats { export interface JavaProcessStats {
processName: string processName: string
/** 服务实例(PID)数量 */
pidCount?: number
/** 服务实例 PID 列表(去重) */
pids?: number[]
avgCpuPercent: number avgCpuPercent: number
maxCpuPercent: number maxCpuPercent: number
avgMemPercent: number avgMemPercent: number
...@@ -167,15 +171,26 @@ export interface JavaProcessStats { ...@@ -167,15 +171,26 @@ export interface JavaProcessStats {
maxRssMb: number maxRssMb: number
} }
/** 目标机 Java 进程服务级趋势点(CPU/内存/RSS 为同服务全部 PID 求和) */
export interface JavaProcessSeriesPoint {
elapsed: number
cpuPercent: number
memPercent: number
rssMb: number
/** 该时间点可见实例数 */
pidCount?: number
/** 该时间点可见实例 PID 列表 */
pids?: number[]
}
/** 目标机 Java 进程趋势序列 */ /** 目标机 Java 进程趋势序列 */
export interface JavaProcessSeries { export interface JavaProcessSeries {
processName: string processName: string
data: Array<{ /** 服务实例(PID)数量 */
elapsed: number pidCount?: number
cpuPercent: number /** 服务实例 PID 列表(去重) */
memPercent: number pids?: number[]
rssMb: number data: JavaProcessSeriesPoint[]
}>
} }
/** 目标机资源监控单次采样数据 */ /** 目标机资源监控单次采样数据 */
...@@ -221,6 +236,23 @@ export interface TargetResourceSeriesPoint { ...@@ -221,6 +236,23 @@ export interface TargetResourceSeriesPoint {
loadAvg1: number loadAvg1: number
} }
/** 目标机 MySQL 指标趋势点(累计值保留原始计数,不计算平均) */
export interface MysqlSeriesPoint {
elapsed: number
threadsConnected?: number
threadsRunning?: number
maxUsedConnections?: number
maxConnections?: number
connections?: number
questions?: number
queries?: number
slowQueries?: number
bytesReceivedMb?: number
bytesSentMb?: number
bufferPoolHitRate?: number
uptime?: number
}
/** 目标机资源监控汇总 */ /** 目标机资源监控汇总 */
export interface TargetResourceSummary { export interface TargetResourceSummary {
host: string host: string
...@@ -255,6 +287,8 @@ export interface TargetResourceSummary { ...@@ -255,6 +287,8 @@ export interface TargetResourceSummary {
queries: number queries: number
bytesReceivedMb: number bytesReceivedMb: number
bytesSentMb: number bytesSentMb: number
/** MySQL 指标趋势序列(可选,旧报告无此字段) */
series?: MysqlSeriesPoint[]
} | null } | null
} }
......
...@@ -579,6 +579,11 @@ ...@@ -579,6 +579,11 @@
</template> </template>
<el-table :data="report.targetResourceSummary.javaProcessStats" size="small" stripe border> <el-table :data="report.targetResourceSummary.javaProcessStats" size="small" stripe border>
<el-table-column prop="processName" label="服务 Process" min-width="220" show-overflow-tooltip /> <el-table-column prop="processName" label="服务 Process" min-width="220" show-overflow-tooltip />
<el-table-column label="实例 PID" width="150" show-overflow-tooltip>
<template #default="{ row }">
{{ row.pidCount ?? (row.pids?.length || 0) }} {{ row.pids?.length ? `(${row.pids.join(', ')})` : '' }}
</template>
</el-table-column>
<el-table-column label="CPU 平均 Avg" width="120"> <el-table-column label="CPU 平均 Avg" width="120">
<template #default="{ row }"> <template #default="{ row }">
<span :style="{ color: row.avgCpuPercent > 80 ? '#f56c6c' : '#409eff', fontWeight: 600 }"> <span :style="{ color: row.avgCpuPercent > 80 ? '#f56c6c' : '#409eff', fontWeight: 600 }">
...@@ -709,6 +714,25 @@ ...@@ -709,6 +714,25 @@
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<!-- MySQL 指标趋势图(仅当存在趋势序列时展示,旧报告无 series 时自动隐藏) -->
<el-row
v-if="report.targetResourceSummary.mysql.series && report.targetResourceSummary.mysql.series.length"
:gutter="12"
>
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>MySQL 状态趋势 MySQL Status(连接 / 活跃线程 / 慢查询 / 命中率)</span></template>
<div ref="mysqlStatusChartRef" class="chart" />
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="chart-card">
<template #header><span>MySQL 累计与流量趋势 MySQL Cumulative(连接 / 查询 / 流量)</span></template>
<div ref="mysqlCumulativeChartRef" class="chart" />
</el-card>
</el-col>
</el-row>
</template> </template>
<!-- 事务处理时间明细(仅事务任务) --> <!-- 事务处理时间明细(仅事务任务) -->
...@@ -958,6 +982,8 @@ function onTabChange(tab: string) { ...@@ -958,6 +982,8 @@ function onTabChange(tab: string) {
networkChart?.resize() networkChart?.resize()
targetResourceChart?.resize() targetResourceChart?.resize()
javaProcessChart?.resize() javaProcessChart?.resize()
mysqlStatusChart?.resize()
mysqlCumulativeChart?.resize()
waterfallChart?.resize() waterfallChart?.resize()
}) })
} }
...@@ -974,6 +1000,10 @@ const networkChartRef = ref<HTMLElement>() ...@@ -974,6 +1000,10 @@ const networkChartRef = ref<HTMLElement>()
const targetResourceChartRef = ref<HTMLElement>() const targetResourceChartRef = ref<HTMLElement>()
const javaProcessChartRef = ref<HTMLElement>() const javaProcessChartRef = ref<HTMLElement>()
// MySQL 趋势图引用
const mysqlStatusChartRef = ref<HTMLElement>()
const mysqlCumulativeChartRef = ref<HTMLElement>()
// 瀑布图引用 // 瀑布图引用
const waterfallChartRef = ref<HTMLElement>() const waterfallChartRef = ref<HTMLElement>()
...@@ -985,6 +1015,8 @@ let resourceChart: echarts.ECharts | null = null ...@@ -985,6 +1015,8 @@ let resourceChart: echarts.ECharts | null = null
let networkChart: echarts.ECharts | null = null let networkChart: echarts.ECharts | null = null
let targetResourceChart: echarts.ECharts | null = null let targetResourceChart: echarts.ECharts | null = null
let javaProcessChart: echarts.ECharts | null = null let javaProcessChart: echarts.ECharts | null = null
let mysqlStatusChart: echarts.ECharts | null = null
let mysqlCumulativeChart: echarts.ECharts | null = null
let waterfallChart: echarts.ECharts | null = null let waterfallChart: echarts.ECharts | null = null
/** 分位数表格行(含 P95) */ /** 分位数表格行(含 P95) */
...@@ -1486,6 +1518,12 @@ function initCharts() { ...@@ -1486,6 +1518,12 @@ function initCharts() {
if (javaProcessChartRef.value && !javaProcessChart) { if (javaProcessChartRef.value && !javaProcessChart) {
javaProcessChart = echarts.init(javaProcessChartRef.value) javaProcessChart = echarts.init(javaProcessChartRef.value)
} }
if (mysqlStatusChartRef.value && !mysqlStatusChart) {
mysqlStatusChart = echarts.init(mysqlStatusChartRef.value)
}
if (mysqlCumulativeChartRef.value && !mysqlCumulativeChart) {
mysqlCumulativeChart = echarts.init(mysqlCumulativeChartRef.value)
}
if (waterfallChartRef.value && !waterfallChart) { if (waterfallChartRef.value && !waterfallChart) {
waterfallChart = echarts.init(waterfallChartRef.value) waterfallChart = echarts.init(waterfallChartRef.value)
} }
...@@ -1659,11 +1697,62 @@ function updateCharts() { ...@@ -1659,11 +1697,62 @@ function updateCharts() {
legend: { data: jps.map((p: any) => p.processName), top: 0, itemWidth: 12, itemHeight: 8 }, legend: { data: jps.map((p: any) => p.processName), top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 }, grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: jLabels, axisLabel: { fontSize: 11 } }, xAxis: { type: 'category', data: jLabels, axisLabel: { fontSize: 11 } },
yAxis: { type: 'value', name: 'CPU %', max: 100 }, yAxis: {
type: 'value',
name: 'CPU %',
// 动态上限:多核 Java 进程可超 100%,按数据最大值取整到 10 的倍数 + 15% 余量,保底 100
max: (value: any) => Math.max(100, Math.ceil((value.max * 1.15) / 10) * 10),
},
series: jSeries, series: jSeries,
}, true) }, true)
} }
// MySQL 状态趋势图(连接 / 活跃线程 / 慢查询 / 缓冲池命中率)
const mts = report.value.targetResourceSummary?.mysql?.series
if (mts && mts.length > 0) {
const mLabels = mts.map((s: any) => `${s.elapsed.toFixed(0)}s`)
const mLegend = ['连接数 Threads Connected', '活跃线程 Threads Running', '慢查询 Slow Queries', '缓冲池命中率 Buffer Hit Rate %']
if (mysqlStatusChart) {
mysqlStatusChart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: mLegend, top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: mLabels, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '连接 / 查询' },
{ type: 'value', name: '命中率 %', splitLine: { show: false }, max: 100 },
],
series: [
{ name: '连接数 Threads Connected', type: 'line', data: mts.map((s: any) => s.threadsConnected), smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#409eff' } },
{ name: '活跃线程 Threads Running', type: 'line', data: mts.map((s: any) => s.threadsRunning), smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#67c23a' } },
{ name: '慢查询 Slow Queries', type: 'line', data: mts.map((s: any) => s.slowQueries), smooth: true, showSymbol: false, lineStyle: { width: 1.2, type: 'dashed', color: '#e6a23c' } },
{ name: '缓冲池命中率 Buffer Hit Rate %', type: 'line', data: mts.map((s: any) => s.bufferPoolHitRate), smooth: true, showSymbol: false, yAxisIndex: 1, lineStyle: { width: 1.5, color: '#f56c6c' } },
],
}, true)
}
// MySQL 累计与流量趋势图(累计连接 / 查询 / 收发流量,累计值保留原始计数)
const mLegend2 = ['累计连接 Connections', '累计查询 Queries', '接收 Received(MB)', '发送 Sent(MB)']
if (mysqlCumulativeChart) {
mysqlCumulativeChart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: mLegend2, top: 0, itemWidth: 12, itemHeight: 8 },
grid: { left: 50, right: 20, bottom: 30, top: 30 },
xAxis: { type: 'category', data: mLabels, axisLabel: { fontSize: 11 } },
yAxis: [
{ type: 'value', name: '累计' },
{ type: 'value', name: 'MB', splitLine: { show: false } },
],
series: [
{ name: '累计连接 Connections', type: 'line', data: mts.map((s: any) => s.connections), smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#409eff' } },
{ name: '累计查询 Queries', type: 'line', data: mts.map((s: any) => s.queries), smooth: true, showSymbol: false, lineStyle: { width: 1.5, color: '#67c23a' } },
{ name: '接收 Received(MB)', type: 'line', data: mts.map((s: any) => s.bytesReceivedMb), smooth: true, showSymbol: false, yAxisIndex: 1, lineStyle: { width: 1.5, type: 'dashed', color: '#e6a23c' } },
{ name: '发送 Sent(MB)', type: 'line', data: mts.map((s: any) => s.bytesSentMb), smooth: true, showSymbol: false, yAxisIndex: 1, lineStyle: { width: 1.5, type: 'dashed', color: '#f56c6c' } },
],
}, true)
}
}
// 瀑布图(事务步骤耗时) // 瀑布图(事务步骤耗时)
if (waterfallChart && report.value.transactionSummary?.stepSummaries?.length) { if (waterfallChart && report.value.transactionSummary?.stepSummaries?.length) {
const steps = report.value.transactionSummary.stepSummaries const steps = report.value.transactionSummary.stepSummaries
...@@ -1737,6 +1826,8 @@ onUnmounted(() => { ...@@ -1737,6 +1826,8 @@ onUnmounted(() => {
networkChart?.dispose() networkChart?.dispose()
targetResourceChart?.dispose() targetResourceChart?.dispose()
javaProcessChart?.dispose() javaProcessChart?.dispose()
mysqlStatusChart?.dispose()
mysqlCumulativeChart?.dispose()
waterfallChart?.dispose() waterfallChart?.dispose()
// 离开报告页时释放 AI 文档 ObjectURL,避免内存泄漏 // 离开报告页时释放 AI 文档 ObjectURL,避免内存泄漏
downloadArtifactStore.close() downloadArtifactStore.close()
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论