SpringCloud微服务开发脚手架跑了一次完整的JMeter压测,从脚本设计到结果分析踩了不少坑,这篇文章把整个流程、关键参数和调优思路都整理出来,给准备做微服务性能验证的同学一份能直接参考的实战记录。
先说背景。我手上的这个脚手架是某项目团队内部沉淀的一套SpringCloud基础框架,集成了网关、注册中心、配置中心、OpenFeign、Sentinel限流等常用组件,业务方新服务基本都是基于这套脚手架来开发。此前只验证过功能和联调,从没做过系统性的压力测试,大家对它的容量上限、性能瓶颈和配置是否合理其实心里没底。所以这次压测的目标很明确:验证脚手架在典型业务场景下的吞吐能力,定位潜在瓶颈,给出可落地的调优建议。
1. 测试目标与场景设计
1.1 先想清楚压什么,再动手压
很多人一上手就是建线程组、配并发数、跑聚合报告,但微服务的压测和单体应用有一个很大的区别:请求链路长、组件多,如果不先明确测试目标和业务场景,压出来的数据很难指导优化。
我这次把测试目标拆成了三个层级:
- 功能验证级:确认脚手架各组件在压力下不报错、不超时,接口返回数据正确。
- 容量评估级:找到系统在可接受的响应时间(比如RT p99 < 500ms)下的最大吞吐量(TPS)。
- 瓶颈定位级:在压测过程中同步监控各节点的CPU、内存、GC、连接池等指标,找出最先到达瓶颈的资源或组件。
场景设计上,我选择了三条具有代表性的链路:
- 网关直通链路:客户端请求 → SpringCloud Gateway → 后端单服务,这测试的是网关本身的基础转发能力。
- 服务间调用链路:客户端请求 → Gateway → 服务A → OpenFeign → 服务B → 数据库,这是微服务最典型的调用场景,重点看服务间调用的开销和数据库连接池的容量。
- 限流降级链路:客户端请求 → Gateway → 服务 → Sentinel,验证限流规则在压力下是否能正常触发,以及降级兜底逻辑是否正确。
这三条链路分开压,不要混在一起,否则出问题了你很难定位是哪一段掉了链子。
1.2 压测环境准备
压测环境我建议单独部署,尽量不要复用开发或测试环境。原因很简单:开发环境有其他人在联调,数据随时在变,压测结果根本不可信。
这次用的是三台独立机器:
- 压测机(JMeter):8核16G,跑JMeter主控和分布式压测节点。
- 应用节点:8核16G,部署SpringCloud脚手架相关服务。
- 数据库节点:4核8G,部署MySQL。
服务部署方式用的是Docker Compose,脚手架里本身带了编排文件,起服务很方便。数据库单独放在了物理机上,避免容器化带来的性能干扰。
注意:压测机和被测机的网络延迟要控制在1ms以内,最好在同一个内网网段。跨公网压测的结果几乎没有任何参考价值,因为网络延迟会完全淹没应用的性能差异。
2. JMeter脚本设计要点
2.1 线程组参数怎么定
线程组是压测的入口,参数设置直接决定了压测的形态。我这次用的是JMeter 5.x,几个关键参数分享下:
并发数(线程数):不要一上来就跑1000并发。我习惯用阶梯加压的方式,先50、再100、200、500,每个梯级跑5分钟,观察TPS和RT的变化趋势。这样能清楚看到系统在哪个并发点开始出现性能拐点。
Ramp-Up Period:意思是多长秒内把线程全部启动。比如100个线程,Ramp-Up设10秒,就是每秒启动10个线程。我建议Ramp-Up时间设置为并发数除以每秒期望启动的线程数,一般控制在5到15秒。
循环次数:勾选"永远",然后用Scheduler的持续时间来控制压测时长。这样比固定循环次数更精准,也好统计数据。单轮压测时长建议不少于10分钟,太短了数据不稳定,尤其是GC抖动和第二轮热点代码编译(JIT)的影响还没完全稳定。
思考时间(Think Time):压测要区分两种模式:并发能力测试和容量测试。这次的目的是验证系统容量,所以不加思考时间,让请求像洪水一样连续打过去,测的是系统的极限处理能力。
2.2 用梯度压测替代固定并发
我这次没有用一个固定并发跑到底,而是用了一个简单的阶梯递增脚本,模拟真实业务量慢慢涨上来的过程:
# 阶梯线程组配置示例(通过JMeter插件或用多个线程组串联) # Thread Group 1: 50并发,持续2分钟 # Thread Group 2: 100并发,持续2分钟 # Thread Group 3: 200并发,持续2分钟 # Thread Group 4: 400并发,持续2分钟 # Thread Group 5: 600并发,持续5分钟这样的好处是,你能直接看到TPS和RT的曲线在哪个并发点开始陡变,而不是等到压测结束去聚合报告里看平均数。平均数会掩盖很多问题——比如前50并发表现很好、后500并发崩溃,平均一下看起来好像还能用,但实际系统已经不可用了。
提示:如果想用JMeter原生功能实现梯度压测,可以用多个线程组并列、设置不同的启动延迟来实现,效果跟插件差不多,还能避免额外插件的兼容问题。
2.3 断言、监听器和数据采样
HTTP请求里我加了两层断言:
- 响应状态码断言:必须返回200。
- 响应内容断言:检查响应体里是否有预期的业务标识字段。
这样能确认不只是网络通,业务逻辑也正常处理了。
监听器我用了三种配合:
- 聚合报告(Aggregate Report):看整体TPS、平均RT、错误率、p90/p99。
- 用简单数据写入器(Simple Data Writer):把每次请求的响应时间落盘,后面用脚本做分位数分析。
- 后端监听器(Backend Listener):配合InfluxDB+Grafana展示实时曲线。这个强烈推荐,压测过程中看着实时TPS和RT曲线变红变绿,比事后看报告直觉得多。
关于采样数据,JMeter默认在聚合报告里只统计样本总数、平均数和百分比,但压测的时候我更关心的是p99的走势。建议在简单数据写入器里把"Successes"和"Failures"分开保存,方便后续分析错误请求的分布。
3. 压测执行与监控体系搭建
3.1 压测前的预热和基线
正式压测前,我先跑了一轮小流量预热,大概100并发持续5分钟。目的是让JIT编译热点代码、让Spring容器和数据库连接池都处于正常工作状态。如果不预热,刚开始跑的前几百个请求里,很多类和方法还在解释执行,响应时间会偏大,数据会误导人。
预热完,我记录了一组基线数据,作为后续压测的参照。
基线数据包括:
- 单次请求的平均RT(经过网关 → 服务A → 服务B → MySQL,全链路)
- TPS基线
- 数据库连接池当前活跃连接数
- 各服务pod的CPU和内存占用
有了基线,后面跑任何一轮压测,都有对比的锚点。
3.2 压测过程中要盯哪些监控指标
压测不是启动JMeter就完事了,真正的技术活在于压测过程中能不能眼观六路,在系统还没完全崩溃前捕捉到瓶颈的信号。
我这次同时开了三块监控屏:
应用监控(通过SpringBoot Actuator暴露指标)
- Heap内存使用量
- GC频率和耗时(重点看Full GC,如果Full GC频率超过了每分钟一次,基本说明堆太小或内存泄漏)
- 活跃线程数(Tomcat线程池/Netty事件线程)
- 数据库连接池活跃连接数和等待获取连接的线程数
系统监控
- CPU使用率(用户态和系统态分开看)
- 磁盘IO(如果有大量日志写入,磁盘IO也可能变成瓶颈)
- 网络带宽和TCP重传率
链路追踪
脚手架里带了Sleuth+Zipkin,压测的时候我采样了一部分请求,看各节点耗时分布。这个非常有用,能直接告诉你时间到底花在哪个环节——是网关转发耗时高,还是OpenFeign调用对端服务慢,还是SQL慢查询。
强烈建议:压测时把JVM的GC日志打开,并记录日志到文件。分析结果时,如果TPS波动很大,多半能在GC日志里找到重大停顿(Stop-The-World)的证据。
3.3 分布式压测的选择
单台JMeter压测机在压到较高并发(比如800以上)时,JMeter本身可能成为瓶颈,因为要维护成千上万个线程的状态和网络连接。
我这次的压测目标是600并发以内,单台压测机其实够用了,但我还是准备了JMeter分布式压测的预案,以防模拟量需要继续往上加。
分布式压测配置要点:
- 一台主控机(Master),两台执行机(Worker/Slave)
- 主控机只负责下发脚本和收集结果,不加压
- 所有执行机的时间要同步,否则聚合报告的时间戳对不上
- CSV数据文件如果用到的话,要分发给所有执行机,不能只在主控机上
配置方式:
# 在每台执行机上启动jmeter-server jmeter-server -Djava.rmi.server.hostname=<执行机IP> # 在主控机上指定远程主机列表 jmeter -n -t test_plan.jmx -R <执行机1IP>,<执行机2IP> -l result.jtl注意:分布式压测的输出结果要统一汇总到主控机,聚合分析用汇总后的JTL文件。另外,用远程主机跑的时候,每台机器上的JMeter版本要完全一致,不然可能会有兼容性报错。
4. 压测结果分析与调优实录
4.1 第一轮压测:网关直通链路的体感
先跑网关直通链路,脚本很简单:一个HTTP请求打到网关,网关直接路由到后端服务的一个健康检查接口(这个接口查了一次缓存,返回很快)。
线程组设置:50、100、200、400、600五个梯级,每梯级持续3分钟。
我预期网关应该能扛住很高的并发,毕竟只是转发,不涉复杂业务逻辑。但实际数据让我有点意外:
| 并发数 | TPS | 平均RT(ms) | p99(ms) | 错误率 |
|---|---|---|---|---|
| 50 | 480 | 104 | 210 | 0% |
| 100 | 620 | 161 | 305 | 0% |
| 200 | 710 | 282 | 520 | 0.1% |
| 400 | 780 | 513 | 986 | 0.8% |
| 600 | 760 | 790 | 1620 | 2.3% |
注意几个信号:
第一,200并发往上,TPS增长非常缓慢,400并发到600并发TPS基本原地踏步,这是典型的瓶颈已现的表现。 第二,平均RT在400并发时已经超过500ms,对业务来说已经偏慢。 第三,错误率从400并发开始出现,600并发时明显升高,说明有连接超时或线程池拒绝的情况。
顺着这个信号,我看了网关所在Pod的监控。
发现:Netty事件循环线程的占用率接近100%,同时线程池的等待队列持续堆积。也就是说,网关本身成了第一个瓶颈,而不是后端服务。
进一步分析,SpringCloud Gateway默认基于Netty,事件循环线程数是CPU核数的两倍。服务器8核,事件线程16个,单链路压测时,所有请求都在走路由转发的逻辑,Netty处理能力被压到了极限。
另外一个发现是:路由断言的正则匹配也吃了不少CPU。脚手架里测试环境的网关配置了十几条路由,每条路由都有断言表达式,Gateway在转发前会逐条进行模式匹配,规则多了之后开销不可小觑。
4.2 网关调优:线程模型和路由配置
针对网关瓶颈,我做了几个调整:
调大Netty事件循环线程数。这个参数在application.yml里配置:
spring: reactor: netty: event-loop-count: 32改成32后,重新压测,同等600并发下TPS从760提升到了920,RT也有下降。
但这里要提醒一下:事件循环线程数不是越大越好。每个事件循环线程绑一个Selector,线程之间会有上下文切换开销。一般建议是CPU核数的2到4倍,具体需要实测调整。我这边8核机器配32是偏激进的,实际生产环境建议从16开始试。
精简路由断言。把网关路由里低优先级的正则表达式改成精确匹配或者Path前缀匹配,减少匹配开销。比如:
# 原配置(正则匹配,开销较大) - id: xxx-service uri: lb://xxx-service predicates: - Path=/api/xxx/** # 调优后(PrefixPath + 精确匹配,减少正则计算) - id: xxx-service uri: lb://xxx-service predicates: - Method=GET,POST不过说实话,路由匹配的优化对整体吞吐的提升幅度有限,主要收益还是在事件循环线程数的调整上。路由优化更大的价值在于降低单请求的CPU消耗,让同一台机器能支撑更多实例。
4.3 第二轮压测:服务间调用链路的数据库瓶颈
网关直通链路调完后,我开始压第二条链路:客户端 → Gateway → 服务A → 服务B → MySQL。
这条链路加了真实的业务逻辑:从MySQL查一批订单数据,做一些聚合计算后返回。查询走的是索引,单次SQL耗时大约2到5ms。
结果如下:
| 并发数 | TPS | 平均RT(ms) | p99(ms) | 错误率 |
|---|---|---|---|---|
| 50 | 220 | 227 | 390 | 0% |
| 100 | 315 | 317 | 580 | 0% |
| 200 | 340 | 588 | 1105 | 0.3% |
| 400 | 310 | 1290 | 2600 | 3.8% |
这次的问题更明显:TPS在200并发后就上不去了,RT却还在涨,错误率也上来了。这是典型的某一下游组件容量满的表现。
按经验,先看数据库。
数据库侧监控显示:MySQL的CPU使用率接近100%,连接数持续飙高,大量线程处于"Waiting for table metadata lock"或"executing"状态。慢查询日志里出现了好几条之前没见过的慢SQL。
这里有个很常见的坑:脚手架里的数据库连接池默认配置是maximum-pool-size: 10,对单体应用可能够用,但微服务场景下,多个服务实例共享同一个数据库,每个实例都有自己的连接池,叠加起来很容易打爆数据库的连接上限。
我当时数了一下:服务A部署了3个实例,每个实例的HikariCP连接池最大10,服务B部署了3个实例,也是10,加上其他服务,一共几十个连接同时打到数据库。数据库默认的max_connections是151,很容易就被占满了。
优化方案有两步:
第一步,把服务A和服务B的maximum-pool-size从10调整到合适的值。怎么算?参考PGBouncer的公式:连接数 = ((核心数 * 2) + 有效磁盘数)。对普通SSD,一般给到每个实例5到8就够,我最后每个实例配了6。
spring: datasource: hikari: maximum-pool-size: 6 minimum-idle: 2 connection-timeout: 30000 idle-timeout: 600000第二步,分析慢SQL。
慢查询里有几条很典型:一个ORDER BY create_time DESC LIMIT 10的查询,因为create_time没建索引,走了全表扫描。还有一个关联查询,join时驱动表的过滤字段没索引。
这两个在压测前其实就有,只是没人发现。压测的高并发让它们现了原形。
给create_time和关联字段加了索引后,这两条SQL的耗时从几百毫秒降到了几毫秒,数据库的CPU负载明显下降。
提示:压测前的数据准备也很关键。我这次测试库里的订单表只有2万多条数据,压测时加了索引,一切正常。但如果数据量到几百万条,索引策略和SQL行为可能完全不同。建议在准备环境时,把数据量往真实环境靠,不然容易误判SQL的真实性能。
4.4 JVM参数和线程池的调整记录
第三次优化,我把注意力转向了应用侧的JVM参数。
第一轮压测时,服务A的堆内存默认配置是-Xmx1g,压到400并发时,GC日志显示Full GC出现了好几次,每次停顿都在300ms以上,RT曲线有明显锯齿状波动。
分析下来,堆太小只是表象,更核心的问题是新老年代的分配比例不合理。服务A的逻辑是:请求进来 → 查库 → 组装JSON → 返回,大量短生命周期对象在Young区被创建和回收。默认的-XX:NewRatio=2表示老年代是年轻代的2倍,但在这个场景下年轻代偏小,对象过早晋升到老年代,导致老年代快速填满,频繁触发Full GC。
调整后的参数:
-Xms2g -Xmx2g -XX:NewRatio=1 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=2 -XX:+UseG1GC -XX:MaxGCPauseMillis=200核心思路:
- 堆从1g加到2g,给老年代留更多空间。
NewRatio=1让年轻代和老年代大小相当,给短生命周期对象更大空间。MaxTenuringThreshold=2让对象在年轻代经历两次GC后如果还活着就晋升,减少无意义的复制。- 用G1替代默认的ParallelGC,毕竟微服务实例一般不需要追求极端吞吐,更在意响应时间的稳定性。
调整后,同一并发下,Full GC频率从每分钟3次降到了基本不出现,RT曲线平稳了很多。
还有一个线程池的坑也值得一提。服务A里的一个线程池(用来异步处理某个上报任务)初始配置是:
ThreadPoolExecutor( 2, // corePoolSize 10, // maxPoolSize 60, // keepAliveSeconds new LinkedBlockingQueue<>(100) )压测时发现,当并发请求打到300以上时,这个线程池的任务队列持续堆积,导致异步任务的完成时间从几百毫秒拖到几秒,反过来增加了请求的RT(因为主流程里有一步在等待异步任务的结果)。
调整思路:把corePoolSize设为8,队列容量降到20,最大线程数设为20,并用CallerRunsPolicy作为拒绝策略。这样在高并发下,任务不会无限堆积在队列里,而是让提交任务的线程自己执行,起到了天然背压的作用。
ThreadPoolExecutor( 8, // corePoolSize 20, // maxPoolSize 60, // keepAliveSeconds new ArrayBlockingQueue<>(20), // 队列改小 new ThreadPoolExecutor.CallerRunsPolicy() )4.5 Sentinel限流和降级验证
最后验证的是限流降级链路。Sentinel在脚手架里已经集成了,我配置了一条针对服务A某个接口的限流规则:QPS超过300时,触发限流,返回一个降级提示。
压测时直接把并发拉高,看Sentinel的表现:
| 并发数 | 通过QPS | 被限流QPS | 降级响应体 |
|---|---|---|---|
| 100 | 98 | 0 | 无 |
| 300 | 299 | 1 | 无 |
| 500 | 300 | 200 | 降级提示 |
| 800 | 300 | 500 | 降级提示 |
Sentinel的限流表现很精准,超过阈值后,超出的请求全部走了降级逻辑,没有出现击穿的情况。这说明脚手架里Sentinel的基础配置是生效的。
但这里有个细节值得注意:Sentinel默认的规则是内存态,也就是服务重启后规则会丢失。如果真的要做生产环境的限流保护,最好把规则推到Nacos配置中心,用推模式下发,而不是直接在控制台上临时添加规则。这也是很多团队在用脚手架时容易忽略的点。
5. 常见问题与排查技巧实录
5.1 压测过程中遇到的典型问题
这次压测前后,踩了一些坑,整理出来给各位参考。
问题一:压测结果忽高忽低,TPS像过山车
排查过程:
- 首先排除网络因素,内网ping延迟正常。
- 想到可能是JIT编译波动,但预热后仍然不稳。
- 看GC日志,发现Full GC频繁,平均每1到2分钟一次。
- 用
jmap -dump抓了堆快照,分析发现有个ConcurrentHashMap存了不该常驻的中间结果数据,导致老年代持续增长。
解决:修正了代码逻辑,把缓存对象改成合适的数据结构,并且给缓存设了过期时间。
这个问题的通用排查思路是:TPS波动大 → 先看GC → 再快照堆分析 → 定位是内存泄漏还是大对象。不要上来就调参,要找到根因。
问题二:压测时连接池大量"Connection is not available"报错
这个算是微服务压测的经典问题。报错信息:HikariPool-1 - Connection is not available, request timed out after 30000ms。
原因分析:
- 数据库的最大连接数限制(
max_connections=151)导致连接分配失败。 - 各服务实例的连接池配置没有基于实际容量规划,叠加后超过数据库上限。
- 慢SQL长时间占用连接,让情况雪上加霜。
解决步骤:
- 降低各服务实例的
maximum-pool-size到合理值。 - 给慢SQL加索引,缩短单条SQL占用连接的时间。
- 数据库侧适当调高
max_connections,同时评估是否需要连接复用中间件(如ProxySQL)。
问题三:JMeter自身成为瓶颈
当压到600并发时,JMeter的CPU已经接近100%,TPS曲线不稳定,结果失真。
识别方法:查看压测机CPU,如果JMeter所在进程CPU超过80%,基本就是压测机瓶颈。
解决:改用分布式压测,把负载分散到多台机器,或者降低每个线程组的线程数,增加线程组数量(不建议,会引入线程管理和调度的额外开销)。
5.2 压测结果数据可信度判断
压测结束,面对一堆聚合数据,要有基本的判断力。以下几个信号,只要出现一个,我就认为这轮压测的结果不可用:
- 错误率超过1%(稳定性测试场景下超过0.1%就要警惕)。
- 压测机的CPU超过80%,说明压测脚本本身限制了目标系统的压力。
- 数据库连接池出现大量等待获取连接的日志,说明数据本身是在异常状态下跑出来的。
- 被测服务的某个实例发生过重启(OOM或健康检查失败),这轮数据基本作废。
压测报告里,我习惯把p99、TP99.9和平均RT一起列出来,只给平均值的报告,在生产环境排障时几乎没什么用。平均值是"感觉",p99才是"真相"。比如平均RT是120ms,听着很健康,但p99是1.2s,说明有1%的用户体验已经明显卡顿了。
6. 脚手架性能调优的整体复盘
三轮压测做完,整个脚手架的调优动作汇总成了一张表:
| 优化项 | 调整前 | 调整后 | 效果(同等并发下TPS提升) |
|---|---|---|---|
| 网关Netty事件线程数 | 16 | 32 | 约20% |
| 数据库连接池 | 10/实例 | 6/实例 | 数据库连接不再成为瓶颈 |
| 慢SQL索引 | 无 | 已添加 | 单SQL耗时下降90%以上 |
| JVM堆和GC参数 | -Xmx1g + ParallelGC | -Xmx2g + G1 | Full GC频率显著降低,RT稳定 |
| 异步线程池参数 | 队列100 + max10 | 队列20 + max20 + CallerRuns | 高并发下异步任务延迟可控 |
这些优化不是一次完成的,而是压测→分析→调整→再压测的循环中逐步迭代的。每一轮压测都有一个明确的验证目标,改动也尽量控制在单一维度,避免多个变量叠加导致无法判断是谁起的决定性作用。
调优后,三条链路的最终数据:
- 网关直通链路:在600并发下,TPS稳定在910,p99在380ms以内,错误率0%。
- 服务间调用链路:在400并发下,TPS稳定在380,p99在620ms以内,错误率0.1%以内。
- 限流降级链路:限流规则精准生效,降级逻辑正常,未出现服务雪崩迹象。
这个数据虽然谈不上极致,但对于微服务脚手架的基础能力来说,已经达到了可上线使用的标准。剩下更高吞吐的追求,需要在架构层面(比如缓存、异步化、读写分离)做更大手笔的改造,那就已经超出了这次压测的范畴。
从压测这件事本身来说,它从来不只是把JMeter跑起来看看数字,而是通过数据反向审视整个系统的设计合理性。这次压测让我对脚手架里每个组件的配置参数心里有了底,也摸清了哪些地方将来业务量增长时得优先扩容。如果你也在打磨微服务脚手架,强烈建议按同样节奏跑一轮压测,很多潜在问题早暴露永远比晚暴露好。