Files
chongming-core/test/bench_http.py
T

204 lines
8.2 KiB
Python
Raw 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.
"""
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
运行:
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()))