
一次真实压测先证明我量到的是被测系统四个角色、八个坑和逐层找墙主题压测结论的可信性干扰项证伪 · 工具自证 · 逐层找墙先说结论给一个微服务电商项目里的AI 导购流式接口SSE 长连接做并发压测我原以为难点是把负载打上去。真正难的是另一件事证明你量到的是被测系统。我第一次压出来的曲线其实挺好看——100 并发吞吐稳定、延迟可控。直到我把失败原因拆开看才发现一半的请求根本没进业务逻辑它们在框架的安全过滤器那一层就被秒拒了返回 HTTP 500 只用了约 130 毫秒。也就是说那条漂亮的 RPS 曲线里混着一大堆还没开始就结束的请求。这篇文章讲的不是我的系统能扛多少并发而是在这条测量链上有四个角色都可能替你回答 —— 不把它们逐个证伪任何数字都不能信。一、测量链上的四个角色① 干扰项 ② 被测系统 ③ 替身 ④ 客户端 ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐ │ 入口限流 │ │ 真正的并发闸门 │ │ mock 大模型 │ │ 压测脚本 │ │ 每用户频控 │ → │ 异步线程池/连接池 │ ← │ 局部替身 │ → │ 另一台机器 │ │ 日预算墙 │ └──────────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ ↑ ↑ ↑ 它先拦住了 它才是答案 它自己慢了 它自己打不动三条可以直接复用的方法论先证伪干扰项再测承载。入口限流、每用户频控、日预算墙都会先于承载上限生效而且它们的失败长得像成功HTTP 200 业务错误体。不把它们关掉或绕开你量到的是限流器的能力不是系统的能力。替身与客户端都要自证。mock 必须先单独压并给出 P50/P99 与吞吐客户端也必须证明自己没饱和CPU/负载取证。否则你量到的是工具的性能。失败必须可分类。干扰项的失败被限流、被频控、被预算拦必须与被测系统的降级分开计数分不开等于没测。一句话压测的产出不是 RPS而是我凭什么相信这个 RPS。二、最贵的一课被测系统自己带着一个 50% 的缺陷跑第一轮就翻车20 并发 × 20 秒硬失败 32/64 50.0%成功率只有 60%。而同一份代码串行 20 次全 200。三条线索把它钉死了失败的请求是HTTP 500而且根本没到业务逻辑—— 替身侧计数不涨、并发闸门占用为 0服务端日志里是AccessDeniedException: Access DeniedUnable 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但每次都留一条 ERROR20/20 ✅ ERROR 归零20 并发 × 20s硬失败 50.0%成功率 60.5%硬失败 0成功率100%100 并发 × 45s未测按趋势必然更差0 失败⭐但这轮修复带来一个反直觉现象RPS 反而从 12.9 掉到 7.5p50 从 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] clenNone techunked rawlen0 json_okFalse ← 修复前 [body] clenNone techunked rawlen854 json_okTrue ← 修复后坑 2ThreadingHTTPServer的 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首字延迟 p5020~210.9 s0.17 s50~210.9 s0.32 s100~191.7 s1.0 s吞吐不动、延迟随并发线性增长 典型排队饱和再加并发只加等待。但闸门满 0、CPU 也没打满 ⇒ 说明瓶颈不在我以为的那道闸门。定位手法很朴素但很有效在替身侧量并发调用数mock 记录自己的active / peak发现峰值恰好是 8而客户端并发是 100放开它再量一次把应用的异步执行器并发从默认8放到50一行环境变量无需改代码重编译同一档总 RPS11.2 → 53.6、p508.6s → 1.8s第二道墙立刻现身mock 的峰值并发从 8 变成20服务端日志开始刷当前并发 20/20已拒绝本次调用。于是这条链上被量出来的是两层墙① 框架异步执行器默认并发 8 ← 一开始我以为是SSE 专用线程池其实主链路走的是默认异步执行器 ② 应用自己的 AI 并发闸门 20 ← 放开①之后才出现这才是设计上的保护点 ③ 入口限流本轮临时放开因此没撞到 ④ 日预算墙本轮用 0 token 规避因此也没撞到踩坑小结我最初看到代码里有一个虚拟线程执行器以为并发由它决定 —— 但它只被旧接口用主链路走的是框架默认执行器。“读到一行配置不等于它就是生效路径”最终还得靠测量替身侧并发计数来确认。六、可复用的清单先证伪干扰项入口限流 / 每用户频控 / 预算墙 —— 逐个关掉或绕开并记录怎么改回来的热更新 原文备份 md5。干扰项要算不能估把每个限额换算成对你要测的那个量的约束。例每用户 10 次 / 60 秒 ⇒可持续 RPS ≈ 用户数 × 10 / 60120 个用户只够20 req/s—— 这就是看起来够、实际不够的典型。替身先自证单独压它拿 P50/P99 与吞吐确认它不是瓶颈再压服务端。客户端也要自证另一台机器 CPU/负载取证本次load 0.27、脚本只占 4% CPU。失败必须可分类且不确定就显式暴露不要为了让报表好看而猜一个分类。判承载只看成功 RPS永远问分子是什么。数字变好看先当危险信号先确认分子里没混进还没开始就结束的请求。后记我更愿意讲的不是扛住了多少并发这轮压测最大的产出不是一组承载数字而是两个缺陷 一条方法论挖出了一个并发下 50% 请求 500的生产缺陷而它串行完全看不出来日志里还一直在正常地报错挖出了压测工具自己的缺陷chunked 读不到、accept 队列丢连接、假失败否则那些数字从一开始就是假的以及一条我很愿意复述的认知同一台服务上可能叠着好几层保护/限额不逐层证伪你压出来的上限其实是某一层限流器的成绩。所以现在有人问我这套系统能扛多少并发我会先反问一句“你是问哪一层”附录工具链自己也会说谎同一批里踩到的四个正文讲的是「被测系统」和「替身」这个附录补的是更外面一层——我用来看结果的那些工具脚本、编辑器、grep、git本身也会给出与事实相反的信号。同一批踩到四个都很便宜但每一个都足够让人得出相反结论。1. 管道会吞掉退出码 —— 「构建失败」看起来像「构建成功」部署脚本里有这么一行dockercompose build服务21|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的「命中」可能只是同一个数字串 —— 我自己造了一次假警报修复完一个 MQ406报错后我用grep -c 406数「还剩几次」。拿到12差点当成「修复没生效」。真相是这 12 条命中的是完全无关的文本—— 配置中心的连接 id 里恰好含406形如…b220ed2d4061_config-0。真正的报错长这样channel error 406 PRECONDITION_FAILED unknown delivery tag而它一次都没出现。教训用整串或加上下文约束去判别用裸数字 ——grep -c 数字这类判据几乎必然有假阳性。顺带一条同源认知「容器还在跑」不等于「依赖连上了」—— 进程在跑、健康检查 200而 MQ 连接其实一直在被拒ACCESS_REFUSED外部零症状。这四条的共同点它们都不是被测系统的问题而是我的观测与操作链的问题 —— 和正文的结论是同一句话任何「判据」都要先问一句它会不会在错误的那一侧也给出「成功」的信号压测要证伪干扰项验收也要证伪自己的工具。先证明「我量到的是被测系统」也要证明「我看到的是真实结果」。