一次真实压测,先证明"我量到的是被测系统":四个角色、八个坑,和逐层找墙

3 阅读14分钟

一次真实压测,先证明"我量到的是被测系统":四个角色、八个坑,和逐层找墙

主题:压测结论的可信性(干扰项证伪 · 工具自证 · 逐层找墙)

先说结论

给一个微服务电商项目里的 AI 导购流式接口(SSE 长连接)做并发压测,我原以为难点是"把负载打上去"。

真正难的是另一件事:证明你量到的是被测系统。

我第一次压出来的曲线其实"挺好看"——100 并发,吞吐稳定、延迟可控。直到我把失败原因拆开看,才发现一半的请求根本没进业务逻辑:它们在框架的安全过滤器那一层就被秒拒了,返回 HTTP 500 只用了约 130 毫秒。也就是说,那条漂亮的 RPS 曲线里,混着一大堆"还没开始就结束"的请求。

这篇文章讲的不是"我的系统能扛多少并发",而是:在这条测量链上,有四个角色都可能替你回答 —— 不把它们逐个证伪,任何数字都不能信。

一、测量链上的四个角色

      ① 干扰项              ② 被测系统              ③ 替身            ④ 客户端
 ┌──────────────┐    ┌──────────────────┐    ┌──────────────┐   ┌──────────────┐
 │ 入口限流      │    │ 真正的并发闸门     │    │ mock 大模型   │   │ 压测脚本      │
 │ 每用户频控    │ →  │ 异步线程池/连接池  │ ←  │ (局部替身)  │ → │ (另一台机器) │
 │ 日预算墙      │    └──────────────────┘    └──────────────┘   └──────────────┘
 └──────────────┘             ↑                     ↑                  ↑
   "它先拦住了"          "它才是答案"          "它自己慢了"        "它自己打不动"

三条可以直接复用的方法论:

  1. 先证伪干扰项,再测承载。 入口限流、每用户频控、日预算墙都会先于承载上限生效,而且它们的失败长得像成功(HTTP 200 + 业务错误体)。不把它们关掉或绕开,你量到的是限流器的能力,不是系统的能力。
  2. 替身与客户端都要"自证"。 mock 必须先单独压并给出 P50/P99 与吞吐;客户端也必须证明自己没饱和(CPU/负载取证)。否则你量到的是工具的性能。
  3. "失败"必须可分类。 干扰项的失败(被限流、被频控、被预算拦)必须与被测系统的降级分开计数;分不开,等于没测。

一句话:压测的产出不是 RPS,而是"我凭什么相信这个 RPS"。

二、最贵的一课:被测系统自己带着一个 50% 的缺陷

跑第一轮就翻车:20 并发 × 20 秒,硬失败 32/64 = 50.0%,成功率只有 60%。而同一份代码串行 20 次全 200。

三条线索把它钉死了:

  • 失败的请求是 HTTP 500,而且根本没到业务逻辑 —— 替身侧计数不涨、并发闸门占用为 0;
  • 服务端日志里是 AccessDeniedException: Access Denied + Unable to handle the Spring Security Exception because the response is already committed;
  • ⭐ 连成功的请求也会打这条 ERROR —— 说明它"每次都在抛",只是"响应是否已提交"的时序决定了客户端看到 200 还是 500。

读代码定位到根因:这个接口返回 StreamingResponseBody(异步),业务在异步线程把流写完之后,会触发一次 ASYNC 二次派发;而 authorizeHttpRequests(...).anyRequest().authenticated() 在 Spring Security 6 里默认对所有 dispatcher type 生效 —— 那一刻容器线程上已经没有了认证对象,于是被判为未认证。

这是一个"授权规则把内部再派发当成新请求又鉴权了一次"的问题。

修复很小:在授权规则最前面放行内部派发。

.authorizeHttpRequests(auth -> auth
        .dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
        .requestMatchers(permitAllMatchers()).permitAll()
        .anyRequest().authenticated())

