Files
MeshSate/tests/benchmark.py
zhaolei 16f134e328 feat: MeshSafe 文件摆渡安全卫士 v1.0 初始提交
核心功能:
- 6 层检测管线: L0 结构分析 → L1 ClamAV → L2 文档深度检测 → L3 关键词匹配 → L4 YARA → L5 PE/二进制
- FastAPI + PostgreSQL + Redis + Docker Compose 架构
- 客户端 HMAC-SHA256 签名鉴权 + 限流 + 幂等性
- 控制台 UI (登录/Dashboard/诊断中心/策略管理/任务列表/测试中心/产品介绍页)
- 三层诊断定时器 (error_scan 30s / db_aggregate 30s / container_check 5min)

并发与稳定性修复:
- 诊断定时器 Redis 时间槽位锁 (多 worker 同一槽位只有一个执行)
- JSONL 存储 fcntl 跨进程排他锁 + 容错解码 + 原子重写
- 移除 SQLite fallback, 统一 PostgreSQL
- API_CLIENTS 脱敏为环境变量, .env 已 .gitignore
2026-10-28 19:07:11 +08:00

662 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""MeshSafe 并发压测脚本。
用法:
python tests/benchmark.py # 跑全部默认场景
python tests/benchmark.py --concurrent 50 # 只跑 50 并发
python tests/benchmark.py --concurrent 50 100 # 跑 50 和 100 两档
python tests/benchmark.py --batch-sizes 10 50 100 # 批量档
python tests/benchmark.py --base-url http://127.0.0.1:44380
python tests/benchmark.py --report out.json # 额外输出 JSON 报告
原理:
httpx.AsyncClient + asyncio.Semaphore 控制并发数
样本从 tests/samples/ 自动扫描,按大小分档
大文件(5MB)现场 os.urandom 生成后自动清理
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import random
import statistics
import sys
import tempfile
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple
import httpx
# ---------------------------------------------------------------------------
# 配置常量
# ---------------------------------------------------------------------------
DEFAULT_BASE_URL = "http://127.0.0.1:44380"
SAMPLES_DIR = Path(__file__).resolve().parent / "samples"
DEFAULT_TIMEOUT = 60.0 # 单个请求 60s 超时
WARMUP_COUNT = 5 # 预热次数(不计入统计)
# 大小分档(字节)
SIZE_TINY = 2 * 1024 # ≤ 2KB
SIZE_SMALL = 16 * 1024 # 2KB ~ 16KB
SIZE_MEDIUM = 100 * 1024 # 16KB ~ 100KB
SIZE_LARGE = 5 * 1024 * 1024 # ≥ 5MB(现场生成)
# ---------------------------------------------------------------------------
# 数据类
# ---------------------------------------------------------------------------
@dataclass
class SampleFile:
path: Path
name: str
size: int
size_bucket: str # tiny / small / medium / large
@dataclass
class RequestResult:
idx: int
start_ts: float
end_ts: float
duration_ms: int
status_code: int
ok: bool
error: Optional[str] = None
files_count: int = 0
avg_file_ms: int = 0
@dataclass
class ScenarioResult:
name: str
concurrent: int
files_per_request: int
size_bucket: str
started_at: str
finished_at: str
total_duration_ms: int
results: List[RequestResult] = field(default_factory=list)
# 聚合统计
@property
def total_requests(self) -> int:
return len(self.results)
@property
def total_files(self) -> int:
return sum(r.files_count for r in self.results)
@property
def qps(self) -> float:
if self.total_duration_ms <= 0:
return 0.0
return self.total_requests / (self.total_duration_ms / 1000.0)
@property
def files_per_sec(self) -> float:
if self.total_duration_ms <= 0:
return 0.0
return self.total_files / (self.total_duration_ms / 1000.0)
@property
def latencies_ms(self) -> List[int]:
return [r.duration_ms for r in self.results if r.ok]
@property
def avg_ms(self) -> float:
data = self.latencies_ms
return statistics.mean(data) if data else 0.0
@property
def p50_ms(self) -> float:
data = self.latencies_ms
return self._percentile(data, 50) if data else 0.0
@property
def p95_ms(self) -> float:
data = self.latencies_ms
return self._percentile(data, 95) if data else 0.0
@property
def p99_ms(self) -> float:
data = self.latencies_ms
return self._percentile(data, 99) if data else 0.0
@property
def error_rate(self) -> float:
if not self.results:
return 0.0
bad = sum(1 for r in self.results if not r.ok)
return bad / len(self.results)
@property
def slowest(self) -> Optional[RequestResult]:
oks = [r for r in self.results if r.ok]
return max(oks, key=lambda r: r.duration_ms) if oks else None
@staticmethod
def _percentile(data: List[int], pct: float) -> float:
if not data:
return 0.0
s = sorted(data)
k = (len(s) - 1) * (pct / 100.0)
f = int(k)
c = f + 1 if f + 1 < len(s) else f
if f == c:
return float(s[f])
return float(s[f] * (c - k) + s[c] * (k - f))
# ---------------------------------------------------------------------------
# 样本加载
# ---------------------------------------------------------------------------
def load_samples() -> List[SampleFile]:
"""扫描 tests/samples/ 下所有文件(跳过 index.json 和 _scan_results.json)。"""
samples: List[SampleFile] = []
if not SAMPLES_DIR.exists():
return samples
for p in SAMPLES_DIR.iterdir():
if not p.is_file():
continue
if p.name.startswith("_") or p.suffix == ".json":
continue
size = p.stat().st_size
if size <= SIZE_TINY:
bucket = "tiny"
elif size <= SIZE_SMALL:
bucket = "small"
elif size <= SIZE_MEDIUM:
bucket = "medium"
else:
bucket = "large"
samples.append(SampleFile(path=p, name=p.name, size=size, size_bucket=bucket))
return samples
def pick_samples(
all_samples: List[SampleFile],
count: int,
bucket: str = "mixed",
with_large_placeholder: bool = False,
) -> List[SampleFile]:
"""从样本池里挑 count 个文件。
bucket: tiny / small / medium / large / mixed
with_large_placeholder: 如果 bucket=large,返回一个 placeholder 让调用者生成大文件
"""
if not all_samples and not with_large_placeholder:
return []
pool = all_samples
if bucket != "mixed":
pool = [s for s in all_samples if s.size_bucket == bucket]
if not pool:
# 如果指定 bucket 没文件,fallback 到 mixed
pool = all_samples
picked = random.choices(pool, k=count)
return list(picked)
def make_large_temp_file(size_bytes: int = 5 * 1024 * 1024) -> Tuple[tempfile.NamedTemporaryFile, str]:
"""生成大文件(5MB),用于 large bucket 测试。"""
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".bin")
tmp.write(os.urandom(size_bytes))
tmp.flush()
return tmp, tmp.name
# ---------------------------------------------------------------------------
# 核心压测客户端
# ---------------------------------------------------------------------------
class BenchmarkClient:
def __init__(
self,
base_url: str,
username: str = "admin",
password: str = "admin123",
timeout: float = DEFAULT_TIMEOUT,
) -> None:
self.base_url = base_url.rstrip("/")
self.username = username
self.password = password
self.timeout = timeout
self.cookies: httpx.Cookies = httpx.Cookies()
async def login(self) -> None:
"""登录 console,获取 session cookie。"""
async with httpx.AsyncClient(timeout=self.timeout, verify=False, follow_redirects=True) as c:
# 先 GET 登录页拿 csrf(如果有)
await c.get(f"{self.base_url}/console/login")
r = await c.post(
f"{self.base_url}/console/login",
data={"username": self.username, "password": self.password},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
self.cookies = c.cookies
if r.status_code not in (200, 302):
raise RuntimeError(f"登录失败: HTTP {r.status_code}")
async def upload_one(
self,
client: httpx.AsyncClient,
files: List[Tuple[str, bytes, str]], # [(filename, content_bytes, content_type), ...]
) -> RequestResult:
"""一次 upload 请求(可带多文件)。"""
idx = random.randint(0, 999999)
t0 = time.perf_counter()
file_handles = []
try:
# 构造 files 参数
upload_files = [("files", (name, content, ct)) for name, content, ct in files]
r = await client.post(
f"{self.base_url}/console/test/upload",
files=upload_files,
)
t1 = time.perf_counter()
ok = r.status_code == 200
error = None
files_count = len(files)
if ok:
try:
body = r.json()
if isinstance(body, dict):
results_list = body.get("results", [])
ok = all(it.get("verdict") not in ("error",) for it in results_list)
except Exception as exc:
ok = False
error = f"JSON decode error: {exc}"
else:
error = f"HTTP {r.status_code}"
avg_file_ms = int((t1 - t0) * 1000 / max(files_count, 1))
return RequestResult(
idx=idx,
start_ts=t0,
end_ts=t1,
duration_ms=int((t1 - t0) * 1000),
status_code=r.status_code,
ok=ok,
error=error,
files_count=files_count,
avg_file_ms=avg_file_ms,
)
except Exception as exc: # noqa: BLE001
t1 = time.perf_counter()
return RequestResult(
idx=idx,
start_ts=t0,
end_ts=t1,
duration_ms=int((t1 - t0) * 1000),
status_code=0,
ok=False,
error=str(exc),
files_count=len(files),
)
finally:
for fh in file_handles:
try:
fh.close()
except Exception:
pass
async def run_scenario(
self,
name: str,
concurrent: int,
total_requests: int,
files_per_request: int,
size_bucket: str,
samples: List[SampleFile],
) -> ScenarioResult:
"""跑一个场景。
concurrent: 并发度(同时在飞的请求数上限)
total_requests: 总共发多少请求
files_per_request: 每个请求带几个文件(1=单文件场景,>1=批量)
size_bucket: tiny/small/medium/large/mixed
samples: 样本池
"""
result = ScenarioResult(
name=name,
concurrent=concurrent,
files_per_request=files_per_request,
size_bucket=size_bucket,
started_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
finished_at="",
total_duration_ms=0,
)
sem = asyncio.Semaphore(concurrent)
async def _worker(idx: int, client: httpx.AsyncClient) -> RequestResult:
async with sem:
return await self._make_request(client, files_per_request, size_bucket, samples)
connector = httpx.Limits(max_connections=concurrent * 2, max_keepalive_connections=concurrent)
async with httpx.AsyncClient(
timeout=self.timeout,
verify=False,
follow_redirects=True,
cookies=self.cookies,
limits=connector,
) as client:
# 预热(不计入)
if WARMUP_COUNT > 0:
warmup_samples = pick_samples(samples, 1, size_bucket)
for _ in range(min(WARMUP_COUNT, total_requests)):
try:
warmup_files = [(s.name, s.path.read_bytes(), "application/octet-stream") for s in warmup_samples]
await self.upload_one(client, warmup_files)
except Exception:
pass
# 正式跑
t_start = time.perf_counter()
tasks = [asyncio.create_task(_worker(i, client)) for i in range(total_requests)]
for fut in asyncio.as_completed(tasks):
r = await fut
result.results.append(r)
t_end = time.perf_counter()
result.total_duration_ms = int((t_end - t_start) * 1000)
result.finished_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return result
async def _make_request(
self,
client: httpx.AsyncClient,
files_per_request: int,
size_bucket: str,
samples: List[SampleFile],
) -> RequestResult:
"""构造一个请求的文件列表,处理大文件临时生成。"""
# 决定这个请求里每个文件的 bucket
if size_bucket == "mixed":
# 混合:随机挑 30% 小、50% 中、20% 大(大的现场生成)
pass
picked = pick_samples(samples, files_per_request, size_bucket)
upload_files: List[Tuple[str, bytes, str]] = []
temp_files_to_clean: List[str] = []
for s in picked:
if size_bucket == "large" or (size_bucket == "mixed" and random.random() < 0.2 and s.size_bucket != "large"):
# 生成大文件 placeholder(用 .bin 模拟,如果没有 large bucket 样本)
tmp_path = None
large_candidates = [ps for ps in picked if ps.size_bucket == "large"]
if large_candidates:
real = random.choice(large_candidates)
upload_files.append((real.name, real.path.read_bytes(), "application/octet-stream"))
else:
tmp, tmp_path = make_large_temp_file(5 * 1024 * 1024)
upload_files.append((f"large_test_{tmp.name}.bin", Path(tmp.name).read_bytes(), "application/octet-stream"))
temp_files_to_clean.append(tmp.name)
tmp.close()
else:
upload_files.append((s.name, s.path.read_bytes(), "application/octet-stream"))
try:
return await self.upload_one(client, upload_files)
finally:
for tf in temp_files_to_clean:
try:
os.unlink(tf)
except Exception:
pass
# ---------------------------------------------------------------------------
# 报告格式化
# ---------------------------------------------------------------------------
def print_report(scenarios: List[ScenarioResult], base_url: str, workers: int = 4) -> None:
"""终端输出汇总报告。"""
print("\n" + "=" * 80)
print(" MeshSafe 并发压测报告")
print("=" * 80)
print(f" 目标: {base_url}")
print(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f" uvicorn: --workers {workers}")
print(f" DB pool: pool_size=20, max_overflow=30 (总上限 50)")
print(f" 预热: {WARMUP_COUNT} 次不计入")
print("-" * 80)
for sc in scenarios:
print(f"\n【{sc.name}】")
print(f" 并发数: {sc.concurrent}")
print(f" 请求文件数: {sc.files_per_request} 个/请求")
print(f" 文件大小档: {sc.size_bucket}")
print(f" 总请求数: {sc.total_requests}")
print(f" 总文件数: {sc.total_files}")
print(f" 总耗时: {sc.total_duration_ms} ms ({sc.total_duration_ms/1000:.2f}s)")
print(f" QPS: {sc.qps:.2f} req/s")
print(f" 文件吞吐: {sc.files_per_sec:.2f} files/s")
print(f" 单请求耗时:")
print(f" 平均: {sc.avg_ms:.0f} ms")
print(f" P50: {sc.p50_ms:.0f} ms")
print(f" P95: {sc.p95_ms:.0f} ms")
print(f" P99: {sc.p99_ms:.0f} ms")
print(f" 错误率: {sc.error_rate*100:.1f}%")
if sc.slowest:
slowest_msg = sc.slowest.error or "ok"
print(f" 最慢请求: {sc.slowest.duration_ms} ms ({slowest_msg[:60]})")
# 瓶颈分析
print("\n" + "-" * 80)
print("【瓶颈分析】")
_analyze(scenarios)
print("\n" + "=" * 80)
def _analyze(scenarios: List[ScenarioResult]) -> None:
if not scenarios:
return
for sc in scenarios:
if sc.concurrent == 1 and sc.files_per_request == 1 and sc.size_bucket in ("mixed", "medium"):
baseline = sc.avg_ms
print(f" 基准单文件串行: {baseline:.0f} ms")
# 并发场景看扩展效率
for sc in scenarios:
if sc.concurrent > 1 and sc.files_per_request == 1:
print(f" {sc.concurrent} 并发单文件:")
if sc.avg_ms < 300:
print(f" ✅ 响应快(avg={sc.avg_ms:.0f}ms)")
elif sc.avg_ms < 800:
print(f" ⚠️ 中等(avg={sc.avg_ms:.0f}ms)")
else:
print(f" 🔴 较慢(avg={sc.avg_ms:.0f}ms)—— 可能 uvicorn worker 不够或 DB pool 满")
if sc.error_rate > 0:
print(f" 🔴 错误率 {sc.error_rate*100:.1f}% —— 检查 DB 连接池 / ClamAV / YARA")
if sc.files_per_request > 1:
per_file = sc.avg_ms / sc.files_per_request
print(f" 批量 {sc.files_per_request} 文件·单用户:")
print(f" 总耗时 ≈ {sc.avg_ms:.0f}ms 单文件均摊 ≈ {per_file:.0f}ms")
if per_file > 300:
print(f" ⚠️ 批量内部 for 循环串行,单文件均摊偏高—— 可考虑 ThreadPoolExecutor 并行化")
else:
print(f" ✅ 批量表现正常")
def to_json_report(scenarios: List[ScenarioResult], base_url: str) -> dict:
return {
"generated_at": datetime.now().isoformat(),
"base_url": base_url,
"uvicorn_workers": 4,
"db_pool": {"pool_size": 20, "max_overflow": 30},
"scenarios": [
{
"name": sc.name,
"concurrent": sc.concurrent,
"files_per_request": sc.files_per_request,
"size_bucket": sc.size_bucket,
"started_at": sc.started_at,
"finished_at": sc.finished_at,
"total_duration_ms": sc.total_duration_ms,
"total_requests": sc.total_requests,
"total_files": sc.total_files,
"qps": round(sc.qps, 2),
"files_per_sec": round(sc.files_per_sec, 2),
"latency_ms": {
"avg": round(sc.avg_ms, 1),
"p50": round(sc.p50_ms, 1),
"p95": round(sc.p95_ms, 1),
"p99": round(sc.p99_ms, 1),
},
"error_rate": round(sc.error_rate, 4),
"slowest_ms": sc.slowest.duration_ms if sc.slowest else None,
}
for sc in scenarios
],
}
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
async def _main() -> None:
parser = argparse.ArgumentParser(description="MeshSafe 并发压测")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="MeshSafe 基地址")
parser.add_argument("--username", default="admin")
parser.add_argument("--password", default="admin123")
parser.add_argument(
"--concurrent",
nargs="+",
type=int,
default=[50, 100],
help="并发度档位(可多值)",
)
parser.add_argument(
"--batch-sizes",
nargs="+",
type=int,
default=[10, 50],
help="批量上传文件数(单用户·多文件场景)",
)
parser.add_argument(
"--requests-per-batch",
type=int,
default=5,
help="批量场景下发几个请求(每个请求带 N 文件)",
)
parser.add_argument(
"--requests-per-concurrent",
type=int,
default=2,
help="并发场景下每个并发度发几个请求",
)
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
parser.add_argument("--report", default=None, help="JSON 报告输出路径")
args = parser.parse_args()
# 1. 加载样本
all_samples = load_samples()
if not all_samples:
print(f"❌ tests/samples/ 下没找到样本文件")
sys.exit(1)
sizes = sorted([(s.size, s.size_bucket) for s in all_samples], reverse=True)
print(f"📦 样本池: {len(all_samples)} 个文件")
for bucket in ("tiny", "small", "medium", "large"):
count = sum(1 for _, b in sizes if b == bucket)
if count:
print(f" {bucket:7s}: {count} 个")
# 2. 登录
print(f"\n🔐 登录 {args.base_url} ...")
client = BenchmarkClient(base_url=args.base_url, username=args.username, password=args.password, timeout=args.timeout)
await client.login()
print(" ✅ 登录成功")
# 3. 跑场景
scenarios: List[ScenarioResult] = []
# 场景 A:基准串行(1 并发)
print("\n" + "=" * 80)
print("【场景 A】基准串行·单文件·混合大小")
print("=" * 80)
sc_a = await client.run_scenario(
name="A1. 基准串行·单文件",
concurrent=1,
total_requests=20,
files_per_request=1,
size_bucket="mixed",
samples=all_samples,
)
scenarios.append(sc_a)
# 场景 B:多用户并发·单文件
for conc in args.concurrent:
print(f"\n{'='*80}")
print(f"【场景 B】{conc} 并发·单文件·混合大小")
print("=" * 80)
sc = await client.run_scenario(
name=f"B.{conc}·并发单文件·混合",
concurrent=conc,
total_requests=conc * args.requests_per_concurrent,
files_per_request=1,
size_bucket="mixed",
samples=all_samples,
)
scenarios.append(sc)
# 场景 C:单用户·批量多文件
for batch_n in args.batch_sizes:
print(f"\n{'='*80}")
print(f"【场景 C】单用户·{batch_n} 文件批量上传")
print("=" * 80)
sc = await client.run_scenario(
name=f"C.{batch_n}·单用户批量",
concurrent=1,
total_requests=args.requests_per_batch,
files_per_request=batch_n,
size_bucket="mixed",
samples=all_samples,
)
scenarios.append(sc)
# 场景 D:多用户并发·批量
conc_mix = max(10, min(args.concurrent[0] // 5, 20))
batch_mix = 10
print(f"\n{'='*80}")
print(f"【场景 D】{conc_mix} 并发·{batch_mix} 文件/请求(最坏情况)")
print("=" * 80)
sc_d = await client.run_scenario(
name=f"D.{conc_mix}并发×{batch_mix}文件",
concurrent=conc_mix,
total_requests=conc_mix,
files_per_request=batch_mix,
size_bucket="mixed",
samples=all_samples,
)
scenarios.append(sc_d)
# 4. 输出报告
print_report(scenarios, args.base_url)
# 5. JSON 报告
if args.report:
report = to_json_report(scenarios, args.base_url)
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
Path(args.report).write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"\n📄 JSON 报告已保存: {args.report}")
if __name__ == "__main__":
asyncio.run(_main())