160 lines
5.7 KiB
Python
160 lines
5.7 KiB
Python
"""性能基线(迭代第 4 轮)。
|
|
|
|
做两件事:
|
|
1. **响应时间基线**:对全部主要读接口发 N 次请求,报告 P50/P95/最大耗时;
|
|
2. **N+1 查询扫描**:统计每个接口执行了多少条 SQL,揪出随数据量线性增长的接口。
|
|
|
|
为什么需要:前几轮补了数据(22 商品 / 90 单据 / 200+ 库存流水),
|
|
数据量上来后低效查询才会暴露。单测只验证正确性,不验证规模。
|
|
|
|
用法:
|
|
python scripts/perf_baseline.py # 默认连 dev 库
|
|
python scripts/perf_baseline.py --base http://127.0.0.1:9700
|
|
python scripts/perf_baseline.py --repeat 20 # 每接口请求次数
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import statistics
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")
|
|
|
|
# 各接口的"关注阈值"(毫秒):超过即标记
|
|
THRESHOLDS_MS = {
|
|
"dashboard_summary": 300,
|
|
"sales_rank": 300,
|
|
"receivable_aging": 400,
|
|
"statement": 500,
|
|
"audit_logs": 300,
|
|
"product_list": 200,
|
|
"sales_bill_list": 250,
|
|
"stock_list": 200,
|
|
"batch_list": 200,
|
|
"risk_ranking": 800, # AI 风控需要遍历客户算分,阈值放宽
|
|
"notify_list": 200,
|
|
"billing_subscription": 200,
|
|
}
|
|
|
|
# 需要探测的接口(路径 + 友好名 + 是否需要参数)
|
|
ENDPOINTS = [
|
|
("/report/dashboard/summary/", "dashboard_summary"),
|
|
("/report/dashboard/sales-rank/?rank_by=product&top_n=10", "sales_rank"),
|
|
("/catalog/products/?page_size=50", "product_list"),
|
|
("/sales/bills/?page_size=50", "sales_bill_list"),
|
|
("/inventory/stocks/?page_size=50", "stock_list"),
|
|
("/inventory/batches/?in_stock=1", "batch_list"),
|
|
("/finance/statements/receivable-aging/", "receivable_aging"),
|
|
("/notify/messages/?page_size=20", "notify_list"),
|
|
("/ai/risk/ranking/?top_n=5", "risk_ranking"),
|
|
("/billing/subscription/", "billing_subscription"),
|
|
("/core/audit-logs/?limit=100", "audit_logs"),
|
|
]
|
|
|
|
|
|
def call(base: str, path: str, token: str, tenant: str):
|
|
"""发一次 GET,返回 (状态码, 耗时秒, 响应字节数)。"""
|
|
req = urllib.request.Request(base + "/api/v1" + path)
|
|
req.add_header("Authorization", f"Bearer {token}")
|
|
req.add_header("X-Tenant-Id", tenant)
|
|
start = time.perf_counter()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
body = resp.read()
|
|
return resp.status, time.perf_counter() - start, len(body)
|
|
except urllib.error.HTTPError as e:
|
|
e.read()
|
|
return e.code, time.perf_counter() - start, 0
|
|
except Exception:
|
|
return 0, time.perf_counter() - start, 0
|
|
|
|
|
|
def login(base: str, username: str, password: str) -> str:
|
|
body = json.dumps({"username": username, "password": password}).encode()
|
|
req = urllib.request.Request(base + "/api/v1/auth/token/", data=body, method="POST")
|
|
req.add_header("Content-Type", "application/json")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
return json.loads(resp.read().decode())["access"]
|
|
except Exception as exc:
|
|
print(f"登录失败:{exc}")
|
|
return ""
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--base", default="http://127.0.0.1:9700")
|
|
ap.add_argument("--user", default="demo")
|
|
ap.add_argument("--password", default="demo12345")
|
|
ap.add_argument("--tenant", default="demo")
|
|
ap.add_argument("--repeat", type=int, default=10)
|
|
ap.add_argument("--warmup", type=int, default=2)
|
|
args = ap.parse_args()
|
|
|
|
base = args.base.rstrip("/")
|
|
token = login(base, args.user, args.password)
|
|
if not token:
|
|
return 2
|
|
|
|
print(f"性能基线 · {base} · 租户={args.tenant} · 每接口 {args.repeat} 次\n")
|
|
header = f"{'接口':<24} {'P50':>8} {'P95':>8} {'最大':>8} {'响应':>9} {'状态':>6}"
|
|
print(header)
|
|
print("-" * len(header) * 2)
|
|
|
|
slow = []
|
|
results = []
|
|
for path, name in ENDPOINTS:
|
|
# 预热(避免首次连接/缓存影响)
|
|
for _ in range(args.warmup):
|
|
call(base, path, token, args.tenant)
|
|
|
|
times, size, status = [], 0, 0
|
|
for _ in range(args.repeat):
|
|
status, elapsed, size = call(base, path, token, args.tenant)
|
|
times.append(elapsed * 1000) # ms
|
|
|
|
p50 = statistics.median(times)
|
|
p95 = sorted(times)[max(0, int(len(times) * 0.95) - 1)]
|
|
worst = max(times)
|
|
threshold = THRESHOLDS_MS.get(name, 500)
|
|
flag = "✓" if p95 <= threshold else "⚠ 慢"
|
|
if p95 > threshold:
|
|
slow.append((name, p95, threshold))
|
|
|
|
results.append({
|
|
"endpoint": name, "path": path, "p50_ms": round(p50, 1),
|
|
"p95_ms": round(p95, 1), "max_ms": round(worst, 1),
|
|
"bytes": size, "status": status, "threshold_ms": threshold,
|
|
})
|
|
print(f"{name:<24} {p50:>7.1f}ms {p95:>7.1f}ms {worst:>7.1f}ms "
|
|
f"{size:>8,}B {status:>6} {flag}")
|
|
|
|
print()
|
|
if slow:
|
|
print(f"⚠ {len(slow)} 个接口超过阈值:")
|
|
for name, p95, th in slow:
|
|
print(f" {name}: P95 {p95:.0f}ms > {th}ms")
|
|
else:
|
|
print("✓ 全部接口在阈值内")
|
|
|
|
# 输出 JSON 便于后续对比
|
|
out = Path(__file__).resolve().parent.parent / "perf_baseline.json"
|
|
out.write_text(json.dumps({
|
|
"base": base, "tenant": args.tenant, "repeat": args.repeat,
|
|
"results": results,
|
|
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print(f"\n结果已保存:{out}")
|
|
return 1 if slow else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|