两个必须说清的点:

  • ⚠️ 只放行 ERROR 派发不够。 项目里早先的记录只指向 ERROR,也做过一次尝试(把 SecurityContext 的传递策略改成继承线程本地),但无效 —— ASYNC 派发用的是容器线程池里的另一个线程,不是"当前线程的子线程",继承根本不生效。真正的另一半是 ASYNC。
  • ✅ 安全性没有放松。 ASYNC/ERROR 是"已经通过鉴权的那次请求的内部再派发",外部客户端无法伪装成这两种 dispatcher type 进来;首次 REQUEST 派发依然要求登录。

修完的同条件对比:

场景修复前修复后
串行 20 次20/20 全 200(但每次都留一条 ERROR)20/20 ✅ ERROR 归零
20 并发 × 20s硬失败 50.0%,成功率 60.5%硬失败 0,成功率 100%
100 并发 × 45s(未测,按趋势必然更差)0 失败

⭐ 但这轮修复带来一个反直觉现象:RPS 反而从 12.9 掉到 7.5,p50 从 1787ms 升到 2210ms。 因为修复前一半请求在安全过滤器被秒拒(~130ms),把 RPS 抬高了、把延迟拉低了。 "数字变好看了"恰恰要警惕 —— 变化方向不等于改善方向,要看分子里到底混了什么。

三、替身的四个坑(只有"先单独压替身"才会暴露)

真模型有成本、有额度,所以压测要用内网 mock 替身。但替身自己有四个坑:

坑 1:只读 Content-Length,遇到 chunked 请求体就"看不见"。 应用的 JSON 结构化任务(提取用户偏好等)是用 Transfer-Encoding: chunked 发出去的,没有 Content-Length;而我的 mock 只按 Content-Length 读 ⇒ 读到 0 字节 ⇒ 把"JSON 任务"误判成普通任务 ⇒ 回了纯文本 ⇒ 应用解析 JSON 抛异常 ⇒ 每次对话都产生一个假 500(在 SSE 里表现为一个 error 事件,压测会把它算成系统降级)。 排查靠的是给 mock 加一行请求摘要日志,一次就能现形:

[body] clen=None te=chunked rawlen=0   json_ok=False    ← 修复前
[body] clen=None te=chunked rawlen=854 json_ok=True     ← 修复后

坑 2:ThreadingHTTPServer 的 accept 队列默认只有 5。 mock 已经"线程化"了、CPU 只占 0.4%,但自压到 100 并发时出现 17 次失败。根因是 request_queue_size 默认只有 5 —— 100 条连接同时进来,accept 队列溢出直接丢连接。显式放大到 512 后:20/50/100/150 并发全部 0 失败、峰值并发 150、吞吐 306 req/s。

"线程化了"不等于"能扛并发" —— 线程模型之外还有 accept 队列、文件描述符、端口。

坑 3:把"结束标记"当成"流结束"。 我的 mock 一开始是"发完 data: [DONE] 就保持长连接等着"。结果客户端会一直阻塞到读超时。读客户端代码才发现:解析循环里那一行是 continue 而不是 break —— 流结束靠的是连接 EOF,不是一个结束标记。 修法:mock 用 chunked 编码显式写结束块 0\r\n\r\n 终结响应体。

做替身/代理/桩时,结束语义要照抄客户端代码,不能照抄"文档看起来应该是这样"。

坑 4:旁路记账把被测系统的闸门提前关掉。 逻辑上"压的是 mock、不花钱",但跑到一半之后每个请求都返回"服务繁忙" —— 看起来像"并发闸门满了",实际是日预算被打满:应用会把每次模型调用的 usage 都记账(mock 返回的 usage 也照记),单价按"元/百万 token"算、预算 2 元/天,累计约 5000 次调用就把当日预算打满,而 100 并发 × 45 秒一档就有 ~4500 次。 修法:让 mock 回 0 token ⇒ 零记账、零污染、不用碰预算限额。

替身要"全真",但记账类副作用要"归零" —— 流格式、工具调用尽量像真的;计费、审计、告警这类副作用必须显式切断,否则你压的不是性能,是额度。

四、读数的两个坑:口径错了,结论就反了

