"""性能基线(迭代第 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())