#WorkBuddy# WebSocket 压测报告全是假的?80 连接虚报成功率 89.69 个百分点,五道关口校出真值

压测报告刚发出去:成功率 100%,吞吐 21,324 条/秒,3.75 秒跑完 8 万条消息。看起来网关稳如泰山。半小时后生产环境告警:网关限流被触发了几万次,大量请求根本没被处理。
问题不在网关,在压测脚本。它只数了"收到了多少帧",从来没看过帧里写的什么。网关返回的 71,752 个 RATE_LIMITED 限流错误帧,被它原封不动地计入了"成功"。真实交付率只有 10.31%——报告里的 100% 虚报了 89.69 个百分点。
这不是故意夸张的段子,是我在 Windows + Python 3.13 + websockets 17.4 环境里真实跑出来的一组对照实验(自建合成网关,无任何真实业务数据,seed=20261114,全部数字可复现)。本文把朴素压测脚本会踩的坑逐一演示,并给出一份"每一个数字都能对账"的治理式压测方案。
朴素脚本的默认假设,在真实网关面前全部不成立:
实验网关模拟了真实限流语义:每连接一个令牌桶(容量 100,每秒补充 400),处理时延 2ms 起加指数抖动;另设 /serial 串行模式专测协调缺失。响应按请求 id 回显,ok=false 单独标记。

四个阶段的实测结果(80 连接、256B 消息、localhost、网关独立进程):
阶段 | 压测方式 | 报告口径 | 真实口径 |
|---|---|---|---|
A 朴素串行 | 1 连接 ping-pong × 3,000 | 62.6 条/秒,p50 15.69ms | 单连接基线,真实可信但只代表 1 条连接 |
B 朴素轰炸 | 80 连接先全发后收、不解析帧 | "成功率 100.00%",21,324.8 条/秒 | 真实交付 10.31%:71,752/80,000 帧是限流错误 |
C 治理式 | 阶梯爬坡 + 开环 100 条/秒/连接 + in-flight≤32 | — | 79,990 条供给 → 79,990 条交付(100.00%),0 限流 0 丢失,p50 86.08ms / p95 114.63ms / p99 124.30ms |
D 协调缺失 | 同速率闭环 vs 开环(/serial) | 闭环 p99 25.94ms | 开环 p99 4,199.58ms,少报 99.4% |
D 阶段的解释:/serial 模式下网关每连接只能串行处理约 62 条/秒,而目标速率是 100 条/秒——网关根本压不进这个速率。闭环脚本"体贴地"降速到网关能承受的水平,于是永远测出漂亮的 25.94ms;开环脚本坚持按名义速率发,队列持续堆积,p99 直接飙到 4.2 秒。同一个网关,两种压测方法,p99 差了 160 倍。
网关侧最终计数:接受连接 172、正常回显 96,902、限流 71,752——与 B 阶段客户端解析出的错误帧数逐条对上,差额为零。压测报告能对账,才算数。
先起网关(独立进程,这一点很关键,后面踩坑清单会讲为什么):
# wsbench_server.py — 自建网关,独立进程运行
import asyncio, json, random, logging
from websockets.asyncio.server import serve
logging.getLogger("websockets").setLevel(logging.CRITICAL)
random.seed(20261114)
S = {"conns": 0, "echoed": 0, "limited": 0} # 网关侧计数,供对账
async def process_one(ws, msg): # 并发处理:每条消息独立任务
await asyncio.sleep(0.002 + random.expovariate(1 / 0.003))
S["echoed"] += 1
await ws.send(json.dumps({"id": msg.get("id"), "ok": True}))
async def gateway(ws):
S["conns"] += 1
serial = ws.request.path.startswith("/serial") # 串行模式供 D 阶段用
tokens, last = 100.0, None # 每连接令牌桶
async for raw in ws:
now = __import__("time").perf_counter()
if last is not None:
tokens = min(100.0, tokens + (now - last) * 400.0) # 补充 400/s
last = now
msg = json.loads(raw)
if "cmd" in msg: # 控制通道:回报统计
await ws.send(json.dumps({"server_stats": dict(S)})); continue
if serial:
await asyncio.sleep(0.005); S["echoed"] += 1
await ws.send(json.dumps({"id": msg["id"], "ok": True}))
elif tokens >= 1.0:
tokens -= 1.0
asyncio.create_task(process_one(ws, msg))
else:
S["limited"] += 1
asyncio.create_task(ws.send(json.dumps(
{"id": msg["id"], "ok": False, "code": "RATE_LIMITED"})))
async def main():
async with serve(gateway, "127.0.0.1", 18914):
await asyncio.sleep(240)
asyncio.run(main())客户端的治理式连接核心逻辑——五道关口都在这 30 行里:
# wsbench_run.py(节选)— 治理式压测连接:开环速率 + in-flight 上限 + 按 id 配对
async def eng_conn(offer_rate, dur_s, results):
ok_ids, err_ids, rtts, in_flight = set(), set(), [], {}
next_send, seq = time.perf_counter(), 0
deadline = next_send + dur_s
async with connect("ws://127.0.0.1:18914/gateway") as ws:
async def reader(): # 关口①:按 id 配对,ok/error 分账
while True:
rep = json.loads(await ws.recv())
sid = rep["id"]
if sid in in_flight:
t = in_flight.pop(sid)
(ok_ids.add(sid), rtts.append(time.perf_counter() - t)) if rep.get("ok") else err_ids.add(sid)
rtask = asyncio.create_task(reader())
while time.perf_counter() < deadline:
now = time.perf_counter()
if now < next_send: # 关口②:开环——严格按名义速率发
await asyncio.sleep(min(next_send - now, 0.005)); continue
if len(in_flight) >= 32: # 关口③:未决请求上限,背压感知
await asyncio.sleep(0.005); continue
in_flight[seq] = time.perf_counter()
await ws.send(json.dumps({"id": seq, "payload": "x" * 256}))
seq += 1; next_send += 1.0 / offer_rate
await asyncio.sleep(1.5); rtask.cancel() # 排空响应再收尾
results.append({"ok": len(ok_ids), "err": len(err_ids), "rtts": rtts})# 建连节奏(关口④)与对账(关口⑤)的主流程骨架
async def main():
tasks = []
for start in range(0, 80, 8): # 阶梯爬坡:每 0.5s 起 8 条连接
for _ in range(8):
tasks.append(asyncio.create_task(eng_conn(100, 10, results)))
await asyncio.sleep(0.5)
await asyncio.gather(*tasks)
async with connect("ws://127.0.0.1:18914/gateway") as ws: # 控制通道对账
await ws.send(json.dumps({"cmd": "stats"}))
print(json.loads(await ws.recv())["server_stats"]) # 必须与客户端逐项对上完整版(含朴素 A/B 阶段与 D 阶段闭环/开环对照)就是本次实验原样跑通的脚本,两个文件共约 300 行,直接 python wsbench_server.py + python wsbench_run.py 即可复现表中全部数字。
坑 1:网关在
async for里逐条await处理——每连接串行,吞吐被钉死在约 62 条/秒,令牌桶(补充 400/s)永远满着,第一版实验 0 条限流,B 阶段直接失效。并发网关必须把处理逻辑丢给独立任务。坑 2:压测客户端和网关放进同一个进程——两端抢同一个事件循环,C 阶段 p50 被推高到 251ms;拆成两个进程后同条件下降到 86ms。压测时客户端与服务端必须进程隔离。
坑 3:Windows 默认时钟粒度约 15.6ms,
asyncio.sleep(0.002)实测量化成 15.69ms 的 p50(三轮实验均复现)。本机跑延迟基准,2ms 的真实时延会被整齐地"格式化"到 15.6ms 档位,解读分位数时要心里有数。坑 4:朴素轰炸把
RATE_LIMITED帧当成功(71,752/80,000),且报告的 21,324.8 条/秒是"含失败帧的收帧速率"——两个指标同时虚高,这是最容易写进汇报却最容易翻车的一对数字。坑 5:限流错误帧的 id 若不在 in_flight 表里会被静默丢弃(id 已被之前的响应 pop 掉)——对账时网关侧计数与客户端计数必须做差额核对,本次终跑差额为零才算收工。
坑 6:上一轮实验崩溃后网关进程残留在端口上,新一轮压测连的是"带着旧计数的老网关",总数对不上才发现。压测前后都应检查端口占用,统计口径只引用"当次增量"。