坑 5:"总 RPS"会被毫秒级的快速失败抬高。 某一档 总 RPS 53.6,看起来比 20 并发(20.7)强得多;但成功只有约 18/s —— 因为 65% 的请求是闸门毫秒级拒绝。

任何"吞吐"都要问一句"分子是什么"。混入快速失败的吞吐,会把"系统更早拒绝"误读成"系统更能扛"。 ⇒ 我的压测脚本从此并排打印"成功 RPS"与"总 RPS",判承载只看成功 RPS。

坑 6:两种完全不同的降级,客户端拿到同一句话。 脚本按响应体文案分类,结果 闸门满 一栏一直是 0,而 其它降级 高达 1701 次。但服务端日志里明明有上千条"当前并发 20/20,已拒绝本次调用"。 根因:流式路径上闸门满时,服务端 catch 写回的是通用文案,与"真异常"完全同一句 ⇒ 客户端原理上分不开。(另一侧:入口限流返回的是 HTTP 200 + SSE error 事件,不是 429 —— 只看 HTTP 状态码会把它算成"成功"。)

修法不是"猜一个分类",而是判闸门回看服务端日志,并在脚本汇总里醒目提示"这一类无法区分、请去查服务端"。 先确认"失败能不能被分类",再谈失败率 —— 分不开的失败率是伪指标。

五、逐层找墙:别把"限流器很有效"讲成"系统上限"

修完缺陷、证伪完工具之后,才轮到真正的承载测试。结果又是反直觉的:吞吐恒定,延迟线性增长。

并发成功 RPSp50首字延迟 p50
20~210.9 s0.17 s
50~210.9 s0.32 s
100~191.7 s1.0 s

吞吐不动、延迟随并发线性增长 = 典型排队饱和:再加并发只加等待。但 闸门满 = 0、CPU 也没打满 ⇒ 说明瓶颈不在我以为的那道闸门。

定位手法很朴素,但很有效:

  • 在替身侧量"并发调用数":mock 记录自己的 active / peak,发现峰值恰好是 8(而客户端并发是 100);
  • 放开它再量一次:把应用的异步执行器并发从默认 8 放到 50(一行环境变量,无需改代码重编译),同一档:总 RPS 11.2 → 53.6、p50 8.6s → 1.8s;
  • 第二道墙立刻现身:mock 的峰值并发从 8 变成 20,服务端日志开始刷"当前并发 20/20,已拒绝本次调用"。

于是这条链上被量出来的是两层墙:

① 框架异步执行器默认并发 8   ← 一开始我以为是"SSE 专用线程池",其实主链路走的是默认异步执行器
② 应用自己的 AI 并发闸门 20  ← 放开①之后才出现,这才是"设计上的保护点"
③ 入口限流(本轮临时放开,因此没撞到)
④ 日预算墙(本轮用 0 token 规避,因此也没撞到)

踩坑小结: 我最初看到代码里有一个"虚拟线程执行器",以为并发由它决定 —— 但它只被旧接口用,主链路走的是框架默认执行器。"读到一行配置"不等于"它就是生效路径",最终还得靠测量(替身侧并发计数)来确认。

六、可复用的清单

  1. 先证伪干扰项:入口限流 / 每用户频控 / 预算墙 —— 逐个"关掉或绕开",并记录怎么改回来的(热更新 + 原文备份 + md5)。
  2. 干扰项要"算",不能"估":把每个限额换算成对你要测的那个量的约束。例:每用户 10 次 / 60 秒 ⇒ 可持续 RPS ≈ 用户数 × 10 / 60,120 个用户只够 20 req/s —— 这就是"看起来够、实际不够"的典型。
  3. 替身先自证:单独压它,拿 P50/P99 与吞吐;确认它不是瓶颈再压服务端。
  4. 客户端也要自证:另一台机器 + CPU/负载取证(本次:load 0.27、脚本只占 4% CPU)。
  5. 失败必须可分类,且不确定就显式暴露,不要为了让报表好看而"猜一个分类"。
  6. 判承载只看成功 RPS,永远问"分子是什么"。
  7. "数字变好看"先当危险信号:先确认分子里没混进"还没开始就结束"的请求。

