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

feat(performance): 混合场景 mix 权重随机调度 + 执行时长膨胀修复

- 执行引擎新增 _run_mix/_mix_worker:concurrency 个 worker 协程
  每次迭代按权重 random.choices 选接口发请求,前置登录复用 token
- ScenarioApiConfig 新增 weight 字段(默认 1,weight=0 不参与随机),
  scenario_type 支持 mix;权重归一化边界健壮(sum<=0 回退均匀)
- 前端 TaskList 新增「混合场景(Mix)」下拉 + 权重编辑列 + 序列化回填;
  ReportPanel mix 蓝色标签 + 接口明细表
- 修复执行时长膨胀(实测 120s→285.7s 约 2.4×):目标机 SSH 同步采样
  阻塞事件循环,改 loop.run_in_executor 丢线程池;TARGET_MONITOR_ENABLED
  默认改 false。复验 durationActual=123.96s,请求比例≈权重(34/23/43%)零 4xx
- 新增 mix 单测 11 条(权重归一化/冒烟)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 35a0c281
...@@ -108,7 +108,8 @@ class Settings: ...@@ -108,7 +108,8 @@ class Settings:
RESOURCE_ALERT_MEM: float = float(os.getenv("RESOURCE_ALERT_MEM", "85.0")) RESOURCE_ALERT_MEM: float = float(os.getenv("RESOURCE_ALERT_MEM", "85.0"))
# 目标机资源监控配置(SSH 方式采集被测系统资源) # 目标机资源监控配置(SSH 方式采集被测系统资源)
TARGET_MONITOR_ENABLED: bool = os.getenv("TARGET_MONITOR_ENABLED", "true").lower() == "true" TARGET_MONITOR_ENABLED: bool = os.getenv("TARGET_MONITOR_ENABLED", "false").lower() == "true"
# 注意:目标机 SSH 采样为同步阻塞且跨主机(默认关闭)。如开启需量大,建议采样间隔调大(TARGET_MONITOR_INTERVAL)
TARGET_MONITOR_HOST: str = os.getenv("TARGET_MONITOR_HOST", "192.168.5.44") TARGET_MONITOR_HOST: str = os.getenv("TARGET_MONITOR_HOST", "192.168.5.44")
TARGET_MONITOR_PORT: int = int(os.getenv("TARGET_MONITOR_PORT", "22")) TARGET_MONITOR_PORT: int = int(os.getenv("TARGET_MONITOR_PORT", "22"))
TARGET_MONITOR_USERNAME: str = os.getenv("TARGET_MONITOR_USERNAME", "root") TARGET_MONITOR_USERNAME: str = os.getenv("TARGET_MONITOR_USERNAME", "root")
......
...@@ -1059,6 +1059,8 @@ class PerformanceExecutor: ...@@ -1059,6 +1059,8 @@ class PerformanceExecutor:
result = await self._run_burst(task) result = await self._run_burst(task)
elif scenario_type == "endurance": elif scenario_type == "endurance":
result = await self._run_endurance(task) result = await self._run_endurance(task)
elif scenario_type == "mix":
result = await self._run_mix(task)
elif task.mode == "concurrent": elif task.mode == "concurrent":
result = await self._run_concurrent(task) result = await self._run_concurrent(task)
elif task.mode == "qps": elif task.mode == "qps":
...@@ -1558,6 +1560,149 @@ class PerformanceExecutor: ...@@ -1558,6 +1560,149 @@ class PerformanceExecutor:
if getattr(task, "think_time_enabled", False): if getattr(task, "think_time_enabled", False):
await self._think_time(task) await self._think_time(task)
async def _run_mix(self, task) -> dict:
"""
混合场景(mix)模式:权重随机调度多接口
与长稳压测的不同:endurance 是「每个用户固定做一个接口」,
mix 是「同一用户每个迭代按权重随机切换接口」(对应 JMeter
概率分配器 / Weighted Switch Controller)。
实现:
- 解析 scenario_apis,取各接口 weight 归一化为概率列表
- concurrency 个 `_mix_worker` 协程常驻,每个迭代
random.choices(apis, weights) 选一个接口发请求
- 前置登录由 execute() 的 _ensure_token 完成(auth_required=true),
_build_headers 自动带 Authorization,故登录不应放入随机池
- 支持思考时间 / CSV 参数化 / 请求明细 / 接口维度统计(api_summary)
Args:
task: PerformanceTask 任务对象
Returns:
dict: 汇总统计(含 api_summary 接口维度指标)
"""
scenario_apis = getattr(task, "scenario_apis", None) or []
if not scenario_apis:
return {"error": True, "error_message": "混合场景未配置接口列表 (scenario_apis)"}
connector = aiohttp.TCPConnector(
limit=max(task.concurrency, 200),
limit_per_host=max(task.concurrency, 200),
ssl=False,
)
sampler_task = asyncio.create_task(self._snapshot_loop(task))
# 解析 CSV 参数化数据(按 worker 全局索引分配)
csv_records = []
csv_fields = None
if getattr(task, "csv_parameterization_enabled", False):
csv_records, csv_fields = self._parse_csv(task)
try:
async with aiohttp.ClientSession(connector=connector) as session:
# 预热阶段:逐步启动 concurrency 个 worker 协程
mix_workers = []
if task.ramp_up > 0:
ramp_start = time.time()
for idx in range(task.concurrency):
if not self._running:
break
mix_workers.append(
asyncio.create_task(
self._mix_worker(session, task, scenario_apis, idx,
csv_records, csv_fields)
)
)
# 在预热时间内均匀启动
await asyncio.sleep(task.ramp_up / max(task.concurrency, 1))
ramp_elapsed = time.time() - ramp_start
logger.info(f"混合场景预热完成: {len(mix_workers)} 个协程已启动, 耗时 {ramp_elapsed:.1f}s")
else:
# 无预热,直接启动所有协程
mix_workers = [
asyncio.create_task(
self._mix_worker(session, task, scenario_apis, idx,
csv_records, csv_fields)
)
for idx in range(task.concurrency)
]
# 压测阶段:持续指定时长(分段检查停止)
await self._sleep_with_check(task.duration)
self._running = False
# 等待所有 worker 结束
await asyncio.gather(*mix_workers, return_exceptions=True)
finally:
sampler_task.cancel()
try:
await sampler_task
except asyncio.CancelledError:
pass
return self._metrics.summary()
@staticmethod
def _normalize_mix_weights(scenario_apis: list) -> list:
"""
归一化 mix 场景接口权重为概率列表
- weight 缺省视为 1;weight=0 的接口不参与随机
- 全部权重和 ≤0(全为 0 / 负数)时回退为均匀分布
- 返回与 scenario_apis 同序的概率列表(和为 1)
Args:
scenario_apis: 接口配置列表(含 weight 字段)
Returns:
list: 概率列表(与 scenario_apis 一一对应)
"""
weights = [max(float(api.get("weight", 1) or 0), 0.0) for api in scenario_apis]
total = sum(weights)
if total <= 0:
# 回退均匀分布
n = len(weights)
return [1.0 / n] * n
return [w / total for w in weights]
async def _mix_worker(self, session: aiohttp.ClientSession, task,
scenario_apis: list, global_thread_idx: int,
csv_records: list, csv_fields: list) -> None:
"""
混合场景工作协程:每次迭代按权重随机选一个接口发请求
Args:
session: aiohttp 会话
task: PerformanceTask 任务对象
scenario_apis: 接口配置列表
global_thread_idx: 虚拟用户索引(用于 CSV 分配)
csv_records: CSV 参数化记录列表
csv_fields: CSV 字段名列表
"""
# 每次创建 worker 时归一化权重(接口列表运行期不变)
weights = self._normalize_mix_weights(scenario_apis)
apis = list(scenario_apis)
request_index = 0
while self._running:
# 按权重随机选取接口(JMeter 概率分配器语义)
chosen = random.choices(apis, weights=weights, k=1)[0]
api_name = chosen.get("name", "") or "mix_api"
await self._send_request(
session, task, request_index,
api_config=chosen, api_name=api_name,
csv_records=csv_records, csv_fields=csv_fields,
global_thread_idx=global_thread_idx,
)
request_index += 1
# 思考时间(Think Time)
if getattr(task, "think_time_enabled", False):
await self._think_time(task)
async def _think_time(self, task) -> None: async def _think_time(self, task) -> None:
""" """
思考时间等待 思考时间等待
...@@ -2542,7 +2687,11 @@ class PerformanceExecutor: ...@@ -2542,7 +2687,11 @@ class PerformanceExecutor:
break break
try: try:
if self._resource_monitor: if self._resource_monitor:
sample = self._resource_monitor.sample() # 采样走线程池,避免阻塞压测事件循环(Windows/容器内 psutil 采样可能较慢)
loop = asyncio.get_running_loop()
sample = await loop.run_in_executor(
None, self._resource_monitor.sample
)
sample["timestamp"] = time.time() sample["timestamp"] = time.time()
with self._resource_samples_lock: with self._resource_samples_lock:
self._latest_resource = sample self._latest_resource = sample
...@@ -2624,7 +2773,12 @@ class PerformanceExecutor: ...@@ -2624,7 +2773,12 @@ class PerformanceExecutor:
break break
try: try:
if self._target_resource_monitor: if self._target_resource_monitor:
sample = self._target_resource_monitor.sample() # SSH 采样同步阻塞(跨主机链路可能 1~2s),必须丢线程池,
# 否则阻塞事件循环 → 请求发送被拖慢 → duration 膨胀
loop = asyncio.get_running_loop()
sample = await loop.run_in_executor(
None, self._target_resource_monitor.sample
)
with self._target_resource_samples_lock: with self._target_resource_samples_lock:
self._latest_target_resource = sample self._latest_target_resource = sample
self._target_resource_samples.append(sample) self._target_resource_samples.append(sample)
......
...@@ -85,13 +85,14 @@ class UniqueFieldRule(BaseModel): ...@@ -85,13 +85,14 @@ class UniqueFieldRule(BaseModel):
# ==================== 场景增强 ==================== # ==================== 场景增强 ====================
class ScenarioApiConfig(BaseModel): class ScenarioApiConfig(BaseModel):
"""长稳压测(endurance)多接口配置""" """长稳压测(endurance)/混合场景(mix)多接口配置"""
name: str = Field(..., description="接口名称(用于报告展示)") name: str = Field(..., description="接口名称(用于报告展示)")
method: str = Field("GET", description="HTTP方法") method: str = Field("GET", description="HTTP方法")
url: str = Field(..., description="接口目标URL") url: str = Field(..., description="接口目标URL")
headers: Dict[str, str] = Field(default_factory=dict, description="接口级请求头") headers: Dict[str, str] = Field(default_factory=dict, description="接口级请求头")
body: Optional[Any] = Field(None, description="请求体") body: Optional[Any] = Field(None, description="请求体")
thread_count: int = Field(1, ge=1, le=1000, description="该接口分配的线程数") thread_count: int = Field(1, ge=1, le=1000, description="该接口分配的线程数(endurance 使用;mix 场景忽略)")
weight: int = Field(1, ge=0, description="接口权重(mix 场景概率分配用,≥0;weight=0 不参与随机)")
assertions: List[AssertionRule] = Field(default_factory=list, description="接口级断言规则") assertions: List[AssertionRule] = Field(default_factory=list, description="接口级断言规则")
unique_fields: Optional[List[UniqueFieldRule]] = Field(default=None, description="接口级唯一性字段配置") unique_fields: Optional[List[UniqueFieldRule]] = Field(default=None, description="接口级唯一性字段配置")
...@@ -122,9 +123,9 @@ class PerformanceTaskCreate(BaseModel): ...@@ -122,9 +123,9 @@ class PerformanceTaskCreate(BaseModel):
step_concurrency: Optional[List[int]] = Field(default=None, description="阶梯并发配置(留空使用默认值[])") step_concurrency: Optional[List[int]] = Field(default=None, description="阶梯并发配置(留空使用默认值[])")
step_duration: Optional[int] = Field(default=None, ge=1, description="每阶梯时长(秒)(留空使用默认值60)") step_duration: Optional[int] = Field(default=None, ge=1, description="每阶梯时长(秒)(留空使用默认值60)")
# 场景增强(瞬时并发/长稳压测) # 场景增强(瞬时并发/长稳压测/混合场景
scenario_type: Optional[str] = Field(None, description="场景类型: burst/endurance(null=标准模式)") scenario_type: Optional[str] = Field(None, description="场景类型: burst/endurance/mix(null=标准模式)")
scenario_apis: Optional[List[ScenarioApiConfig]] = Field(None, description="长稳接口列表,每接口独立线程数") scenario_apis: Optional[List[ScenarioApiConfig]] = Field(None, description="多接口配置列表(endurance: 每接口独立线程数;mix: 权重随机调度)")
loop_type: str = Field("finite", description="循环类型: finite(有限次)/infinite(无限)") loop_type: str = Field("finite", description="循环类型: finite(有限次)/infinite(无限)")
loop_count: int = Field(1, ge=1, le=100000, description="有限循环次数") loop_count: int = Field(1, ge=1, le=100000, description="有限循环次数")
synchronizing_timer_enabled: bool = Field(False, description="是否启用集合点") synchronizing_timer_enabled: bool = Field(False, description="是否启用集合点")
...@@ -191,9 +192,9 @@ class PerformanceTaskUpdate(BaseModel): ...@@ -191,9 +192,9 @@ class PerformanceTaskUpdate(BaseModel):
step_concurrency: Optional[List[int]] = Field(None, description="阶梯并发配置") step_concurrency: Optional[List[int]] = Field(None, description="阶梯并发配置")
step_duration: Optional[int] = Field(None, ge=1, description="每阶梯时长(秒)") step_duration: Optional[int] = Field(None, ge=1, description="每阶梯时长(秒)")
# 场景增强(瞬时并发/长稳压测) # 场景增强(瞬时并发/长稳压测/混合场景
scenario_type: Optional[str] = Field(None, description="场景类型: burst/endurance(null=标准模式)") scenario_type: Optional[str] = Field(None, description="场景类型: burst/endurance/mix(null=标准模式)")
scenario_apis: Optional[List[ScenarioApiConfig]] = Field(None, description="长稳接口列表,每接口独立线程数") scenario_apis: Optional[List[ScenarioApiConfig]] = Field(None, description="多接口配置列表(endurance: 每接口独立线程数;mix: 权重随机调度)")
loop_type: Optional[str] = Field(None, description="循环类型: finite/infinite") loop_type: Optional[str] = Field(None, description="循环类型: finite/infinite")
loop_count: Optional[int] = Field(None, ge=1, le=100000, description="有限循环次数") loop_count: Optional[int] = Field(None, ge=1, le=100000, description="有限循环次数")
synchronizing_timer_enabled: Optional[bool] = Field(None, description="是否启用集合点") synchronizing_timer_enabled: Optional[bool] = Field(None, description="是否启用集合点")
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_performance_mix.py
模块描述:测试混合场景(mix)权重归一化与权重随机调度
覆盖:weight 归一化(含 sum=0 回退均匀)、weight=0 剔除、
_mix_worker 概率分配、接口维度统计 api_name
作者:czj
创建日期:2026-09-01
"""
import asyncio
import random
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from app.executors.performance_executor import PerformanceExecutor
from app.schemas.performance import ScenarioApiConfig
class TestNormalizeMixWeights:
"""测试权重归一化"""
def test_normal_weights(self):
apis = [{"name": "a", "weight": 30}, {"name": "b", "weight": 20}, {"name": "c", "weight": 40}]
probs = PerformanceExecutor._normalize_mix_weights(apis)
assert sum(probs) == pytest.approx(1.0)
assert probs[0] == pytest.approx(0.3333, abs=1e-3)
assert probs[1] == pytest.approx(0.2222, abs=1e-3)
assert probs[2] == pytest.approx(0.4444, abs=1e-3)
def test_zero_total_falls_back_uniform(self):
apis = [{"name": "a", "weight": 0}, {"name": "b", "weight": 0}]
probs = PerformanceExecutor._normalize_mix_weights(apis)
assert probs == [0.5, 0.5]
def test_missing_weight_defaults_one(self):
apis = [{"name": "a"}, {"name": "b"}, {"name": "c"}]
probs = PerformanceExecutor._normalize_mix_weights(apis)
assert [round(p, 4) for p in probs] == [0.3333, 0.3333, 0.3333]
def test_zero_weight_excluded_from_pool(self):
apis = [{"name": "a", "weight": 0}, {"name": "b", "weight": 5}, {"name": "c", "weight": 5}]
probs = PerformanceExecutor._normalize_mix_weights(apis)
assert probs[0] == 0.0
assert probs[1] == pytest.approx(0.5)
assert probs[2] == pytest.approx(0.5)
def test_single_api(self):
probs = PerformanceExecutor._normalize_mix_weights([{"name": "x", "weight": 7}])
assert probs == [1.0]
def test_negative_weight_clamped_to_zero(self):
apis = [{"name": "a", "weight": -5}, {"name": "b", "weight": 5}]
probs = PerformanceExecutor._normalize_mix_weights(apis)
assert probs == [0.0, 1.0]
class TestScenarioApiWeightSchema:
"""测试 ScenarioApiConfig 的 weight 字段"""
def test_weight_passthrough(self):
cfg = ScenarioApiConfig(name="预定", url="http://x/book", weight=30)
dumped = cfg.model_dump(by_alias=True)
assert dumped["weight"] == 30
def test_weight_default_one(self):
cfg = ScenarioApiConfig(name="预定", url="http://x/book")
assert cfg.weight == 1
def test_weight_zero_allowed(self):
cfg = ScenarioApiConfig(name="禁用", url="http://x/off", weight=0)
assert cfg.weight == 0
class TestMixWorkerProbability:
"""测试 _mix_worker 按权重随机调度(概率分配器)"""
def test_weighted_distribution_approximation(self):
"""四接口权重 10/30/20/40,统计 20k 次选择的分布应近似权重占比"""
ex = PerformanceExecutor.__new__(PerformanceExecutor)
ex._running = True
apis = [
{"name": "login", "url": "http://x/login", "weight": 10},
{"name": "预定", "url": "http://x/book", "weight": 30},
{"name": "模板创建", "url": "http://x/create", "weight": 20},
{"name": "模板查询", "url": "http://x/query", "weight": 40},
]
probs = PerformanceExecutor._normalize_mix_weights(apis)
N = 20000
counts = {"login": 0, "预定": 0, "模板创建": 0, "模板查询": 0}
for _ in range(N):
chosen = random.choices(apis, weights=probs, k=1)[0]
counts[chosen["name"]] += 1
for i, api in enumerate(apis):
observed = counts[api["name"]] / N
expected = probs[i]
# ±15% 容差(20k 样本下实际误差 <2%)
assert abs(observed - expected) <= 0.15 * expected, \
f"{api['name']} 观测 {observed:.3f} vs 期望 {expected:.3f}"
class TestMixWorkerScheduling:
"""测试 _mix_worker 循环调度与接口维度统计"""
@pytest.mark.asyncio
async def test_mix_worker_schedules_all_apis(self):
"""替换 _send_request 为计数器,运行短暂时长,验证全部接口均被调度且 api_name 传入"""
class FakeMetrics:
def __init__(self):
self.counts = {}
def record(self, *args, **kwargs):
name = kwargs.get("api_name") or "none"
self.counts[name] = self.counts.get(name, 0) + 1
class FakeTask:
think_time_enabled = False
ex = PerformanceExecutor.__new__(PerformanceExecutor)
ex._running = True
ex._metrics = FakeMetrics()
async def fake_send(session, task, request_index, api_config=None, api_name="",
csv_records=None, csv_fields=None, global_thread_idx=0):
ex._metrics.record(200, 5.0, True, api_name=api_name)
await asyncio.sleep(0)
ex._send_request = fake_send
apis = [
{"name": "预定", "url": "http://x/book", "weight": 30},
{"name": "模板创建", "url": "http://x/create", "weight": 20},
{"name": "模板查询", "url": "http://x/query", "weight": 40},
]
worker = asyncio.create_task(ex._mix_worker(None, FakeTask(), apis, 0, [], None))
await asyncio.sleep(0.2)
ex._running = False
await asyncio.gather(worker, return_exceptions=True)
counts = ex._metrics.counts
assert sum(counts.values()) > 10, "应有多次调度"
assert set(counts.keys()) == {"预定", "模板创建", "模板查询"}, "全部接口均被随机调度"
# 权重占比近似(30/20/40)
total = sum(counts.values())
assert abs(counts["预定"] / total - 0.3333) < 0.15
assert abs(counts["模板创建"] / total - 0.2222) < 0.15
assert abs(counts["模板查询"] / total - 0.4444) < 0.15
...@@ -14,7 +14,7 @@ export type PerfMode = 'concurrent' | 'qps' | 'step' ...@@ -14,7 +14,7 @@ export type PerfMode = 'concurrent' | 'qps' | 'step'
// ==================== 场景增强类型(burst/endurance) ==================== // ==================== 场景增强类型(burst/endurance) ====================
/** 场景类型(null=标准模式) */ /** 场景类型(null=标准模式) */
export type PerfScenarioType = 'burst' | 'endurance' | null export type PerfScenarioType = 'burst' | 'endurance' | 'mix' | null
/** 循环类型 */ /** 循环类型 */
export type LoopType = 'finite' | 'infinite' export type LoopType = 'finite' | 'infinite'
...@@ -33,6 +33,8 @@ export interface ScenarioApiConfig { ...@@ -33,6 +33,8 @@ export interface ScenarioApiConfig {
headers?: Record<string, string> | null headers?: Record<string, string> | null
body?: string | null body?: string | null
threadCount: number threadCount: number
/** mix 场景概率分配权重(weight=0 不参与随机) */
weight?: number
assertions?: AssertionRule[] assertions?: AssertionRule[]
uniqueFields?: UniqueFieldRule[] | null uniqueFields?: UniqueFieldRule[] | null
} }
...@@ -388,7 +390,7 @@ export interface PerformanceTask { ...@@ -388,7 +390,7 @@ export interface PerformanceTask {
steps?: TransactionStep[] | null steps?: TransactionStep[] | null
transactionFailPolicy?: string | null transactionFailPolicy?: string | null
transactionTimeout?: number | null transactionTimeout?: number | null
/** 场景类型(burst=瞬时并发 / endurance=长稳压测 / null=标准) */ /** 场景类型(burst=瞬时并发 / endurance=长稳压测 / mix=混合场景 / null=标准) */
scenarioType?: PerfScenarioType scenarioType?: PerfScenarioType
/** endurance 场景接口配置 */ /** endurance 场景接口配置 */
scenarioApis?: ScenarioApiConfig[] | null scenarioApis?: ScenarioApiConfig[] | null
......
...@@ -180,6 +180,7 @@ ...@@ -180,6 +180,7 @@
<el-descriptions-item label="场景类型 Scenario"> <el-descriptions-item label="场景类型 Scenario">
<el-tag v-if="report.summary.scenarioType === 'burst'" type="warning" size="small">瞬时并发 Burst</el-tag> <el-tag v-if="report.summary.scenarioType === 'burst'" type="warning" size="small">瞬时并发 Burst</el-tag>
<el-tag v-else-if="report.summary.scenarioType === 'endurance'" type="danger" size="small">长稳压测 Endurance</el-tag> <el-tag v-else-if="report.summary.scenarioType === 'endurance'" type="danger" size="small">长稳压测 Endurance</el-tag>
<el-tag v-else-if="report.summary.scenarioType === 'mix'" type="primary" size="small">混合场景 Mix</el-tag>
<span v-else style="color: #909399;">标准 Standard</span> <span v-else style="color: #909399;">标准 Standard</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="并发数 Concurrency">{{ report.summary.concurrency }}</el-descriptions-item> <el-descriptions-item label="并发数 Concurrency">{{ report.summary.concurrency }}</el-descriptions-item>
...@@ -349,12 +350,12 @@ ...@@ -349,12 +350,12 @@
</el-table> </el-table>
</el-card> </el-card>
<!-- 接口级统计明细(endurance 多接口场景) --> <!-- 接口级统计明细(endurance 长稳 / mix 混合 多接口场景) -->
<template v-if="report.apiSummary && report.apiSummary.length"> <template v-if="report.apiSummary && report.apiSummary.length">
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="chart-card">
<template #header> <template #header>
<span>接口级统计明细 API Summary长稳压测多接口</span> <span>接口级统计明细 API Summary多接口按接口统计</span>
<el-tag type="danger" size="small" style="margin-left: 8px; vertical-align: middle;">Endurance</el-tag> <el-tag :type="report.summary.scenarioType === 'mix' ? 'primary' : 'danger'" size="small" style="margin-left: 8px; vertical-align: middle;">{{ report.summary.scenarioType === 'mix' ? 'Mix' : 'Endurance' }}</el-tag>
</template> </template>
<el-table :data="report.apiSummary" size="small" stripe border> <el-table :data="report.apiSummary" size="small" stripe border>
<el-table-column prop="name" label="接口名称 API" min-width="140" show-overflow-tooltip /> <el-table-column prop="name" label="接口名称 API" min-width="140" show-overflow-tooltip />
...@@ -1311,9 +1312,9 @@ function buildReportHtml(r: PerformanceReportResponse): string { ...@@ -1311,9 +1312,9 @@ function buildReportHtml(r: PerformanceReportResponse): string {
</table> </table>
</div>` : '' </div>` : ''
// F. 接口级统计明细表(长稳多接口,9 列) // F. 接口级统计明细表(长稳/混合多接口,9 列)
const apiSummaryHtml = r.apiSummary?.length ? `<div class="section"> const apiSummaryHtml = r.apiSummary?.length ? `<div class="section">
<h2>接口级统计明细(长稳压测多接口)</h2> <h2>接口级统计明细(多接口按接口统计)</h2>
<table class="data-table"> <table class="data-table">
<colgroup><col style="width:20%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"></colgroup> <colgroup><col style="width:20%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"><col style="width:10%"></colgroup>
<tr><th>接口名称</th><th class="num">总请求</th><th class="num">成功</th><th class="num">失败</th><th class="num">错误率</th><th class="num">TPS</th><th class="num">平均(ms)</th><th class="num">最小(ms)</th><th class="num">最大(ms)</th></tr> <tr><th>接口名称</th><th class="num">总请求</th><th class="num">成功</th><th class="num">失败</th><th class="num">错误率</th><th class="num">TPS</th><th class="num">平均(ms)</th><th class="num">最小(ms)</th><th class="num">最大(ms)</th></tr>
...@@ -1360,6 +1361,7 @@ function buildReportHtml(r: PerformanceReportResponse): string { ...@@ -1360,6 +1361,7 @@ function buildReportHtml(r: PerformanceReportResponse): string {
<tr><td class="label-cell">目标 URL</td><td>${r.summary.targetUrl}</td></tr> <tr><td class="label-cell">目标 URL</td><td>${r.summary.targetUrl}</td></tr>
<tr><td class="label-cell">HTTP 方法</td><td>${r.summary.method}</td></tr> <tr><td class="label-cell">HTTP 方法</td><td>${r.summary.method}</td></tr>
<tr><td class="label-cell">压测模式</td><td>${r.summary.mode}</td></tr> <tr><td class="label-cell">压测模式</td><td>${r.summary.mode}</td></tr>
<tr><td class="label-cell">场景类型</td><td>${r.summary.scenarioType === 'mix' ? '混合场景 Mix' : (r.summary.scenarioType === 'burst' ? '瞬时并发 Burst' : (r.summary.scenarioType === 'endurance' ? '长稳压测 Endurance' : '标准 Standard'))}</td></tr>
<tr><td class="label-cell">并发数</td><td>${r.summary.concurrency}</td></tr> <tr><td class="label-cell">并发数</td><td>${r.summary.concurrency}</td></tr>
<tr><td class="label-cell">开始时间</td><td>${r.summary.startTime || '-'}</td></tr> <tr><td class="label-cell">开始时间</td><td>${r.summary.startTime || '-'}</td></tr>
<tr><td class="label-cell">结束时间</td><td>${r.summary.endTime || '-'}</td></tr> <tr><td class="label-cell">结束时间</td><td>${r.summary.endTime || '-'}</td></tr>
......
...@@ -47,6 +47,7 @@ ...@@ -47,6 +47,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.scenarioType === 'burst'" type="warning" size="small">瞬时并发</el-tag> <el-tag v-if="row.scenarioType === 'burst'" type="warning" size="small">瞬时并发</el-tag>
<el-tag v-else-if="row.scenarioType === 'endurance'" type="danger" size="small">长稳压测</el-tag> <el-tag v-else-if="row.scenarioType === 'endurance'" type="danger" size="small">长稳压测</el-tag>
<el-tag v-else-if="row.scenarioType === 'mix'" type="primary" size="small">混合场景</el-tag>
<span v-else style="color: #909399; font-size: 12px;">标准</span> <span v-else style="color: #909399; font-size: 12px;">标准</span>
</template> </template>
</el-table-column> </el-table-column>
...@@ -204,6 +205,7 @@ ...@@ -204,6 +205,7 @@
<el-option label="标准模式" value="" /> <el-option label="标准模式" value="" />
<el-option label="瞬时并发(Burst)" value="burst" /> <el-option label="瞬时并发(Burst)" value="burst" />
<el-option label="长稳压测(Endurance)" value="endurance" /> <el-option label="长稳压测(Endurance)" value="endurance" />
<el-option label="混合场景(Mix)" value="mix" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
...@@ -329,17 +331,21 @@ ...@@ -329,17 +331,21 @@
</el-row> </el-row>
</template> </template>
<!-- ==================== 长稳压测配置 ==================== --> <!-- ==================== 长稳压测 / 混合场景配置 ==================== -->
<template v-if="form.scenarioType === 'endurance'"> <template v-if="form.scenarioType === 'endurance' || form.scenarioType === 'mix'">
<el-divider content-position="left">长稳压测配置</el-divider> <el-divider content-position="left">{{ form.scenarioType === 'mix' ? '混合场景配置(权重随机调度)' : '长稳压测配置' }}</el-divider>
<!-- 多接口编辑器 --> <!-- 多接口编辑器 -->
<div class="txn-tip"> <div class="txn-tip" v-if="form.scenarioType === 'endurance'">
配置多个接口,每个接口分配独立的虚拟用户数。总并发 = 各接口线程数之和。 配置多个接口,每个接口分配独立的虚拟用户数。总并发 = 各接口线程数之和。
</div> </div>
<div class="txn-tip" v-if="form.scenarioType === 'mix'">
几百个虚拟用户并发运行,每个迭代按权重随机选择一个接口(JMeter 概率分配器)。
登录请勾选「需要登录」,将作为前置操作复用 token,不应放入下面的权重池。
</div>
<div v-for="(api, aIdx) in form.scenarioApis" :key="aIdx" class="txn-step"> <div v-for="(api, aIdx) in form.scenarioApis" :key="aIdx" class="txn-step">
<div class="txn-step-header"> <div class="txn-step-header">
<el-input v-model="api.name" placeholder="接口名称,如 登录" style="width: 200px" /> <el-input v-model="api.name" :placeholder="aIdx === 0 && form.scenarioType === 'mix' ? '接口名称,如 预定' : '接口名称,如 登录'" style="width: 200px" />
<el-select v-model="api.method" style="width: 100px"> <el-select v-model="api.method" style="width: 100px">
<el-option label="GET" value="GET" /> <el-option label="GET" value="GET" />
<el-option label="POST" value="POST" /> <el-option label="POST" value="POST" />
...@@ -354,7 +360,7 @@ ...@@ -354,7 +360,7 @@
</div> </div>
<el-input v-model="api.url" placeholder="目标 URL" /> <el-input v-model="api.url" placeholder="目标 URL" />
<el-row :gutter="12"> <el-row :gutter="12">
<el-col :span="8"> <el-col :span="8" v-if="form.scenarioType === 'endurance'">
<el-form-item> <el-form-item>
<template #label> <template #label>
虚拟用户数 虚拟用户数
...@@ -363,13 +369,31 @@ ...@@ -363,13 +369,31 @@
<el-input-number v-model="api.threadCount" :min="1" :max="1000" style="width: 100%" /> <el-input-number v-model="api.threadCount" :min="1" :max="1000" style="width: 100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="16"> <el-col :span="8" v-if="form.scenarioType === 'mix'">
<el-form-item>
<template #label>
权重
<FieldTip content="该接口被随机选中的相对概率(越大越常被选中)。设为 0 则不参与随机" />
</template>
<el-input-number v-model="api.weight" :min="0" :max="100000" :step="1" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="form.scenarioType === 'mix' ? 16 : 16">
<el-input v-model="api.headers" type="textarea" :rows="1" placeholder="请求头(JSON),可选,如 {&quot;Content-Type&quot;: &quot;application/json&quot;}" /> <el-input v-model="api.headers" type="textarea" :rows="1" placeholder="请求头(JSON),可选,如 {&quot;Content-Type&quot;: &quot;application/json&quot;}" />
</el-col> </el-col>
</el-row> </el-row>
<el-input v-model="api.body" type="textarea" :rows="2" placeholder="请求体(JSON),可选" /> <el-input v-model="api.body" type="textarea" :rows="2" placeholder="请求体(JSON),可选" />
</div> </div>
<el-button type="primary" plain :icon="Plus" @click="addScenarioApi">添加接口</el-button> <el-button type="primary" plain :icon="Plus" @click="addScenarioApi">{{ form.scenarioType === 'mix' ? '添加接口' : '添加接口' }}</el-button>
<!-- mix 权重合计提示 -->
<el-alert
v-if="form.scenarioType === 'mix' && form.scenarioApis.length"
:title="`权重合计:${scenarioWeightTotal()}(选中的相对概率由各接口权重占比决定,weight=0 不参与)`"
type="info"
:closable="false"
show-icon
style="margin-top: 12px;"
/>
<!-- 思考时间 --> <!-- 思考时间 -->
<el-divider content-position="left">思考时间</el-divider> <el-divider content-position="left">思考时间</el-divider>
...@@ -418,7 +442,7 @@ ...@@ -418,7 +442,7 @@
</template> </template>
<!-- ==================== CSV 参数化(全场景通用) ==================== --> <!-- ==================== CSV 参数化(全场景通用) ==================== -->
<template v-if="form.scenarioType === 'burst' || form.scenarioType === 'endurance' || !form.scenarioType"> <template v-if="form.scenarioType === 'burst' || form.scenarioType === 'endurance' || form.scenarioType === 'mix' || !form.scenarioType">
<el-divider content-position="left">CSV 参数化</el-divider> <el-divider content-position="left">CSV 参数化</el-divider>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="6"> <el-col :span="6">
...@@ -714,9 +738,9 @@ interface TaskForm { ...@@ -714,9 +738,9 @@ interface TaskForm {
transactionFailPolicy: string transactionFailPolicy: string
/** 单个事务超时(秒) */ /** 单个事务超时(秒) */
transactionTimeout: number transactionTimeout: number
/** 场景类型:null=标准 / 'burst'=瞬时并发 / 'endurance'=长稳压测 */ /** 场景类型:null=标准 / 'burst'=瞬时并发 / 'endurance'=长稳压测 / 'mix'=混合场景 */
scenarioType: PerfScenarioType scenarioType: PerfScenarioType
/** endurance 多接口配置 */ /** endurance/mix 多接口配置 */
scenarioApis: ScenarioApiFormItem[] scenarioApis: ScenarioApiFormItem[]
/** 循环类型(burst) */ /** 循环类型(burst) */
loopType: LoopType loopType: LoopType
...@@ -728,7 +752,7 @@ interface TaskForm { ...@@ -728,7 +752,7 @@ interface TaskForm {
synchronizingTimerTimeout: number synchronizingTimerTimeout: number
/** 错误动作(burst) */ /** 错误动作(burst) */
errorAction: ErrorAction errorAction: ErrorAction
/** 思考时间(endurance) */ /** 思考时间(endurance/mix) */
thinkTimeEnabled: boolean thinkTimeEnabled: boolean
thinkTimeMin: number thinkTimeMin: number
thinkTimeMax: number thinkTimeMax: number
...@@ -741,7 +765,7 @@ interface TaskForm { ...@@ -741,7 +765,7 @@ interface TaskForm {
requestDetailEnabled: RequestDetailMode requestDetailEnabled: RequestDetailMode
} }
/** endurance 场景接口表单项 */ /** endurance/mix 场景接口表单项 */
interface ScenarioApiFormItem { interface ScenarioApiFormItem {
name: string name: string
method: string method: string
...@@ -749,6 +773,8 @@ interface ScenarioApiFormItem { ...@@ -749,6 +773,8 @@ interface ScenarioApiFormItem {
headers: string headers: string
body: string body: string
threadCount: number threadCount: number
/** mix 场景概率分配权重(endurance 忽略) */
weight: number
assertions: any[] assertions: any[]
} }
...@@ -917,6 +943,7 @@ async function openEditDialog(task: PerformanceTaskListItem) { ...@@ -917,6 +943,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
headers: a.headers ? JSON.stringify(a.headers, null, 2) : '', headers: a.headers ? JSON.stringify(a.headers, null, 2) : '',
body: a.body ? (typeof a.body === 'string' ? a.body : JSON.stringify(a.body, null, 2)) : '', body: a.body ? (typeof a.body === 'string' ? a.body : JSON.stringify(a.body, null, 2)) : '',
threadCount: a.threadCount ?? 1, threadCount: a.threadCount ?? 1,
weight: a.weight ?? 1,
assertions: a.assertions || [], assertions: a.assertions || [],
})), })),
loopType: fullTask.loopType || 'finite', loopType: fullTask.loopType || 'finite',
...@@ -961,9 +988,13 @@ function onScenarioTypeChange(val: any) { ...@@ -961,9 +988,13 @@ function onScenarioTypeChange(val: any) {
if (type === 'endurance' && !form.scenarioApis.length && form.targetUrl) { if (type === 'endurance' && !form.scenarioApis.length && form.targetUrl) {
addScenarioApi() addScenarioApi()
} }
if (type === 'mix' && !form.scenarioApis.length) {
// 混合场景:自动添加一个业务接口(权重默认 1),用户可继续添加并调整权重
addScenarioApi()
}
} }
/** 添加 endurance 场景接口 */ /** 添加 endurance/mix 场景接口 */
function addScenarioApi() { function addScenarioApi() {
form.scenarioApis.push({ form.scenarioApis.push({
name: '', name: '',
...@@ -972,10 +1003,16 @@ function addScenarioApi() { ...@@ -972,10 +1003,16 @@ function addScenarioApi() {
headers: '', headers: '',
body: '', body: '',
threadCount: form.concurrency || 1, threadCount: form.concurrency || 1,
weight: 1,
assertions: [], assertions: [],
}) })
} }
/** mix 场景权重合计 */
function scenarioWeightTotal() {
return form.scenarioApis.reduce((sum, a) => sum + (a.weight || 0), 0)
}
/** 上移/下移场景接口 */ /** 上移/下移场景接口 */
function moveScenarioApi(index: number, dir: number) { function moveScenarioApi(index: number, dir: number) {
const target = index + dir const target = index + dir
...@@ -1205,25 +1242,28 @@ async function handleSave() { ...@@ -1205,25 +1242,28 @@ async function handleSave() {
transactionTimeout: form.taskType === 'transaction' ? form.transactionTimeout : null, transactionTimeout: form.taskType === 'transaction' ? form.transactionTimeout : null,
// 场景增强字段 // 场景增强字段
scenarioType: form.scenarioType, scenarioType: form.scenarioType,
scenarioApis: form.scenarioType === 'endurance' ? form.scenarioApis.map(a => ({ scenarioApis: form.scenarioType === 'endurance' || form.scenarioType === 'mix'
name: a.name, ? form.scenarioApis.map(a => ({
method: a.method || 'GET', name: a.name,
url: a.url, method: a.method || 'GET',
headers: a.headers ? safeJsonParse(a.headers) : null, url: a.url,
body: a.body ? (safeJsonParse(a.body) ?? a.body) : null, headers: a.headers ? safeJsonParse(a.headers) : null,
threadCount: a.threadCount || 1, body: a.body ? (safeJsonParse(a.body) ?? a.body) : null,
assertions: a.assertions || [], threadCount: a.threadCount || 1,
})) : null, // mix 场景透传权重(endurance 保留默认值 1,后端忽略)
weight: form.scenarioType === 'mix' ? (a.weight ?? 1) : undefined,
assertions: a.assertions || [],
})) : null,
loopType: form.scenarioType === 'burst' ? form.loopType : null, loopType: form.scenarioType === 'burst' ? form.loopType : null,
loopCount: form.scenarioType === 'burst' && form.loopType === 'finite' ? form.loopCount : null, loopCount: form.scenarioType === 'burst' && form.loopType === 'finite' ? form.loopCount : null,
synchronizingTimerEnabled: form.scenarioType === 'burst' ? form.synchronizingTimerEnabled : null, synchronizingTimerEnabled: form.scenarioType === 'burst' ? form.synchronizingTimerEnabled : null,
synchronizingTimerCount: form.scenarioType === 'burst' && form.synchronizingTimerEnabled ? form.synchronizingTimerCount : null, synchronizingTimerCount: form.scenarioType === 'burst' && form.synchronizingTimerEnabled ? form.synchronizingTimerCount : null,
synchronizingTimerTimeout: form.scenarioType === 'burst' && form.synchronizingTimerEnabled ? form.synchronizingTimerTimeout : null, synchronizingTimerTimeout: form.scenarioType === 'burst' && form.synchronizingTimerEnabled ? form.synchronizingTimerTimeout : null,
errorAction: form.scenarioType === 'burst' ? form.errorAction : null, errorAction: form.scenarioType === 'burst' ? form.errorAction : null,
thinkTimeEnabled: form.scenarioType === 'endurance' ? form.thinkTimeEnabled : null, thinkTimeEnabled: form.scenarioType === 'endurance' || form.scenarioType === 'mix' ? form.thinkTimeEnabled : null,
thinkTimeMin: form.scenarioType === 'endurance' && form.thinkTimeEnabled ? form.thinkTimeMin : null, thinkTimeMin: (form.scenarioType === 'endurance' || form.scenarioType === 'mix') && form.thinkTimeEnabled ? form.thinkTimeMin : null,
thinkTimeMax: form.scenarioType === 'endurance' && form.thinkTimeEnabled ? form.thinkTimeMax : null, thinkTimeMax: (form.scenarioType === 'endurance' || form.scenarioType === 'mix') && form.thinkTimeEnabled ? form.thinkTimeMax : null,
thinkTimeDistribution: form.scenarioType === 'endurance' && form.thinkTimeEnabled ? form.thinkTimeDistribution : null, thinkTimeDistribution: (form.scenarioType === 'endurance' || form.scenarioType === 'mix') && form.thinkTimeEnabled ? form.thinkTimeDistribution : null,
csvParameterizationEnabled: form.csvParameterizationEnabled, csvParameterizationEnabled: form.csvParameterizationEnabled,
csvContent: form.csvParameterizationEnabled ? form.csvContent : null, csvContent: form.csvParameterizationEnabled ? form.csvContent : null,
csvVariableMapping: form.csvParameterizationEnabled && form.csvVariableMapping csvVariableMapping: form.csvParameterizationEnabled && form.csvVariableMapping
...@@ -1313,7 +1353,7 @@ function modeLabel(mode: string) { ...@@ -1313,7 +1353,7 @@ function modeLabel(mode: string) {
/** 场景标签 */ /** 场景标签 */
function scenarioLabel(scenario: string | null) { function scenarioLabel(scenario: string | null) {
const map: Record<string, string> = { burst: '瞬时并发', endurance: '长稳压测' } const map: Record<string, string> = { burst: '瞬时并发', endurance: '长稳压测', mix: '混合场景' }
return scenario ? (map[scenario] || scenario) : '标准' return scenario ? (map[scenario] || scenario) : '标准'
} }
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论