真实运行截图:
一、压测要回答的不是"快不快",是三个数
很多同学一提压测就说"看看能不能扛住 1 万并发",这个目标太虚。一次压测真正要带回来的是三个数:
- 吞吐量(req/s)——单位时间内处理完多少请求;
- 分位数延迟(P50 / P95 / P99)——99% 的请求在多少毫秒内返回;
- 错误率——压测中失败的请求占比。
为什么必须看分位数而不能只看平均?我项目里 9 月 22 日那次 JMeter 压测就是活例子(数据来自D:\dev\iot-device-platform\loadtest\README.md,2026-09-22 实测):
| 指标 | 数值 |
|---|---|
| 总请求数 | 136,892 |
| 吞吐量 | 2,285 req/s |
| 平均响应 | 36.5 ms |
| P50 | 7 ms |
| P99 | 422 ms |
| 错误率 | 0.00% |
平均 36.5ms 看着很健康,但 P99 是 422ms——最慢的 1% 请求比一半以上的请求慢 60 倍。用户刷看板时"偶尔卡一下"的体感,就藏在这 1% 里,平均数根本照不出来。
二、这次我先把项目跑起来,重新压了一遍
为了让文章有当场可复现的数据,今天(10-09)我把项目用默认 H2 profile 重新起在 8080 端口(不依赖 EMQX,MQTT 连不上只是 warning,不影响 HTTP 接口),先注册 2 台压测设备确认接口活着(见上面第三张截图),然后对热点读接口GET /api/devices做并发压测。
第一个版本的压测脚本我直接写在浏览器页面里跑,结果翻车了——这正是本文最大的价值。
三、坑一:用浏览器当压测客户端,64 并发只压出 247 req/s
浏览器页面里用fetch发 64 个并发请求、持续 15 秒,实测结果:
- 总请求数 3,766,吞吐量只有 247 req/s
- 平均响应 256ms,P50 210ms,P99 828ms
同一台机器、同一个服务,稍后换个客户端能压出4191 req/s,相差17 倍。原因不是服务端抖了,而是浏览器的连接管理:Chromium 对同一个 HTTP/1.1 源最多只开6 条 TCP 连接,64 个 fetch 排队抢 6 条通道,测出来的"响应时间"大半是在排队,不是服务端在处理。
这就是压测的第一课:数字难看时,先怀疑压测客户端,再怀疑服务端。项目里那次 JMeter 报告我特意写了句"瓶颈在压测端并发模型而不是服务端"(loadtest\README.md原文),这次算是用另一条路径又验证了一遍。
顺带说一句:浏览器压测不是不能用,但它适合做单用户延迟采样,不适合做并发吞吐压测。
四、换 Node/undici 重压:4191 req/s、P99 32ms
Node 的内置 fetch(undici)没有每源 6 连接的限制,才是合格的压测客户端。核心代码(真实脚本,来源ai-content-studio\tools\bench-node-1009.cjs,已删减注释):
asyncfunctiononeShot(){constt0=performance.now();letstatus=0;try{constr=awaitfetch(URL,{cache:'no-store'});awaitr.arrayBuffer();status=r.status;}catch(e){status=-1;}return{ms:performance.now()-t0,status};}// 预热 500 次(丢弃 JIT 冷启动请求),再 64 个 worker 并发跑满固定时长for(leti=0;i<500;i++)awaitoneShot();constlat=[];leterrors=0;constt0=performance.now();conststop=t0+DURATION;asyncfunctionworker(){while(performance.now()<stop){constr=awaitoneShot();if(r.status!==200)errors++;lat.push(r.ms);}}awaitPromise.all(Array.from({length:CONC},worker));lat.sort((a,b)=>a-b);constpct=p=>lat[Math.min(lat.length-1,Math.floor(lat.length*p))];// 输出:total / rps / avg / p50 / p90 / p95 / p99 / max / errRate实测(64 并发 × 15 秒):
| 指标 | 数值 |
|---|---|
| 总请求数 | 62,884 |
| 吞吐量 | 4,191 req/s |
| 平均响应 | 15.3 ms |
| P50 | 14.5 ms |
| P90 | 19.5 ms |
| P95 | 22.7 ms |
| P99 | 32 ms |
| 最大 | 65.7 ms |
| 错误率 | 0.00% |
注意两个细节:
- 预热必须做。我第一轮没预热时只有 2266 req/s、P99 57.8ms,JIT 冷启动把前几秒的慢请求全算进分位数了;预热 500 次后才是稳定状态的真实水平。
- 这次 P50 14.5ms 到 P99 32ms 只差 2 倍、最大 65.7ms,长尾很短——因为空库查询真的没什么可慢的。
五、4191 和 2285 哪个准?都不准,也都准
有人会问:9 月 JMeter 压出 2285 req/s,今天 Node 压出 4191 req/s,是不是服务端变快了一倍?
不是。这两组数字根本不可比,因为压测环境三要素全变了:
- 数据集不同:9 月那次打的是 MySQL profile、目标设备有 40+ 条遥测记录;今天 H2 是空内存库,连磁盘 IO 都没有;
- 客户端不同:JMeter 100 线程 vs undici 64 并发,客户端本身的开销和调度完全不同;
- 机器状态不同:9 月那次 JMeter 和服务端同机抢核(README 里写了这是 P99 422ms 的主要来源之一),今天压测端是另一个进程模型。
所以压测报告必须把环境写死在标题旁边:客户端模型、数据集、机器部署、JVM 参数,缺一个数字就没意义。这一点在面试里说出来,比背出任何一个具体数字都加分。
六、服务端为什么扛得住:三道"防打爆"设计
压测能扛,不是 Tomcat 厉害,是接口设计时就没留打爆自己的口子。三处真实代码:
① 参数硬上限(来源src\main\java\com\iothub\data\DataPointController.java):
privatestaticfinalintDEFAULT_LIMIT=100;privatestaticfinalintMAX_LIMIT=1000;intsize=limit==null?DEFAULT_LIMIT:Math.min(limit,MAX_LIMIT);Pageablepageable=PageRequest.of(0,size,Sort.by(Sort.Direction.DESC,"id"));调用方传limit=999999也会被钳到 1000——遥测数据是无界的,不设上限等于把内存交给调用方管。
② 查询走索引(来源src\main\java\com\iothub\data\DataPoint.java):
@Entity@Table(indexes=@Index(name="idx_datapoint_device",columnList="deviceId"))publicclassDataPoint{...}时序表是全平台数据量最大的表,deviceId 上没有索引,压测第一个死的就是它。
③ 关掉 open-in-view(来源src\main\resources\application.yml):
spring:jpa:open-in-view:false不持有数据库连接到视图渲染结束,连接池才能在高并发下快速周转——这也是很多"压不上去"案例里藏得很深的一条。
七、P99 真的高了,按这个顺序查
回到标题的问题:压测发现 P99 很高怎么办?按我踩过的经验,排查顺序应该是:
- 先看压测客户端:连接数有没有上限、有没有和被测服务抢 CPU(今天 247 req/s 的教训);
- 看 GC 日志:长尾里最常见的常驻嘉宾,P50 和 P99 差几十倍多半有它的份;
- 看慢 SQL:索引命中了吗?有没有深分页(
OFFSET 100000这种); - 看连接池:HikariCP 默认 10 个连接,64 并发一来就在池上排队;
- 最后才是"加机器"。
顺序反了,就会出现"调了一周 JVM 参数,其实瓶颈在压测脚本"的惨剧。
写在最后
我是软件工程在读,正在从零搭一个物联网设备接入平台(Spring Boot + EMQX + MySQL),这个压测是平台性能这条支线的第一篇。下一篇计划写共享订阅(shared subscription)或者 Kafka 解耦。如果这篇对你有用,评论区聊一句你压测踩过的坑,比点赞更让我高兴。
本文所有数据均为本机实测:JMeter 数据来自项目loadtest\README.md(2026-09-22),Node 压测数据为 2026-10-09 当场实测,代码均来自项目真实源码,截图为真实运行画面。