Files

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())