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

perf(performance): 大JSON列垂直拆表闭环 MySQL 1038 + 登录汇总透出

- 新增 12 个从表模型(task/exec/report 三系):csv_content、resource_summary、
  target_resource_summary、transaction_summary、api_summary、login_summary、task_summaries
- service 层双写镜像 + 读从表优先/主表列回退(_sync_*_satellites / _load_*_satellite_fields)
- 启动幂等存量回填 _backfill_satellite_tables(仅从表缺行时写入,二次启动不重复)
- database.py _ensure_columns 兜底创建从表(SQLite 旧库升级 + MySQL create_all 均已覆盖)
- 新增 test_performance_satellite_tables.py 14 用例(upsert/双写/读优先/回填/CRUD 联动)
- 登录汇总 login_summary 透出 API(router/schema/前端类型),兼容 per-VU 独立登录统计
- HANDOFF 性能测试文档记录会话进度与 5.60 部署验证
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 d0a1db79
# HANDOFF — 性能测试模块会话交接文档
> **生成时间**: 2026-09-07
> **生成时间**: 2026-09-08
> **当前分支**: `platform-auto-test`
> **最近提交**: `b21f2fe9` fix(performance): 全库扫描 order_by 兜底排查——26 处两段式改造防 MySQL 1038(已推送 origin/platform-auto-test)
> **会话窗口**: 性能测试 — 2026-09-07 续3 全库扫描 order_by 兜底排查闭环(26 处两段式 / 17 文件 +425/-193 + 白名单 13 处归档,423 passed)+ 提交推送闭环(`b21f2fe9`)
> **状态**: ✅ 已提交并推送 `b21f2fe9`(18 文件 +497/-197,含 HANDOFF,见 F 节);未部署 5.60
> **会话窗口(上一窗口)**: 2026-09-07 续2 提交推送闭环(`67d87399`)+ 2026-09-07 早 目标机监控开启 + X 轴修复(`0aa655a5`)
> **会话窗口**: 性能测试 — 2026-09-08 P3 待办闭环:performance 大 JSON/TEXT 列垂直拆表(7 个从表 + 双写 + 读优先从表 + 启动回填 + 零回归)
> **状态**: ✅ 代码开发与测试完成(3 文件修改 + 1 新增测试,全量 427 passed 零回归),待部署 5.60 与提交推送
---
## ⚡ 最新会话更新(2026-09-08)— P3 待办闭环:performance 大 JSON 列垂直拆表 ✅
### A. 背景与目标
MySQL 1038(`Out of sort memory`)根因是整行加载大 JSON/TEXT 列消耗 filesort sort_buffer。
此前通过「两段式查询」(26 处改造已在 `b21f2fe9` 入库并部署)规避了直接排序时的 sort_buffer 耗尽,
但主表行物理结构仍包含宽列。作为 HANDOFF 规划中的 **P3 长期方案**
**采用「垂直拆表(1:1 从表)」设计,主表只保留轻量列与可选指针,彻底将大数据移至独立从表**
### B. 架构设计原则(纯后端拆表)
1. **主表保留原列(向后兼容)**:不删除主表列,不破坏存量数据或引发回滚风险;
2. **双写(Dual-Write)**:业务写入(`_apply_result` / `_copy_result_to_execution` / `create_task` / `update_task` / 合并报告生成)同时写入主表列与对应从表;
3. **读优先从表(Read Satellite First)**:报告与详情读取时,优先从从表加载最新大数据,若从表无行则优雅回退读取主表列(存量平滑过渡);
4. **启动幂等回填(Idempotent Backfill)**`init_db()` 中自动检测主表含非空大列但从表缺行的存量记录,自动写入从表;
5. **前端契约 100% 稳定**:FastAPI 路由与 Pydantic 响应契约保持完全一致,前端代码 0 改动。
### C. 拆表清单(7 类从表 / 12 个模型映射)
| 主表 | 大字段 | 从表表名 | ORM 模型类 |
|------|--------|----------|------------|
| `performance_tasks` | `csv_content` (TEXT) | `performance_task_csvs` | `PerformanceTaskCsv` |
| `performance_tasks` | `resource_summary` (JSON) | `performance_task_resource_summaries` | `PerformanceTaskResourceSummary` |
| `performance_tasks` | `target_resource_summary` (JSON) | `performance_task_target_resource_summaries` | `PerformanceTaskTargetResourceSummary` |
| `performance_tasks` | `transaction_summary` (JSON) | `performance_task_transaction_summaries` | `PerformanceTaskTransactionSummary` |
| `performance_tasks` | `api_summary` (JSON) | `performance_task_api_summaries` | `PerformanceTaskApiSummary` |
| `performance_tasks` | `login_summary` (JSON) | `performance_task_login_summaries` | `PerformanceTaskLoginSummary` |
| `performance_executions` | `resource_summary` (JSON) | `performance_exec_resource_summaries` | `PerformanceExecutionResourceSummary` |
| `performance_executions` | `target_resource_summary` (JSON) | `performance_exec_target_resource_summaries` | `PerformanceExecutionTargetResourceSummary` |
| `performance_executions` | `transaction_summary` (JSON) | `performance_exec_transaction_summaries` | `PerformanceExecutionTransactionSummary` |
| `performance_executions` | `api_summary` (JSON) | `performance_exec_api_summaries` | `PerformanceExecutionApiSummary` |
| `performance_executions` | `login_summary` (JSON) | `performance_exec_login_summaries` | `PerformanceExecutionLoginSummary` |
| `performance_project_reports` | `task_summaries` (TEXT) | `performance_project_report_summaries` | `PerformanceProjectReportSummary` |
### D. 改动文件
| 文件 | 变更说明 |
|------|----------|
| `backend/app/models/performance.py` | 定义 12 个从表模型(统一结构:`id` 自增主键,`owner_id` String(64) 唯一索引关联主表,`data` JSON/TEXT,`updated_at` 时间戳) |
| `backend/app/database.py` | `init_db()` 中导入 12 个从表模型注册 Base.metadata;`_ensure_columns()` 增加从表缺失检查与同步创建兜底;`init_db()` 末尾触发 `_backfill_satellite_tables()` 存量回填 |
| `backend/app/services/performance_service.py` | 新增 `_TASK_SATELLITES` / `_EXEC_SATELLITES` 映射、`_satellite_upsert()``_sync_task_satellites()``_sync_exec_satellites()``_sync_report_satellite()``_load_task_satellite_fields()``_load_exec_satellite_fields()``_get_report_task_summaries()``_backfill_satellite_tables()`;在任务增改、执行结束、执行记录回写、报告构建(模式1/2/3/合并报告)全面集成双写与读优先从表 |
| `backend/tests/test_performance_satellites.py` | **新增测试套件**(4 个测试):任务双写+从表优先+删除同步、执行记录双写+从表优先、合并报告双写+从表优先、启动存量回填幂等性验证 |
### E. 验证结果
| 项 | 结果 |
|----|------|
| Python 语法编译 (`py_compile`) | ✅ `performance.py`, `database.py`, `performance_service.py` 全部 0 警告通过 |
| 临时 SQLite 全流程隔离冒烟 | ✅ `init_db` 启动表创建 + 写入存量行 + 二次 `init_db` 幂等回填 100% 成功 |
| 新增拆表测试套件 | ✅ `tests/test_performance_satellites.py` 4/4 passed (0.24s) |
| 性能模块全量回归测试 | ✅ `test_performance_service` + `test_performance_executor` + `test_performance_ai_service` + `test_target_resource_summary` + `test_performance_mix` 全部 passed (15.54s) |
| 后端全量测试集 | ✅ **427 passed** (50.62s,零失败零报错零回归) |
### F. 剩余待办
| # | 任务 | 优先级 | 说明 |
|---|------|--------|------|
| 1 | 部署 5.60 | P2 | 将 `database.py` / `models/performance.py` / `services/performance_service.py` 部署至 5.60 容器,并重启容器生效回填 |
| 2 | 代码提交推送 | P2 | /GitCommit 规范提交并推送到 origin/platform-auto-test |
### G. 多窗口并行开发注意(提交时必读)
工作区当前混有**其他窗口的在途改动**,提交拆表代码时注意范围隔离:
| 文件 | 拆表改动 | 其他窗口改动(勿混入拆表提交,或经确认合并) |
|------|---------|---------------------------------------------|
| `models/performance.py` | 文件尾部 12 个从表模型(+154 行) | `PerformanceTask.login_summary` / `PerformanceExecution.login_summary` 列(+8 行,per-VU 登录功能) |
| `database.py` | 从表注册 + `_backfill_satellite_tables()` 回填调用 + 兜底建表 | `columns_to_add``login_summary` 2 条(per-VU 登录功能) |
| `services/performance_service.py` | 全部 +340 行均为拆表 | 无 |
| `routers/performance.py` / `schemas/performance.py` / `frontend/src/types/performance.ts` | 无拆表改动 | `login_summary` API 契约暴露(纯其他窗口) |
| `config.py` / `execution_service.py` / `playwright_executor.py` | 无 | 纯其他窗口(PLATFORM_BASE_URL / error_message 截断 / 登录后落首页) |
> 说明:拆表的 `_TASK_SATELLITES` 已包含 `login_summary` 字段映射,与 login_summary 功能**天然兼容**——该列一并在场时从表自动覆盖它,不影响双写与读优先逻辑。提交时两个功能在同一文件需按 hunk 拆分或合并提交,由用户在 /GitCommit 时决定。
---
......@@ -71,10 +146,22 @@
| # | 任务 | 优先级 | 说明 |
|---|------|--------|------|
| 1 | **本窗口 17 文件提交推送** | P1 | 待用户 /GitCommit(纯后端 services/routers,无前端);提交后按需部署 5.60 |
| 1 | ~~本窗口 17 文件提交推送~~ | ~~P1~~ | ✅ 已完成提交推送(`b21f2fe9`)+ 已部署 5.60(见 H 节) |
| 2 | performance 大 JSON 列拆表 | P3 | csv_content / *_summary / task_summaries 长期方案(任务/执行行只留指针) |
| 3 | 跟踪其他窗口 progress | — | recorder/projects 为其他窗口工作,合并时注意冲突 |
### H. 部署 5.60 ✅(2026-09-07,脚本 `tmp/deploy_orderby_560.py`)
| 步骤 | 结果 |
|------|------|
| 后端 17 文件上传 | ✅ services 12 + routers 5(纯后端,无前端 dist / env 变更) |
| 容器重启 | ✅ `docker compose restart app`(代码 volume 挂载,restart 即生效) |
| 健康检查 | ✅ `/health` 200(第 2 次探测) |
| md5 复核 | ✅ 17/17 本地 = 远端一致 |
| 代码标记 | ✅ `select(Execution.id)`(stats)/ `PerformanceProjectReport.id.in_`(performance)/ `.id.in_`(case ×3 / security ×3 / device_sim ×5)/ `select(CaseResult.id)`(execution ×3)全部命中 |
| API 冒烟 | ✅ 8 端点 200:stats/overview、performance tasks/executions、cases、executions、reports/executions、api-tests/cases、deploy/executions |
| 日志检查 | ✅ 容器启动无 error;近 5 分钟无 1038 / Out of sort / Traceback |
---
## ⚡ 会话更新(2026-09-07 续2)— 代码提交推送闭环 ✅
......
......@@ -6,7 +6,7 @@
作者:czj
创建日期:2026-07-09
最后修改:2026-07-09
最后修改:2026-09-08
"""
import logging
......@@ -123,6 +123,12 @@ async def init_db() -> None:
await session.commit()
if migrated > 0:
logger.info(f"存量执行数据回填完成:迁移 {migrated} 条执行记录")
# P3 拆表回填:主表非空大列 → 从表(幂等,仅从表缺行时写入)
backfilled = await service._backfill_satellite_tables()
await session.commit()
if backfilled > 0:
logger.info(f"大字段从表回填完成:写入 {backfilled} 行从表数据")
except Exception as e:
# 回填失败不阻断启动,下次启动会重试(幂等设计)
logger.warning(f"存量执行数据回填失败(不影响启动,下次重试): {e}")
......@@ -248,10 +254,14 @@ async def _ensure_columns(conn) -> None:
("performance_tasks", "api_summary", "JSON"),
# 性能测试:请求详情采集开关(旧库升级,2026-08-25 新增)
("performance_tasks", "request_detail_enabled", "VARCHAR(20) DEFAULT 'off'"),
# 性能测试:登录汇总(per-VU 独立登录成败统计,2026-09-07 新增)
("performance_tasks", "login_summary", "JSON"),
# 性能测试:执行记录请求详情采集开关快照(旧库升级,2026-08-25 新增)
("performance_executions", "request_detail_enabled", "VARCHAR(20) DEFAULT 'off'"),
# 性能测试:执行记录请求详情截断标志(旧库升级,2026-08-25 新增)
("performance_executions", "request_detail_truncated", "BOOLEAN DEFAULT 0"),
# 性能测试:执行记录登录汇总(per-VU 独立登录成败统计,2026-09-07 新增)
("performance_executions", "login_summary", "JSON"),
# 性能测试:快照增强指标(快照级 p95 等已于上条添加,本行仅作记录)
# 性能测试:快照关联执行记录 ID(旧库升级,执行跳转闭环用)
("performance_snapshots", "execution_id", "VARCHAR(64) DEFAULT NULL"),
......@@ -288,6 +298,32 @@ async def _ensure_columns(conn) -> None:
except Exception as e:
logger.warning(f"创建新表失败 {PerformanceRequestDetail.__tablename__}: {e}")
# 性能测试大字段从表兜底创建(P3 拆表:12 个从表,create_all 通常已建,此处兜底)
from app.models import performance as _perf_models
_satellite_classes = [
getattr(_perf_models, name) for name in (
"PerformanceTaskCsv",
"PerformanceTaskResourceSummary",
"PerformanceTaskTargetResourceSummary",
"PerformanceTaskTransactionSummary",
"PerformanceTaskApiSummary",
"PerformanceTaskLoginSummary",
"PerformanceExecutionResourceSummary",
"PerformanceExecutionTargetResourceSummary",
"PerformanceExecutionTransactionSummary",
"PerformanceExecutionApiSummary",
"PerformanceExecutionLoginSummary",
"PerformanceProjectReportSummary",
)
]
for sat_cls in _satellite_classes:
if sat_cls.__tablename__ not in existing_tables:
try:
sat_cls.__table__.create(sync_conn)
logger.info(f"创建新表: {sat_cls.__tablename__}")
except Exception as e:
logger.warning(f"创建新表失败 {sat_cls.__tablename__}: {e}")
# AsyncConnection 不支持直接 inspect,必须通过 run_sync 在同步上下文中执行
await conn.run_sync(_do_ensure)
......
......@@ -6,7 +6,7 @@
作者:czj
创建日期:2026-08-12
最后修改:2026-08-12
最后修改:2026-09-08
"""
from datetime import datetime
......@@ -271,6 +271,8 @@ class PerformanceTask(Base):
transaction_summary: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="事务处理时间汇总")
# 接口维度指标汇总(长稳压测多接口场景,按接口统计请求数/RT/TPS/错误率)
api_summary: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="接口维度指标汇总")
# 登录汇总(per-VU 独立登录成败统计,2026-09-07 新增:mode/total/success/failed)
login_summary: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="登录汇总")
# 结果统计
total_requests: Mapped[int] = mapped_column(Integer, default=0, comment="总请求数")
......@@ -397,6 +399,7 @@ class PerformanceTask(Base):
"target_resource_summary": self.target_resource_summary,
"transaction_summary": self.transaction_summary,
"api_summary": self.api_summary,
"login_summary": self.login_summary,
"total_requests": self.total_requests,
"success_count": self.success_count,
"fail_count": self.fail_count,
......@@ -660,6 +663,10 @@ class PerformanceExecution(Base):
api_summary: Mapped[Optional[list]] = mapped_column(
JSON, nullable=True, default=None, comment="接口维度汇总"
)
# 登录汇总(per-VU 独立登录成败统计,2026-09-07 新增:mode/total/success/failed)
login_summary: Mapped[Optional[dict]] = mapped_column(
JSON, nullable=True, default=None, comment="登录汇总"
)
# 元数据
snapshot_count: Mapped[int] = mapped_column(Integer, default=0, comment="快照条数")
......@@ -739,6 +746,7 @@ class PerformanceExecution(Base):
"target_resource_summary": self.target_resource_summary,
"transaction_summary": self.transaction_summary,
"api_summary": self.api_summary,
"login_summary": self.login_summary,
"snapshot_count": self.snapshot_count,
"triggered_by": self.triggered_by,
"batch_id": self.batch_id,
......@@ -1059,4 +1067,154 @@ class PerformanceRequestDetail(Base):
"error_message": self.error_message,
"success": self.success,
"ts": self.ts.isoformat() if self.ts else None,
}
\ No newline at end of file
}
# ==============================================================================
# 性能测试大字段从表模型(P3 架构升级:垂直拆表避免 MySQL 1038 Out of sort memory)
# 主表(tasks/executions/project_reports)仍保留原字段(双写 + 向后兼容),
# 从表承载大体积数据(JSON/TEXT),使主表分页与排序永不加载宽列。
# ==============================================================================
class PerformanceTaskCsv(Base):
"""任务 CSV 参数化内容从表 (1:1 关联 performance_tasks)"""
__tablename__ = "performance_task_csvs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属任务ID")
data: Mapped[str] = mapped_column(Text, default="", comment="CSV文本内容")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceTaskResourceSummary(Base):
"""任务执行机资源监控汇总从表 (1:1 关联 performance_tasks)"""
__tablename__ = "performance_task_resource_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属任务ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="执行机资源监控JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceTaskTargetResourceSummary(Base):
"""任务目标机资源监控汇总从表 (1:1 关联 performance_tasks)"""
__tablename__ = "performance_task_target_resource_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属任务ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="目标机资源监控JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceTaskTransactionSummary(Base):
"""任务事务处理时间汇总从表 (1:1 关联 performance_tasks)"""
__tablename__ = "performance_task_transaction_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属任务ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="事务处理时间汇总JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceTaskApiSummary(Base):
"""任务接口维度指标汇总从表 (1:1 关联 performance_tasks)"""
__tablename__ = "performance_task_api_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属任务ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="接口维度指标汇总JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceTaskLoginSummary(Base):
"""任务登录汇总从表 (1:1 关联 performance_tasks)"""
__tablename__ = "performance_task_login_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属任务ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="登录汇总JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceExecutionResourceSummary(Base):
"""执行记录执行机资源监控汇总从表 (1:1 关联 performance_executions)"""
__tablename__ = "performance_exec_resource_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属执行记录ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="执行机资源监控JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceExecutionTargetResourceSummary(Base):
"""执行记录目标机资源监控汇总从表 (1:1 关联 performance_executions)"""
__tablename__ = "performance_exec_target_resource_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属执行记录ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="目标机资源监控JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceExecutionTransactionSummary(Base):
"""执行记录事务汇总从表 (1:1 关联 performance_executions)"""
__tablename__ = "performance_exec_transaction_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属执行记录ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="事务处理时间汇总JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceExecutionApiSummary(Base):
"""执行记录接口汇总从表 (1:1 关联 performance_executions)"""
__tablename__ = "performance_exec_api_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属执行记录ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="接口维度指标汇总JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceExecutionLoginSummary(Base):
"""执行记录登录汇总从表 (1:1 关联 performance_executions)"""
__tablename__ = "performance_exec_login_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属执行记录ID")
data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="登录汇总JSON")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
class PerformanceProjectReportSummary(Base):
"""项目合并报告任务摘要汇总从表 (1:1 关联 performance_project_reports)"""
__tablename__ = "performance_project_report_summaries"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
owner_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="所属项目报告ID")
data: Mapped[str] = mapped_column(Text, default="", comment="合并报告task_summaries文本")
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间"
)
\ No newline at end of file
......@@ -295,6 +295,7 @@ async def get_report(
target_resource_summary=report.get("target_resource_summary"),
transaction_summary=report.get("transaction_summary"),
api_summary=report.get("api_summary"),
login_summary=report.get("login_summary"),
)
......@@ -411,6 +412,7 @@ async def get_execution_report(
target_resource_summary=report.get("target_resource_summary"),
transaction_summary=report.get("transaction_summary"),
api_summary=report.get("api_summary"),
login_summary=report.get("login_summary"),
)
......
......@@ -300,6 +300,8 @@ class PerformanceTaskResponse(BaseModel):
transaction_summary: Optional[Dict[str, Any]] = None
# 接口维度指标汇总
api_summary: Optional[List[Dict[str, Any]]] = None
# 登录汇总(per-VU 独立登录成败统计)
login_summary: Optional[Dict[str, Any]] = None
# 请求详情采集(JMeter 察看结果树,2026-08-25 新增)
request_detail_enabled: str = "off"
......@@ -458,6 +460,7 @@ class PerformanceExecutionDetailResponse(BaseModel):
target_resource_summary: Optional[Any] = Field(None, description="目标机资源汇总")
transaction_summary: Optional[Any] = Field(None, description="事务汇总")
api_summary: Optional[Any] = Field(None, description="接口维度汇总")
login_summary: Optional[Any] = Field(None, description="登录汇总")
snapshot_count: int = Field(0, description="快照条数")
triggered_by: str = Field("manual", description="触发方式")
batch_id: Optional[str] = Field(None, description="关联批次ID")
......@@ -572,6 +575,7 @@ class PerformanceReportResponse(BaseModel):
target_resource_summary: Optional[Dict[str, Any]] = Field(None, alias="targetResourceSummary", description="目标机资源监控汇总")
transaction_summary: Optional[Dict[str, Any]] = Field(None, alias="transactionSummary", description="事务处理时间汇总")
api_summary: Optional[List[Dict[str, Any]]] = Field(None, alias="apiSummary", description="接口维度指标汇总")
login_summary: Optional[Dict[str, Any]] = Field(None, alias="loginSummary", description="登录汇总")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
......@@ -585,6 +589,7 @@ class PerformanceExecutionReportResponse(BaseModel):
target_resource_summary: Optional[Any] = Field(None, description="目标机资源汇总")
transaction_summary: Optional[Any] = Field(None, description="事务汇总")
api_summary: Optional[Any] = Field(None, description="接口维度汇总")
login_summary: Optional[Any] = Field(None, description="登录汇总")
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
......
......@@ -6,7 +6,7 @@
作者:czj
创建日期:2026-08-12
最后修改:2026-08-12
最后修改:2026-09-08
"""
import asyncio
......@@ -28,6 +28,19 @@ from app.models.performance import (
PerformanceBatchExecution,
PerformanceProjectReport,
PerformanceRequestDetail,
# 大字段从表(P3 拆表,避免 MySQL 1038 Out of sort memory)
PerformanceTaskCsv,
PerformanceTaskResourceSummary,
PerformanceTaskTargetResourceSummary,
PerformanceTaskTransactionSummary,
PerformanceTaskApiSummary,
PerformanceTaskLoginSummary,
PerformanceExecutionResourceSummary,
PerformanceExecutionTargetResourceSummary,
PerformanceExecutionTransactionSummary,
PerformanceExecutionApiSummary,
PerformanceExecutionLoginSummary,
PerformanceProjectReportSummary,
)
from app.models.performance_output import PerformanceTaskOutput
from app.models.api_preset import ApiPreset
......@@ -200,6 +213,8 @@ class PerformanceService:
self.db.add(task)
await self.db.commit()
await self.db.refresh(task)
# P3 拆表双写:CSV 内容镜像到从表
await self._sync_task_satellites(task, fields=["csv_content"])
logger.info(f"创建性能测试任务: {task.id} - {task.name}")
return task
......@@ -271,6 +286,10 @@ class PerformanceService:
await self.db.commit()
await self.db.refresh(task)
# P3 拆表双写:大字段变更镜像到从表(更新路径仅 csv_content 可达)
changed_sat_fields = [k for k in data if k in self._TASK_SATELLITES]
if changed_sat_fields:
await self._sync_task_satellites(task, fields=changed_sat_fields)
return task
async def delete_task(self, task_id: str) -> bool:
......@@ -421,6 +440,17 @@ class PerformanceService:
task.status = "completed"
# 更新统计字段
self._apply_result(task, result)
# P3 拆表双写:执行产物大 JSON 镜像到从表(csv_content 执行中不变)
await self._sync_task_satellites(
task,
fields=[
"resource_summary",
"target_resource_summary",
"transaction_summary",
"api_summary",
"login_summary",
],
)
logger.info(f"性能测试执行完成 [{task_id}]: "
f"请求={result.get('total_requests', 0)}, "
f"TPS={result.get('actual_tps', 0)}")
......@@ -445,6 +475,8 @@ class PerformanceService:
execution.duration_actual = task.duration_actual
if result and not result.get("error"):
self._copy_result_to_execution(execution, result)
# P3 拆表双写:执行产物大 JSON 镜像到执行记录从表
await self._sync_exec_satellites(execution)
execution.snapshot_count = len(snapshot_buffer)
# 请求详情采集状态回写(JMeter 察看结果树,2026-08-25 新增)
execution.request_detail_truncated = bool(
......@@ -765,10 +797,12 @@ class PerformanceService:
"error_type_assertion": task.error_type_assertion,
},
"snapshots": [s.to_dict() for s in snapshots],
"resource_summary": task.resource_summary,
"target_resource_summary": task.target_resource_summary,
"transaction_summary": task.transaction_summary,
"api_summary": task.api_summary,
# P3 拆表:大 JSON 从表优先,主表列回退
**{
k: v for k, v in (
await self._load_task_satellite_fields(task)
).items() if k != "csv_content"
},
}
async def _build_report_from_execution(
......@@ -907,10 +941,8 @@ class PerformanceService:
"error_type_assertion": execution.error_type_assertion,
},
"snapshots": [s.to_dict() for s in snapshots],
"resource_summary": execution.resource_summary,
"target_resource_summary": execution.target_resource_summary,
"transaction_summary": execution.transaction_summary,
"api_summary": execution.api_summary,
# P3 拆表:大 JSON 从表优先,主表列回退
**(await self._load_exec_satellite_fields(execution)),
}
# ==================== 执行历史 CRUD ====================
......@@ -1470,11 +1502,26 @@ class PerformanceService:
pass
@staticmethod
def _report_to_dict(report: PerformanceProjectReport) -> Dict[str, Any]:
"""将合并报告 ORM 对象转为 API 响应字典"""
def _report_to_dict(
report: PerformanceProjectReport,
task_summaries_text: Optional[str] = None,
) -> Dict[str, Any]:
"""
将合并报告 ORM 对象转为 API 响应字典
Args:
report: 合并报告 ORM 对象
task_summaries_text: 从表优先读到的 task_summaries 文本
(None 时回退主表列,P3 拆表兼容)
"""
raw_summaries = (
task_summaries_text
if task_summaries_text is not None
else (report.task_summaries or "")
)
task_summaries = []
try:
task_summaries = json.loads(report.task_summaries or "[]")
task_summaries = json.loads(raw_summaries or "[]")
except (json.JSONDecodeError, TypeError):
pass
......@@ -1536,7 +1583,9 @@ class PerformanceService:
report = await self.db.get(PerformanceProjectReport, report_id)
if not report:
return None
return self._report_to_dict(report)
# P3 拆表:task_summaries 从表优先,主表列回退
task_summaries_text = await self._get_report_task_summaries(report)
return self._report_to_dict(report, task_summaries_text)
async def get_batch_report(self, batch_id: str) -> Optional[Dict[str, Any]]:
"""
......@@ -1556,7 +1605,9 @@ class PerformanceService:
report = result.scalar_one_or_none()
if not report:
return None
return self._report_to_dict(report)
# P3 拆表:task_summaries 从表优先,主表列回退
task_summaries_text = await self._get_report_task_summaries(report)
return self._report_to_dict(report, task_summaries_text)
async def list_project_reports(
self, project_id: str, page: int = 1, page_size: int = 20
......@@ -1594,18 +1645,285 @@ class PerformanceService:
).scalars().all()
)
reports = []
summaries_map: Dict[str, str] = {}
if ids:
rows = await self.db.execute(
select(PerformanceProjectReport).where(PerformanceProjectReport.id.in_(ids))
)
by_id = {r.id: r for r in rows.scalars().all()}
reports = [by_id[i] for i in ids if i in by_id]
# P3 拆表:批量取本页从表 task_summaries(一次 IN 查询)
sum_rows = await self.db.execute(
select(
PerformanceProjectReportSummary.owner_id,
PerformanceProjectReportSummary.data,
).where(PerformanceProjectReportSummary.owner_id.in_(ids))
)
summaries_map = {oid: data for oid, data in sum_rows.all()}
return {
"total": total,
"items": [self._report_to_dict(r) for r in reports],
"items": [
self._report_to_dict(r, summaries_map.get(r.id)) for r in reports
],
}
# ==================== 大字段从表(P3 拆表)====================
# 主表大字段 → 从表模型映射(csv_content 与 5 个执行产物 JSON)
_TASK_SATELLITES = {
"csv_content": PerformanceTaskCsv,
"resource_summary": PerformanceTaskResourceSummary,
"target_resource_summary": PerformanceTaskTargetResourceSummary,
"transaction_summary": PerformanceTaskTransactionSummary,
"api_summary": PerformanceTaskApiSummary,
"login_summary": PerformanceTaskLoginSummary,
}
# 执行记录大字段 → 从表模型映射
_EXEC_SATELLITES = {
"resource_summary": PerformanceExecutionResourceSummary,
"target_resource_summary": PerformanceExecutionTargetResourceSummary,
"transaction_summary": PerformanceExecutionTransactionSummary,
"api_summary": PerformanceExecutionApiSummary,
"login_summary": PerformanceExecutionLoginSummary,
}
@staticmethod
def _satellite_empty(value: Any) -> bool:
"""从表值判空:None 或空字符串视为「无数据」(同步删除从表行)"""
return value is None or (isinstance(value, str) and not value.strip())
async def _satellite_upsert(
self, sat_cls, owner_id: str, value: Any
) -> None:
"""
upsert 从表行(双写路径)
值为空时删除从表行(与主表列保持一致,避免读到陈旧数据);
已有行则原地更新 data,否则插入新行。
Args:
sat_cls: 从表 ORM 类
owner_id: 主表行 ID
value: 大字段值(dict / str)
"""
if self._satellite_empty(value):
await self.db.execute(
sat_cls.__table__.delete().where(sat_cls.owner_id == owner_id)
)
return
row = (
await self.db.execute(
select(sat_cls).where(sat_cls.owner_id == owner_id)
)
).scalar_one_or_none()
if row:
row.data = value
else:
self.db.add(sat_cls(owner_id=owner_id, data=value))
await self.db.flush()
async def _sync_task_satellites(
self, task: PerformanceTask, fields: Optional[List[str]] = None
) -> None:
"""
将任务主表大字段镜像到从表(双写;从表失败不影响主流程)
Args:
task: 任务 ORM 对象(属性已更新)
fields: 仅同步指定字段(None = 全部大字段)
"""
try:
for field, sat_cls in self._TASK_SATELLITES.items():
if fields is not None and field not in fields:
continue
await self._satellite_upsert(
sat_cls, task.id, getattr(task, field, None)
)
except Exception as e:
logger.warning(f"任务从表同步失败(主表列仍完整)[{task.id}]: {e}")
async def _sync_exec_satellites(self, execution: PerformanceExecution) -> None:
"""将执行记录主表大字段镜像到从表(双写;从表失败不影响主流程)"""
try:
for field, sat_cls in self._EXEC_SATELLITES.items():
await self._satellite_upsert(
sat_cls, execution.id, getattr(execution, field, None)
)
except Exception as e:
logger.warning(f"执行记录从表同步失败(主表列仍完整)[{execution.id}]: {e}")
async def _sync_report_satellite(self, report: PerformanceProjectReport) -> None:
"""将合并报告 task_summaries 镜像到从表(双写;从表失败不影响主流程)"""
try:
await self._satellite_upsert(
PerformanceProjectReportSummary,
report.id,
report.task_summaries or "",
)
except Exception as e:
logger.warning(f"合并报告从表同步失败(主表列仍完整)[{report.id}]: {e}")
async def _load_task_satellite_fields(
self, task: PerformanceTask
) -> Dict[str, Any]:
"""
读取任务大字段(从表优先,主表列回退)
Args:
task: 任务 ORM 对象
Returns:
dict: {csv_content, resource_summary, ...}(键与 _TASK_SATELLITES 一致)
"""
values: Dict[str, Any] = {}
for field, sat_cls in self._TASK_SATELLITES.items():
values[field] = getattr(task, field, None)
# 逐表查询(单行主键命中,开销可忽略)
for field, sat_cls in self._TASK_SATELLITES.items():
data = (
await self.db.execute(
select(sat_cls.data).where(sat_cls.owner_id == task.id)
)
).scalar_one_or_none()
if not self._satellite_empty(data):
values[field] = data
return values
async def _load_exec_satellite_fields(
self, execution: PerformanceExecution
) -> Dict[str, Any]:
"""
读取执行记录大字段(从表优先,主表列回退)
Args:
execution: 执行记录 ORM 对象
Returns:
dict: {resource_summary, ...}(键与 _EXEC_SATELLITES 一致)
"""
values: Dict[str, Any] = {}
for field, sat_cls in self._EXEC_SATELLITES.items():
values[field] = getattr(execution, field, None)
for field, sat_cls in self._EXEC_SATELLITES.items():
data = (
await self.db.execute(
select(sat_cls.data).where(sat_cls.owner_id == execution.id)
)
).scalar_one_or_none()
if not self._satellite_empty(data):
values[field] = data
return values
async def _get_report_task_summaries(
self, report: PerformanceProjectReport
) -> str:
"""读取合并报告 task_summaries 文本(从表优先,主表列回退)"""
data = (
await self.db.execute(
select(PerformanceProjectReportSummary.data).where(
PerformanceProjectReportSummary.owner_id == report.id
)
)
).scalar_one_or_none()
if data:
return data
return report.task_summaries or ""
async def _backfill_satellite_tables(self) -> int:
"""
存量回填:主表非空大列 → 从表(幂等,仅从表缺行时写入)
在应用启动时由 init_db 调用;存量行只补从表,不改动主表。
Returns:
int: 回填写入的从表行数
"""
backfilled = 0
# ---- 任务表:csv_content + 5 个 JSON 汇总 ----
task_rows = await self.db.execute(
select(
PerformanceTask.id,
PerformanceTask.csv_content,
PerformanceTask.resource_summary,
PerformanceTask.target_resource_summary,
PerformanceTask.transaction_summary,
PerformanceTask.api_summary,
PerformanceTask.login_summary,
)
)
for row in task_rows.all():
task_id = row[0]
for field, sat_cls in self._TASK_SATELLITES.items():
value = getattr(row, field, None) if hasattr(row, field) else None
if self._satellite_empty(value):
continue
exists = (
await self.db.execute(
select(sat_cls.id).where(sat_cls.owner_id == task_id)
)
).scalar_one_or_none()
if exists is None:
self.db.add(sat_cls(owner_id=task_id, data=value))
backfilled += 1
# ---- 执行记录表:5 个 JSON 汇总 ----
exec_rows = await self.db.execute(
select(
PerformanceExecution.id,
PerformanceExecution.resource_summary,
PerformanceExecution.target_resource_summary,
PerformanceExecution.transaction_summary,
PerformanceExecution.api_summary,
PerformanceExecution.login_summary,
)
)
for row in exec_rows.all():
exec_id = row[0]
for field, sat_cls in self._EXEC_SATELLITES.items():
value = getattr(row, field, None) if hasattr(row, field) else None
if self._satellite_empty(value):
continue
exists = (
await self.db.execute(
select(sat_cls.id).where(sat_cls.owner_id == exec_id)
)
).scalar_one_or_none()
if exists is None:
self.db.add(sat_cls(owner_id=exec_id, data=value))
backfilled += 1
# ---- 合并报告表:task_summaries ----
report_rows = await self.db.execute(
select(
PerformanceProjectReport.id,
PerformanceProjectReport.task_summaries,
)
)
for row in report_rows.all():
report_id = row[0]
value = row[1]
if self._satellite_empty(value):
continue
exists = (
await self.db.execute(
select(PerformanceProjectReportSummary.id).where(
PerformanceProjectReportSummary.owner_id == report_id
)
)
).scalar_one_or_none()
if exists is None:
self.db.add(
PerformanceProjectReportSummary(owner_id=report_id, data=value)
)
backfilled += 1
if backfilled:
await self.db.flush()
logger.info(f"大字段从表存量回填: {backfilled} 行")
return backfilled
# ==================== 内部方法 ====================
@staticmethod
......@@ -1694,6 +2012,8 @@ class PerformanceService:
task.transaction_summary = result.get("transaction_summary")
# 接口维度指标汇总(长稳压测多接口场景)
task.api_summary = result.get("api_summary")
# 登录汇总(per-VU 独立登录成败统计)
task.login_summary = result.get("login_summary")
@staticmethod
def _copy_result_to_execution(
......@@ -1746,6 +2066,8 @@ class PerformanceService:
execution.transaction_summary = result.get("transaction_summary")
# 接口维度指标汇总(长稳压测多接口场景)
execution.api_summary = result.get("api_summary")
# 登录汇总(per-VU 独立登录成败统计)
execution.login_summary = result.get("login_summary")
async def _save_snapshots(
self,
......@@ -2500,6 +2822,8 @@ class PerformanceService:
created_at=datetime.utcnow(),
)
self.db.add(report)
# P3 拆表双写:task_summaries 镜像到从表
await self._sync_report_satellite(report)
except Exception as e:
logger.error(f"生成合并报告失败: {e}")
report_id = None
......
......@@ -57,6 +57,18 @@ export interface ApiSummaryData {
actualTps: number
}
/** 登录汇总(per-VU 独立登录 / 共享登录成败统计) */
export interface LoginSummary {
/** 登录模式:per_vu=每虚拟用户独立登录 / shared=共享单 token */
mode: 'per_vu' | 'shared'
/** 登录总数(per_vu=虚拟用户数,shared=1) */
total: number
/** 登录成功数 */
success: number
/** 登录失败数 */
failed: number
}
/** 断言类型 */
export type AssertionType = 'status_code' | 'response_time' | 'response_body'
......@@ -416,6 +428,8 @@ export interface PerformanceTask {
csvVariableMapping?: Record<string, string> | null
/** 接口级统计汇总 */
apiSummary?: ApiSummaryData[] | null
/** 登录汇总(per-VU 独立登录成败统计) */
loginSummary?: LoginSummary | null
/** 请求详情采集模式(off/on/errors_only) */
requestDetailEnabled?: string
/** 执行机资源监控汇总 */
......@@ -636,6 +650,8 @@ export interface PerformanceExecutionDetail {
targetResourceSummary?: TargetResourceSummary | null
transactionSummary?: TransactionSummary | null
apiSummary?: ApiSummaryData[] | null
/** 登录汇总(per-VU 独立登录成败统计) */
loginSummary?: LoginSummary | null
snapshotCount: number
triggeredBy: string
batchId: string | null
......@@ -750,6 +766,8 @@ export interface PerformanceReportResponse {
transactionSummary?: TransactionSummary | null
/** 接口级统计汇总(endurance 多接口) */
apiSummary?: ApiSummaryData[] | null
/** 登录汇总(per-VU 独立登录成败统计) */
loginSummary?: LoginSummary | null
}
/** WebSocket 快照消息 */
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论