让 WorkBuddy 按这套框架帮你做压测,可以直接贴这段话:
用 Python 的 websockets 库帮我做一次 WebSocket 网关压测: 1. 自建一个合成网关(独立进程):支持限流令牌桶(容量 100、补充 400/秒)、处理时延 2ms + 指数抖动、按请求 id 回显、ok=false 错误帧; 2. 压测分四阶段:A 单连接串行基线;B 朴素并发轰炸(先全发后收、不解析帧);C 治理式(阶梯爬坡 + 开环固定速率 + in-flight≤32 + 按 id 配对 + 分位延迟);D 同速率闭环 vs 开环对照(测协调缺失); 3. 输出对比表:朴素口径 vs 真实口径,网关侧计数与客户端逐项对账,差额必须为 0; 4. 所有数字必须真实跑出来,禁止编造;跑完给我完整结论和可复现的脚本文件。
压测的价值不在于"跑得多快",而在于"每个数字都能对账"。这次实验里,同一个网关、同一份流量:朴素脚本交出了"成功率 100%、吞吐 2.1 万条/秒"的漂亮报告,真相是交付率 10.31%、p99 被协调缺失少报 99.4%。修正只需要五道关口:按 id 配对、开环速率、in-flight 上限、阶梯爬坡、分位延迟加对账——加起来不到 40 行代码。
下次拿到一份压测报告,先问三个问题:限流错误帧算不算成功?速率是开环还是闭环?网关侧计数和客户端对过账吗?三问全过,数字才值得相信。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。