同一台机器,同一个模型,只是把某个开关从A拨到B,压测结果出来差了30%。当你把开关拨回去,结果却回不到原来的数字。这种诡异的"不可复现"现象,我从入行到现在,见过不下十次。
前阵子帮一个团队做模型推理服务的压测验收。他们发现把某个缓存开关从开改成关,TPS掉了40%,于是紧张地来拉我一起排查。我让他们把开关改回去,再跑一轮,结果TPS只回到了原来的87%。开关明明改回去了,性能却没有完全回来。那一刻团队所有人都沉默了——自己看到的压测数据,可能根本不能用来做任何判断。
这类场景在压测圈太常见了。你以为自己严格遵循了"单一变量"原则:同一个模型、同一台机器,只改一个开关。但实际跑出来的数据,每一次都不一样,甚至方向都相反。这种时候基本可以断定,不是开关本身有问题,而是我们对压测可信度的控制还不到位。
这篇文章要聊的就是:为什么在"同一模型、同一机器、只改一个开关"的条件下,压测结果依然不可信;背后到底藏着哪些隐形变量;以及我平时是怎么设计压测流程,让结果可复现、可对比、敢写进报告。适合所有给被测系统做压测的QA工程师、后端开发、SRE,以及任何被"压测数据对不上"折磨过的人。就算你压的是AI模型推理服务、普通Web API,方法论都一样。
1. 压测结果不可信的核心原因:你只控制了一个开关,但环境里有三组变量
很多人理解的"控制变量",是把被测代码的配置固定住,就以为条件一致了。但压测不是单元测试,被测系统不是一段静态的代码,它是一台活着的、有状态的机器。在这样的前提下,"同一个模型、同一台机器"只是书面上的假象,背后至少有三组变量在你改开关的瞬间跟着动了。
1.1 开关背后跟着的是JIT、GC和缓存三兄弟
先拿以Java为代表的运行时来说。JIT编译是分层的,你第一次跑某个接口时,它是解释执行,之后才逐步升级到C1、C2优化过的机器码。第一次压测时,代码可能已经被编译过了,但开关一旦改动,相关热点方法会重新触发编译,甚至因为内联策略改变而失去之前的优化。JVM的编译日志不会撒谎,你翻一下-XX:+PrintCompilation,经常会看到"同一方法、不同开关配置,编译层级完全不同"。
GC就更明显了。一个看似无关的业务开关,比如把缓存命中率从90%降到50%,对象分配速率就会立刻上升,GC从Minor GC变成频繁Full GC。GC一上去,TPS必然掉。可你把开关改回去,缓存重新热起来需要时间,GC频率也不会瞬间恢复,于是出现了"开关改回去了,性能却回不去了"的滞后效应。
还有缓存的整个体系:CPU的L1/L2/L3、JVM堆内的对象缓存、操作系统的Page Cache。开关改动后,数据访问频次变了,这些缓存的命中率都需要重新累积,不会瞬间恢复。这意味着,改动前后的两次压测,其实处在完全不同的"物理温度"下,你拿这两组数据对比,本身就不公平。
1.2 "模型"本身也不是静止的
这里说的模型,无论是AI推理模型,还是业务系统的负载模型,都有自己的内部状态。AI模型如果有动态batching,或者有KV-Cache这类推理缓存,不同请求顺序、不同输入长度,会直接改变前向计算的耗时。你压测时发的请求序列稍有不同,结果就可能差出好几个百分点。
负载模型就更隐蔽了。压测工具一般用固定QPS或固定并发数去压,固定QPS时,请求是匀速到达的;固定并发数时,请求是"前一个结束、下一个立刻出发"的突发模式。同样是那个开关配置、同样的平均耗时,两种请求轨迹下,排队效果和尾部延迟完全是两回事。很多团队没意识到,自己两次跑的根本不是同一个"负载模型"。
1.3 压测工具本身的开关联动也在引入噪声
比如JMeter里线程组、循环次数、调度的设置,还有连接池的Keep-Alive开关和超时时间。连接池这个开关特别阴险。它一开,压测客户端会把TCP连接复用起来,服务端的连接建立开销几乎为零;它一关,每个请求都是新建TCP连接,三次握手和慢启动开销全进来了。你明明只改了被测系统的一个开关,但跑出来的数据里,也混进了压测工具的开关效应。
还有压测客户端的线程调度。JMeter的线程数和压测机器核数不匹配时,客户端自己CPU先被打满,这时测出来的就是客户端上限,根本不是服务端表现。这类噪声必须靠系统监控来区分,不盯着看很容易被带偏,把工具瓶颈当成业务瓶颈。
2. 一个开关改动引发性能漂移的完整链路:三个实测案例
光说理论不解决问题,我把实际踩过的三种场景拆出来讲一下,你对照一下自己的情况,看看是不是同款。
2.1 案例一:业务并发开关从50调到100,TPS反而腰斩
曾经压过一个订单查询服务,标准的Java微服务。团队想看线程池最大线程数从50调到100,能不能提升吞吐,结果TPS从3200掉到了1800,大跌眼镜。
打开系统指标发现:线程数上去了,CPU使用率反而从75%降到了60%,更多时间花在锁等待和上下文切换上。vmstat里的cs列从每秒3万飙到8万,GC日志里Young GC频率也上去了,一次压测下来的/proc/pressure数据明显恶化。
问题根源在于,这个服务里有一个共享的数据库连接池。线程多了之后,更多线程在排队等连接池的许可,锁竞争反而加剧;同时线程多了,每个线程都去抢占工作队列的锁,队列消费效率也变差。这个案例想说明的是,你改的那个开关,往往不是"性能开关",而是一串连锁反应的起点。50到100这个改动,真实影响的是锁、队列、连接池、GC四个环节,这四个环节还互相作用,最终结果和直觉完全相反。
2.2 案例二:CPU节能开关导致的"薛定谔性能"
这个案例发生在压测环境没有关闭CPU节能的情况下。前一次压测结果稳定在5000 QPS,过了两天重测,同样的代码、同样的配置,只有4300 QPS。查了一圈,最后发现服务器CPU的governor从performance变成了powersave。
Linux的CPU调频策略是会自动升降频的。机器空闲一段时间后,CPU频率会被压到很低,压测开始后,频率再爬回来需要时间。如果压测脚本只跑几分钟,前半段正好撞上低频状态,平均时延和吞吐都会被拉低。最坑的是,这个开关甚至都不需要人去操作,系统自己会切。
解法也很简单:压测前把CPU governor固定到performance模式,顺便关掉Turbo Boost的干扰,最好在BIOS或系统层面直接锁频。你连这些系统级开关都没控制好,就别谈严格的性能对比。
2.3 案例三:压测工具Keep-Alive开关让"服务端优化"看起来没效果
有一次,团队优化了服务端HTTP响应处理逻辑,想减少一个不必要的消耗点,但压测数据却显示没有提升。后来我把压测脚本里HTTP Keep-Alive打开之后,数据立刻出现了差异。
原因是这样的:Keep-Alive关闭时,服务端每个请求都要新建TCP连接,工作量非常大,优化带来的那点收益被连接建立开销淹没了。而真实业务场景里长连接是常态,只有把连接复用打开,才能测出业务收益的真实大小。
这个案例给我的启发特别大:压测脚本本身也是一种"被测配置",它自己的开关引入的变量,甚至可能大于被测系统的改动。任何压测结论,都要先声明压测工具的开关状态,否则同场景换个人来跑,结果完全对不上。
3. 让压测结果可信的四个关键步骤:从预热到A/B对比
下面这部分是重头戏,也是我这几年沉淀下来的一套"可信压测协议"。不管压的是模型服务、普通Web服务还是中间件,这套流程都可以直接用。
3.1 第一步:预热跑够,用稳态指标做基线
任何压测都必须先跑预热。预热时间至少要覆盖JIT编译、缓存填充、连接池初始化。我一般用"稳态判定法":连续观察5分钟TPS和时延变化,如果波动范围保持在5%以内,才认为系统进入稳态,才开始记录数据。
# 每2秒采样一次,观察稳态期间系统指标是否平稳 vmstat 2 150如果预热期间CPU使用率还在爬升,说明JIT还没编译完;如果GC日志显示Full GC频率还在下降,说明堆内存还没稳定。这些信号都比"跑了10分钟就开测"可靠得多。很多团队把压测时间设成1分钟,这完全不够。一个依赖JIT和GC分层的Java服务,至少要跑10到15分钟才敢说数据可信。时间越短,冷启动噪声占比越高,结果就越"随机"。
3.2 第二步:同配置多次运行,用统计口径对比,而不是只看一次
单次跑出的数字没有任何意义。我通常的做法是:同一配置至少运行3次,每次重启进程,记录TPS均值、P99时延、错误率,然后取中位数或均值做对比。
这里有一个关键细节:重启进程这一步必须做。如果不重启,第二次运行会继承上次留下的JIT编译结果和缓存热状态,不但不能消除误差,反而会引入"记忆效应"。只有重启之后状态干净了,多次运行之间的差异才能真实反映系统波动。
统计口径上,我习惯同时看均值和中位数。平均值容易被极端值拉偏,中位数更能反映"绝大多数请求"的体验。如果多次运行的P95、P99离散程度很高,说明系统本身就不稳定,这时候任何两个开关的对比都站不住脚。
3.3 第三步:环境噪声抑制,把系统和压测端固定住
这一步是体力活,但每项都能省掉以后的麻烦。我整理了一个自查清单,每次压测前照做一遍。
先把CPU governor设为性能模式,关闭透明大页和NUMA balancing,这几项在压测期间会带来不可预期的干扰。压测机器要和被测机器分开,不要共享CPU核,用taskset给压测客户端和被压服务绑定不同的物理核,避免相互争抢CPU资源。
# 将压测客户端绑到0-3号物理核,服务端绑到4-7号物理核 taskset -c 0-3 jmeter -n -t test.jmx taskset -c 4-7 java -jar app.jar压测客户端离被测服务越近越好,最好在同一个交换机下,避免跨机房链路抖动。如果被测服务依赖数据库或缓存,这类依赖也要固定连接池大小,固定延迟水平,否则数据库的一次慢查询就会把整体数据带偏。
这里有一个我踩过的坑:压测机上如果开了监控采集、日志采集Agent,它们会在某些瞬间占满CPU。你可以用top盯着压测机,如果看到CPU使用率突然跳到100%,那次跑出来的结果基本就废了,需要重新来。
3.4 第四步:严格A/B对比,每次只动一个开关,并记录环境指纹
改开关之前,先记录一份"环境指纹":操作系统版本、内核参数、固件版本、JVM启动参数、GC日志路径、压测工具版本和参数。同一个模型、同一台机器,不意味着环境一致。只有环境指纹一致的前提下,开关对比才有意义。
正式对比时,我采用A/B/A的交叉顺序。先跑A配置,再跑B配置,最后再跑一次A配置。如果A的两次结果接近,而B明显不同,这个结论才敢写进报告。如果A的两次结果本身就差10%以上,那说明环境噪声太大,这次压测完全不可信,需要先排查环境问题,而不是继续纠结B配置的好坏。
这种A/B/A设计的核心思想是:用重复测量来暴露环境漂移,而不是默认环境一定不会漂移。很多人就是栽在这上面——默认环境是稳定的,结果被环境骗了一次又一次。
4. 压测结果可信度速查表:六个危险信号与排查清单
最后把经验浓缩成可以直接参考的东西,下次压测时拿出来对照一下。
4.1 六个"假结果"信号
第一个信号:只跑一次就下结论。第二个信号:多次运行结果差距超过10%。第三个信号:预热期间指标一路下滑,没有进入稳态。第四个信号:CPU使用率忽高忽低,或者监控里出现了CPU steal。第五个信号:GC日志里出现Full GC,但报告里提都没提。第六个信号:压测结果和代码逻辑直觉完全矛盾,却没有做任何根因分析就直接说"优化没效果"。
这六个信号,任何一个出现,压测数据都需要打上问号。我见过太多团队,拿着"开关改动后TPS降了40%"的报告去做决策,但报告里没有预热数据、没有多次运行、没有GC日志、没有环境指纹。这种情况下,结论大概率是错的。
4.2 常见问题排查速查表
| 表现 | 常见原因 | 排查手段 |
|---|---|---|
| 压测结果每次相差大 | 预热不足、稳态未达成 | 延长预热时间,观察5分钟波动 |
| TPS断崖式下跌 | 连接池/线程池锁竞争、GC暴增 | 看GC日志、线程dump、做锁分析 |
| 开关改回去但性能没回来 | 缓存热状态丢失、JIT重新编译 | 等待系统重新预热,确认稳态 |
| 客户端本身CPU 100% | 压测工具线程数过高 | 用top盯压测机,调整并发脚本 |
| CPU频率波动导致结果发散 | 节能策略自动调频 | 固定governor、锁频 |
| 报告结论不可复现 | 环境状态未记录 | 记录环境指纹、做A/B/A验证 |
这张表是我排查压测问题时用得最频繁的东西。它的价值不在于让你照着抄,而在于提供一条排查路径:先怀疑环境,再怀疑脚本,最后才轮到被测代码本身。
4.3 我的独家避坑经验
最后分享几条常规文档里不太会写的经验,都是我踩过坑换来的。
永远不要在下午下班前开始压测,然后仓促收尾。压测不是"跑一下看看",一个可信结论至少需要半天时间。预留足够的窗口期,否则你会因为赶时间,主动放弃预热和重复验证,最后拿到的还是一组没法用的数字。
压测脚本也是一等一的被测试对象。每次压测前把脚本版本、参数全部记录下来,像管理被测系统代码一样管理压测脚本。你会发现很多"无法复现"的问题,其实是脚本版本不一致导致的。
如果被测系统是AI推理模型,务必固定输入数据分布和batch size。模型推理对输入长度特别敏感,同一个开关配置下,输入分布不同,可能造成30%以上的差异。我在做模型推理压测时,甚至会把请求中的token长度分布打印出来,确保每一轮压测的输入分布一致,才敢说"开关改动"是唯一变量。
我在实际做压测这件事上最大的体会是:压测的难点,从来不是把压测工具跑起来,而是判断跑出来的数字能不能信。"同一模型、同一机器、只改一个开关"这几个字,听着就让人放松警惕,但现实中几乎所有压测噪声,都藏在这句确定性描述的背后。真正专业的压测人员,一半时间都在做"怀疑自己"的工作——怀疑流程、怀疑脚本、怀疑环境,最后才轮到怀疑代码逻辑本身。
建议你下次压测前,先问自己三个问题:预热够了吗?跑了几轮?A/B/A交叉了吗?把这三个问题变成习惯之后,你会发现自己报告里的数据突然就"稳"了。这不是玄学,是环境噪声终于被你挡在了外面。