首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >#WorkBuddy# WebSocket 压测报告全是假的?80 连接虚报成功率 89.69 个百分点,五道关口校出真值

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

原创
作者头像
用户12784192
发布于 2026-11-14 10:28:20
发布于 2026-11-14 10:28:20
80
举报

#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,全部数字可复现)。本文把朴素压测脚本会踩的坑逐一演示,并给出一份"每一个数字都能对账"的治理式压测方案。

二、根因分析:朴素压测错在四个假设

朴素脚本的默认假设,在真实网关面前全部不成立:

  1. "收到回复 = 请求成功"。 真实网关有限流、降级、熔断,失败也是一条合法的 WebSocket 帧。不解析帧语义,成功率就是把失败也计入成功。
  2. "发完再收,顺序一致"。 并发处理下响应天然乱序;先全发后收的写法根本测不出每条请求的延迟,只能测出一个"平均稀释后的错觉"。
  3. "发得越猛测得越准"。 80 条连接同时握手会冲击 backlog;无上限地灌消息时,send 缓冲区把"排队"伪装成"低延迟",把内存当成吞吐。
  4. "发一条等一条就是压测"。 这叫闭环压测,它会随网关变慢自动降速,把过载完整地藏起来——业内叫协调缺失(Coordinated Omission),本文 D 阶段实测它让 p99 少报了 99.4%。

实验网关模拟了真实限流语义:每连接一个令牌桶(容量 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 阶段客户端解析出的错误帧数逐条对上,差额为零。压测报告能对账,才算数。

四、可复制代码:治理式压测的最小实现

先起网关(独立进程,这一点很关键,后面踩坑清单会讲为什么):

代码语言:python
复制
# 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 行里:

代码语言:python
复制
# 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})
代码语言:python
复制
# 建连节奏(关口④)与对账(关口⑤)的主流程骨架
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 的提示词模板

让 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 删除。

目录
  • 一、痛点开场:报告很漂亮,网关很委屈
  • 二、根因分析:朴素压测错在四个假设
  • 三、真实数据对比:同一份流量,两份报告
  • 四、可复制代码:治理式压测的最小实现
  • 五、踩坑清单:这次实验自己踩过的坑
  • 六、给 WorkBuddy 的提示词模板
  • 七、收尾
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档