后记:我更愿意讲的不是"扛住了多少并发"

这轮压测最大的产出不是一组承载数字,而是两个缺陷 + 一条方法论:

  • 挖出了一个并发下 50% 请求 500 的生产缺陷(而它串行完全看不出来,日志里还一直在"正常地"报错);
  • 挖出了压测工具自己的缺陷(chunked 读不到、accept 队列丢连接、假失败),否则那些数字从一开始就是假的;
  • 以及一条我很愿意复述的认知:同一台服务上可能叠着好几层保护/限额,不逐层证伪,你压出来的"上限"其实是某一层限流器的成绩。

所以现在有人问我"这套系统能扛多少并发",我会先反问一句:"你是问哪一层?"


附录:工具链自己也会说谎(同一批里踩到的四个)

正文讲的是「被测系统」和「替身」;这个附录补的是更外面一层 —— 我用来看结果的那些工具(脚本、编辑器、grep、git)本身也会给出与事实相反的信号。同一批踩到四个,都很便宜,但每一个都足够让人得出相反结论。

1. 管道会吞掉退出码 —— 「构建失败」看起来像「构建成功」

部署脚本里有这么一行:

docker compose build <服务> 2>&1 | tail -3

这条命令里真正被 shell 取退出码的是 tail(恒为 0)。于是当 build 因为「该服务没有构建上下文」而直接失败时,脚本当没事发生继续往下跑,up -d 紧接着用了旧镜像 —— 而容器照旧 Up、接口照旧 200。

教训:关键命令不要挂在管道末尾;要么 set -o pipefail,要么把完整输出重定向到文件再看。

2. 编辑器会悄悄改掉文件编码 —— 中文脚本开始报「怪错」

仓库里有个 PowerShell 脚本,文件头专门写着「本文件是 UTF-8 with BOM(否则 PS 5.1 会把中文按 ANSI 解析)」。我用脚本化方式改了一次内容之后,BOM 被抹掉了 —— 中文本身没坏,但解释器换了编码去读,报出来的却是「字符串缺少终止符」这种看起来和本次改动毫无关系的错。

教训:改含非 ASCII 的脚本后,顺手看一眼头三字节(BOM 是 EF BB BF);把「工具可能改编码」当已知风险,而不是等它报怪错。

3. 忽略规则会吞掉新文件 —— 「我写了」但仓库里没有

.gitignore 里有一行忽略了某个部署目录(因为里面曾有敏感文件)。之后我往该目录新增文件时,git add 只给了一句「该路径被忽略」的提示就过去了 —— 文件根本没进版本库。

教训:往「被忽略过的目录」里加文件,git status 里看不到它是预期行为;要么显式 git add -f,要么把忽略规则收窄到具体文件。

4. grep 的「命中」可能只是同一个数字串 —— 我自己造了一次假警报

修复完一个 MQ 406 报错后,我用 grep -c 406 数「还剩几次」。拿到 12,差点当成「修复没生效」。

真相是:这 12 条命中的是完全无关的文本 —— 配置中心的连接 id 里恰好含 406(形如 …b220ed2d4061_config-0)。真正的报错长这样:channel error 406 PRECONDITION_FAILED unknown delivery tag,而它一次都没出现。

教训:用整串(或加上下文约束)去判,别用裸数字 —— grep -c <数字> 这类判据几乎必然有假阳性。 顺带一条同源认知:「容器还在跑」不等于「依赖连上了」 —— 进程在跑、健康检查 200,而 MQ 连接其实一直在被拒(ACCESS_REFUSED),外部零症状。

这四条的共同点

它们都不是被测系统的问题,而是我的观测与操作链的问题 —— 和正文的结论是同一句话:

任何「判据」,都要先问一句:它会不会在错误的那一侧,也给出「成功」的信号?

压测要证伪干扰项;验收也要证伪自己的工具。先证明「我量到的是被测系统」,也要证明「我看到的是真实结果」。