Files
chongming-core/test/bench_http.py
T

204 lines
8.2 KiB
Python
Raw Normal View History

2026-08-25 09:29:27 +08:00
"""
HTTP 端到端 RPC 压测脚本(chongming 网关全链路)
链路: 压测客户端(asyncio+httpx) → FastAPI 网关 :8000 → Zenoh Query/Reply → worker 进程
前置启动(均后台运行,需在仓库根目录):
1. Zenoh router: uv run python utils/worker-rs/scripts/zenoh_router.py
2. 网关(无热重载): uv run uvicorn chongming.app:app --host 0.0.0.0 --port 8000 --no-access-log
3. Worker: uv run chongming-worker --type demo --count 2
2026-08-25 09:29:27 +08:00
运行:
uv run python test/bench_http.py --concurrency 1,10,50,100,200 --duration 30
指标: RPS(总请求/秒)、p50/p95/p99/max 延迟(成功请求, ms)、错误率(非 2xx/超时/连接异常)、
结束后拉取网关侧 GET /api/v1/stats 做服务端对照。
"""
import argparse
import asyncio
import json
import statistics
import sys
import time
from dataclasses import dataclass, field
import httpx
DEFAULT_URL = "http://localhost:8000/api/v1/demo/hello?name=bench"
@dataclass
class Bench:
"""单档压测共享状态(asyncio 单线程内访问,无需加锁)"""
start: float = 0.0
total: int = 0 # 全部完成的请求(含错误/超时)
ok: int = 0 # 2xx 成功请求
timeouts: int = 0
errors: int = 0 # 非 2xx / 连接异常
status_dist: dict = field(default_factory=dict)
lats_ms: list = field(default_factory=list) # 成功请求延迟(ms)
def percentile(sorted_lats: list, p: float) -> float:
"""有序列表上的线性插值分位数(p 为 0~1)"""
if not sorted_lats:
return 0.0
idx = (len(sorted_lats) - 1) * p
lo = int(idx)
hi = min(lo + 1, len(sorted_lats) - 1)
frac = idx - lo
return sorted_lats[lo] * (1 - frac) + sorted_lats[hi] * frac
async def worker(client: httpx.AsyncClient, url: str, bench: Bench, end_time: float) -> None:
loop = asyncio.get_running_loop()
while True:
t0 = loop.time()
if t0 >= end_time:
return
try:
resp = await client.get(url, timeout=10.0)
except httpx.TimeoutException:
bench.timeouts += 1
continue
except httpx.HTTPError:
bench.errors += 1
continue
lat_ms = (loop.time() - t0) * 1000
if resp.status_code == 200:
bench.ok += 1
bench.lats_ms.append(lat_ms)
else:
bench.errors += 1
bench.status_dist[resp.status_code] = bench.status_dist.get(resp.status_code, 0) + 1
bench.total += 1
async def progress_reporter(bench: Bench, duration: float) -> None:
"""每 5s 打印进度(RPS/成功/错误),供长压测观测"""
while True:
await asyncio.sleep(5)
elapsed = time.perf_counter() - bench.start
if elapsed <= 0:
continue
print(f" [{elapsed:5.0f}s] RPS={bench.total / elapsed:7.1f} "
f"ok={bench.ok} err={bench.errors} timeout={bench.timeouts}", flush=True)
async def run_level(client: httpx.AsyncClient, url: str, concurrency: int,
duration: float, warmup: float) -> dict:
"""单档并发:warmup 预热(结果丢弃)→ duration 计时统计"""
bench = Bench()
async def phase(end_time: float) -> None:
tasks = [asyncio.create_task(worker(client, url, bench, end_time))
for _ in range(concurrency)]
await asyncio.gather(*tasks)
loop = asyncio.get_running_loop()
print(f" 预热 {warmup:.0f}s ...", flush=True)
bench.start = time.perf_counter()
await phase(loop.time() + warmup)
bench = Bench() # 丢弃预热结果,正式计时
bench.start = time.perf_counter()
print(f" 压测 {duration:.0f}s (并发 {concurrency}) ...", flush=True)
reporter = asyncio.create_task(progress_reporter(bench, duration))
await phase(loop.time() + duration)
reporter.cancel()
await asyncio.gather(reporter, return_exceptions=True)
elapsed = time.perf_counter() - bench.start
sorted_lats = sorted(bench.lats_ms)
total = bench.total
return {
"concurrency": concurrency,
"duration_s": round(elapsed, 2),
"total": total,
"ok": bench.ok,
"errors": bench.errors,
"timeouts": bench.timeouts,
"error_rate_pct": round((bench.errors + bench.timeouts) / total * 100, 2) if total else 0.0,
"rps": round(total / elapsed, 1) if elapsed else 0.0,
"p50_ms": round(percentile(sorted_lats, 0.50), 2),
"p95_ms": round(percentile(sorted_lats, 0.95), 2),
"p99_ms": round(percentile(sorted_lats, 0.99), 2),
"max_ms": round(sorted_lats[-1], 2) if sorted_lats else 0.0,
"avg_ms": round(statistics.fmean(bench.lats_ms), 2) if bench.lats_ms else 0.0,
"status_dist": dict(sorted(bench.status_dist.items())),
}
async def fetch_stats_snapshot(client: httpx.AsyncClient) -> dict:
"""拉取网关侧 /api/v1/stats 快照(失败返回空 dict)"""
try:
resp = await client.get("http://localhost:8000/api/v1/stats", timeout=5.0)
return resp.json() if resp.status_code == 200 else {}
except Exception:
return {}
async def main() -> int:
parser = argparse.ArgumentParser(description="chongming HTTP 端到端 RPC 压测")
parser.add_argument("--url", default=DEFAULT_URL, help="压测目标 URL")
parser.add_argument("--concurrency", default="100", help="并发数,逗号分隔多档(如 1,10,50,100,200)")
parser.add_argument("--duration", type=float, default=30.0, help="每档压测时长(秒)")
parser.add_argument("--warmup", type=float, default=3.0, help="每档预热时长(秒)")
parser.add_argument("--max-connections", type=int, default=100,
help="httpx 连接池上限(并发数超过此值时客户端会排队)")
parser.add_argument("--output", default=None, help="结果保存为 JSON 文件")
args = parser.parse_args()
levels = [int(c.strip()) for c in args.concurrency.split(",") if c.strip()]
print(f"压测目标: {args.url}")
print(f"并发档位: {levels} | 每档 {args.duration:.0f}s + 预热 {args.warmup:.0f}s")
results = []
limits = httpx.Limits(max_connections=args.max_connections,
max_keepalive_connections=args.max_connections)
async with httpx.AsyncClient(http2=False, limits=limits) as client:
for level in levels:
stats_before = await fetch_stats_snapshot(client)
result = await run_level(client, args.url, level, args.duration, args.warmup)
stats_after = await fetch_stats_snapshot(client)
delta = max(0, stats_after.get("total_calls", 0) - stats_before.get("total_calls", 0))
result["gateway_total_calls_delta"] = delta
results.append(result)
# ---- 汇总表格 ----
print("\n" + "=" * 88)
print(f"{'并发':>5} | {'RPS':>8} | {'avg(ms)':>8} | {'p50':>7} | {'p95':>7} | "
f"{'p99':>7} | {'max':>7} | {'错误率%':>7} | 网关侧计数差")
print("-" * 88)
for r in results:
print(f"{r['concurrency']:>5} | {r['rps']:>8.1f} | {r['avg_ms']:>8.2f} | {r['p50_ms']:>7.2f} | "
f"{r['p95_ms']:>7.2f} | {r['p99_ms']:>7.2f} | {r['max_ms']:>7.2f} | "
f"{r['error_rate_pct']:>7.2f} | {r.get('gateway_total_calls_delta', '-'):>10}")
print("=" * 88)
# ---- 网关侧统计对照 ----
async with httpx.AsyncClient() as client:
stats = await fetch_stats_snapshot(client)
if stats.get("latency"):
print("\n网关侧 /api/v1/stats 延迟统计(进程内聚合,全部档累计):")
for lat in stats["latency"]:
print(f" worker_type={lat['worker_type']:<8} count={lat['count']:<8} "
f"avg={lat['avg_ms']:<8.2f}ms max={lat['max_ms']:.2f}ms")
print(f" 累计调用: {stats.get('total_calls', 0)}")
for c in stats.get("calls", []):
if c["count"]:
print(f" subject={c['subject']:<24} status={c['status']} count={c['count']}")
else:
print("\n(网关侧 /api/v1/stats 无数据或不可用)")
if args.output:
with open(args.output, "w") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\n结果已保存: {args.output}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))