恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
用Groovy脚本搞定Tomcat压测:从动态参数到JMX监控实战
首页
资讯中心
/
用Groovy脚本搞定Tomcat压测:从动态参数到JMX监控实战
用Groovy脚本搞定Tomcat压测:从动态参数到JMX监控实战
发布时间:2026/10/19 4:48:24
做Java后端、中间件运维或者自动化测试的朋友迟早要面对一个绕不开的课题给线上那台Tomcat做个像样的性能测试。JMeter大家都熟但如果你只拿默认的HTTP请求元件加个CSV参数就去压测通常会遇到三类尴尬接口的动态参数没法真实模拟登录态关联麻烦得要死服务端Tomcat线程池和内存到底有没有被打死也完全看不到。前两年我在做一个网关项目的容量评估时把JMeter的JSR223断言、前置处理器、后置处理器全部换成Groovy脚本来实现配合JMX远程监控Tomcat的MBean才算第一次真正把压测做明白了。这篇文章把我从脚本设计、环境准备、Groovy核心代码到结果解读的完整流程都整理出来你可以直接按步骤去复现也可以只看你的项目需要的那一部分代码。内容更适合有一点点JMeter基础、想往性能测试深水区走的朋友纯小白也不用慌我会把每个关键设计为什么这样做都讲清楚。1. 为什么坚持用Groovy脚本做Tomcat压测1.1 Tomcat压测的三个实际痛点先聊我踩过的真实场景。那次压测的对象是一个部署在Tomcat上的业务服务核心接口依赖两个动态参数一个是由服务端下发的临时凭证另一个是带时间戳的签名值。传统做法是把参数提前生成几千条数据放进CSV文件压测的时候用CSV Data Set Config逐个读取。听起来很顺但实际上只要压测时长超过10分钟CSV里的数据就不够用了而且每次都去动态生成几万条固定数据测试准备本身就成了瓶颈。第二个痛点是依赖传递。业务流程往往是先调登录接口拿token再带着token去访问真正的业务接口。这时候如果你用JMeter正则表达式提取器做token关联写出来的正则又丑又脆接口返回结构稍微变一下整个提取就失效。我后来直接用Groovy脚本拿到prev对象的响应体用JSON解析器取值一行代码解决再也不用在那条又长又难维护的正则上反复调试。第三个痛点更隐蔽压测机上看到的聚合报告一片飘绿但Tomcat那边的线程池早就被打满了。原因是JMeter默认只采集客户端视角的采样数据服务端Tomcat的线程数、忙线程数、连接数这些东西你根本看不到。要拿到这些指标就得自己写监控脚本去连Tomcat的JMX端口读取它暴露出来的MBean属性。这三件事用Groovy做都能在半小时内搞定。1.2 Beanshell、JavaScript与Groovy的取舍JSR223脚本组件在JMeter里其实支持很多种语言最常见的有Beanshell、JavaScript和Groovy。如果你打开JMeter的旧教程满屏都是Beanshell的例子但官方从5.x开始就已经明确建议新写脚本一律用Groovy原因很简单。先看性能差距。Beanshell脚本在执行时需要动态解释每一次调用都会产生不少额外开销在高并发场景下这个开销会直接吃掉压测机自己的CPU导致你测的不是Tomcat而是JMeter本身。Groovy则引入了编译缓存机制JMeter会缓存已经编译好的Groovy类同一个脚本第二次执行时直接走缓存执行一次和十万次的开销差距极小。再看语法兼容性。Groovy几乎就是Java的增强版原来写Java的习惯可以直接沿用同时它又带了很多语法糖比如JsonSlurper、GPath字符串模板、闭包等等。JavaScript虽然学起来也容易但和JMeter内部的API交互不够自然类型松散反而容易埋雷。我把三种语言的核心差异整理成一张表方便你在技术方案评审时直接引用对比项BeanshellJavaScriptGroovy官方推荐程度已不推荐一般强烈推荐编译缓存无有限完整缓存Java API兼容性一般差好JSON/XML处理手写解析较繁琐内置JsonSlurper高并发下CPU开销高中低调试友好度低低高还有一个细节很简单JMeter 5.x版本安装包里已经内置了Groovy库你不用额外装插件就能在JSR223元件里直接选Groovy语言。我第一次用的时候还去找插件后来才发现是自带的省了一堆事。2. 测试场景与脚本框架的整体设计2.1 先搞清楚Tomcat压测到底要测什么很多新手拿到压测需求就直接开JMeter灌流量这是最容易翻车的。Tomcat作为一个Servlet容器它的性能瓶颈和你的应用部署方式强相关至少要先分清楚要压的是哪一层。常见的压测对象有三种。第一种是纯静态资源比如Tomcat直接返回的HTML、图片瓶颈通常在Tomcat的并发连接数和网络吞吐第二种是动态Servlet或者说Spring MVC写的Controller瓶颈大概率在Tomcat线程池和你的业务代码耗时第三种是经过Tomcat连接池访问数据库的接口这时候瓶颈可能根本不在Tomcat而在数据库连接池配置上。我在做脚本设计前一定会先看目标接口是否依赖外部服务有没有缓存是同步还是异步。比如登录接口里面调一次数据库查询和商品列表接口走了Redis缓存这两者的压测结论完全不同。提前把这些信息画出来再决定测试脚本的结构才能避免压了半天测出个无关指标的情况。另外要明确压测的类型。是做容量测试、稳定性测试还是并发极限测试。它们对脚本的要求不一样短时容量测试可以接受较多的随机参数生成逻辑稳定性测试则要关注长时间运行下脚本自身有没有内存泄漏极限测试反而要把超时设置调得相对激进一点不然全是超时报错你也看不出真实容量。2.2 脚本目录与JMeter测试计划的结构设计一个规范的压测脚本工程不应该是单个孤零零的jmx文件。我的习惯是建一个独立的项目目录把测试计划、数据文件、Groovy脚本、压测结果分开存tomcat-perf/ ├── plan/ # jmx测试计划文件 ├── scripts/ # Groovy脚本文件 │ └── common/ # 公共函数库 ├── data/ # 参数化数据文件 ├── report/ # 压测结果与聚合报告 └── jmx/ # Tomcat JMX连接配置这个结构看着简单但后面接CI流水线或者多环境切换时会非常舒服。Groovy脚本建议抽成公共代码时使用props或vars来传递变量尽量别写死。比如环境地址和JMX端口这种经常变化的信息单独放到一个用户自定义变量或属性文件里脚本里统一引用。测试计划内部的组织也有讲究线程组上面放配置元件用户定义的变量、CSV数据源、HTTP请求默认值和开启Tomcat JMX监控的定时线程组线程组下面放业务请求和JSR223处理器。我习惯把公共的前置逻辑放到测试计划级别的JSR223脚本里把单请求逻辑放到请求级别的JSR223预处理脚本里这样职责清晰。2.3 估算并发数、吞吐量与Ramp-up在搭脚本前还有一个绕不开的工作确定压测参数。很多朋友问并发数到底设置多少合适这个问题没有一个固定答案但可以算。理论并发数和QPS、响应时间之间存在强相关关系可以用Littles Law来估算并发数 目标QPS × 平均响应时间。比如目标是压出每秒1000次请求接口平均响应时间是200毫秒那么并发数大致就是1000 × 0.2 200个线程。这里有一个前提就是系统必须处于稳定状态响应时间没有剧烈抖动的背景下才成立。Ramp-up时间的设置同样有讲究。我不会设置过长的爬坡时间一般压测10到15分钟时Ramp-up控制在30秒到60秒之间就足够。太长会导致整体压力曲线太平滑难以观察到突变点太短则可能一上来就把Tomcat打崩连错误堆栈都来不及抓。实际做法是先用20个线程跑30秒做冒烟确认脚本没问题后再把并发数拉到估算值。需要注意的是如果目标接口本身比较慢比如单次响应时间是2秒那200个并发带来的QPS其实只有100左右这种情况下光调线程数没用真正的瓶颈可能在应用自身。我会结合聚合报告的吞吐量列来判断压测机输出的压力是否符合预期如果QPS远低于理论值先查脚本是否有超时重试或者断言过重的问题。3. Groovy脚本开发核心实战3.1 动态参数生成时间戳、随机数和UUID动态参数是Groovy脚本用得最频繁的场景没有之一。拿最常见的接口签名举例服务端要求每个请求带一个当前时间戳、一个随机数、一个由这些参数拼出来的MD5签名。用Groovy写前置脚本大概是这样import java.security.MessageDigest // 生成时间戳毫秒和随机数 def timestamp System.currentTimeMillis().toString() def nonce UUID.randomUUID().toString().replaceAll(-, ).substring(0, 16) // 假设服务端约定按固定规则拼接后计算MD5 def raw appIdtesttimestamp${timestamp}nonce${nonce}secretyour_secret def md5 MessageDigest.getInstance(MD5) .digest(raw.getBytes(UTF-8)) .encodeHex() .toString() vars.put(req_timestamp, timestamp) vars.put(req_nonce, nonce) vars.put(req_sign, md5)把vars.put的值在后续HTTP请求中用${req_timestamp}引用即可。这里有一个必须注意的点vars本身是JMeterVariables对象复用率极高命名一定要规范加上前缀否则遇到多个请求共用一个变量时很容易出现变量被后一个脚本覆盖的诡异问题。如果是随机手机号或者身份证这类业务参数不建议用JMeter自带的${__Random}函数去拼因为函数在循环里的可读性和可控性都差。用Groovy会清晰很多还能支持更复杂的约束条件比如特定号段、带校验位等。3.2 接口依赖与Token关联脚本接口依赖是性能测试里仅次于动态参数的第二大痛点。我在项目里最常处理的流程是压测脚本先请求登录接口从JSON响应里解析出token然后后续的业务接口在请求头里带这个token。传统的做法是写正则表达式而正则表达式对于JSON这类结构化数据既不直观也不稳定。换Groovy之后用内置的JsonSlurper几行解决import groovy.json.JsonSlurper // prev是JSR223脚本引擎注入的上一个采样结果对象 def respData prev.getResponseDataAsString() def json new JsonSlurper().parseText(respData) // 假设返回结构是 {code:0,data:{token:abc123}} def token json.data.token if (token null || token.isEmpty()) { throw new AssertionError(登录响应中没有解析到token响应体 respData) } vars.put(auth_token, token) // 顺手把采样标签改成带用户名的形式方便后续查看 prev.setSampleLabel(login-${vars.get(test_user)})这段代码通常会放在登录请求下的JSR223后置处理器中。很多人在后置处理器里写脚本时忽略了prev对象的存在以为只能用正则提取器其实就是对JSR223注入的变量不熟。记住一点JSR223处理器内部可以直接用prev、vars、props、log、ctx这五个基本对象它们分别代表了上一次采样结果、JMeter变量、全局属性、日志接口和上下文。token关联还有一种更复杂的场景token不是放在登录接口后面而是在一个独立的授权接口里而且响应里的状态码偶尔会变化。这种情况我会把解析逻辑抽成一个公共Groovy脚本段通过vars控制是否启用或者直接在JSR223脚本里写if (vars.get(need_login) true)来做分支判断。3.3 实时响应校验脚本在压测过程中如果只记录响应代码而不校验响应内容很容易出现一种情况聚合报告显示成功率99%但实际上业务逻辑已经全面返错。我就遇到过接口返回200但内容为空、响应码500但JSON被吞掉的坑所以断言脚本必须写得有业务含义。JMeter自带的响应断言用模式匹配适合简单场景。要做复杂的校验逻辑我倾向用JSR223断言。示例脚本import groovy.json.JsonSlurper // 先校验HTTP状态码 if (prev.getResponseCode() ! 200) { throw new AssertionError(接口返回非200${prev.getResponseCode()}, body${prev.getResponseDataAsString()}) } // 再校验业务状态码 def body prev.getResponseDataAsString() def json new JsonSlurper().parseText(body) if (json.code ! 0) { throw new AssertionError(业务码异常${json.code}, 消息${json.message}) } // 校验关键字段 if (!body.contains(orderId)) { throw new AssertionError(响应体中缺少orderId字段完整响应${body}) }JSR223断言有一个容易被忽视的好处它可以直接抛出带详细信息的AssertionError这些信息会实时显示在JMeter的断言结果监听器里比你看那一大堆ResponseAssertion: false要直观得多。有一点要提醒不要在断言脚本里做太多复杂JSON解析。压测任务本身就是高并发执行的如果每个采样都走一遍重量级JSON解析JMeter自身CPU开销会明显上升。如果只是判断一个字段优先用字符串contains实在需要结构化数据再用JsonSlurper并且尽量在JSR223脚本的缓存开启情况下使用。3.4 通过JMX脚本监控Tomcat线程池与内存这部分是我个人最推荐加的环节。压测时想要看清Tomcat的真实状态最可靠的数据来源不是看防火墙日志而是直接连Tomcat的JMX端口读MBean。前提是Tomcat启动时已经开启了JMX远程监控一般在catalina.sh里加上类似这样一段配置CATALINA_OPTS$CATALINA_OPTS -Dcom.sun.management.jmxremotetrue -Dcom.sun.management.jmxremote.port9010 -Dcom.sun.management.jmxremote.rmi.port9011 -Dcom.sun.management.jmxremote.authenticatefalse -Dcom.sun.management.jmxremote.sslfalse注意要同时指定jmxremote.port和rmi.port我这里写的是9010和9011实际按你的环境改。然后写一个独立的Groovy脚本放在一个单独的Tomcat监控线程组里和业务线程组并行跑。import javax.management.ObjectName import javax.management.remote.JMXConnectorFactory import javax.management.remote.JMXServiceURL def jmxUrl new JMXServiceURL(service:jmx:rmi:///jndi/rmi://${__P(tomcat_monitor_host,localhost)}:${__P(tomcat_jmx_port,9010)}/jmxrmi) def connector JMXConnectorFactory.connect(jmxUrl) try { def mbsc connector.getMBeanServerConnection() def threadPool new ObjectName(Catalina:typeThreadPool,name\http-apr-8080\) def current mbsc.getAttribute(threadPool, currentThreadCount) def busy mbsc.getAttribute(threadPool, currentThreadsBusy) def max mbsc.getAttribute(threadPool, maxThreads) // 写入监控数据后续可以用后端监听器或JSR223采样器输出到文件 vars.put(tomcat_current_threads, current.toString()) vars.put(tomcat_busy_threads, busy.toString()) vars.put(tomcat_max_threads, max.toString()) // 顺手记录JVM堆内存使用情况 def mem new ObjectName(java.lang:typeMemory) def heapUsage mbsc.getAttribute(mem, HeapMemoryUsage) def used (heapUsage.get(used) / 1024 / 1024).longValue() def committed (heapUsage.get(committed) / 1024 / 1024).longValue() vars.put(heap_used_mb, used.toString()) vars.put(heap_committed_mb, committed.toString()) log.info(Tomcat监控: 线程${current}/${busy}/${max}, 堆内存${used}MB/${committed}MB) } finally { connector.close() }这个监控线程组用定时器来采样建议用固定吞吐量计时器或者自定义间隔比如每2秒采样一次。重点看两个值currentThreadsBusy是否长期接近maxThreads、heap_used_mb是否持续上升。一个细节JMX连接本身也是有开销的监控线程组并发设成1个线程就行不要高并发去请求它。还有不要在生产环境未授权的情况下开启无认证JMX这是安全红线监控脚本在测试环境或者准生产环境用才是正常的。4. 完整实战压测一个Tomcat用户登录接口4.1 准备被测环境与JVM参数为了让你能照着做我拿一个典型场景举例Tomcat 9部署了一个模拟的用户登录接口接口逻辑是查询一次MySQL做一个密码校验然后返回token。在压测之前先把Tomcat的关键连接器和线程池参数调整到一个比较合理的基线不然压测结果没参考价值。编辑server.xml里的Connector和Executor配置。Tomcat 8.5之后的版本建议显式配置一个线程池执行器再将其引用到Connector上server.xml大致如下Executor nametomcatThreadPool namePrefixcatalina-exec- maxThreads400 minSpareThreads50 maxQueueSize100 / Connector executortomcatThreadPool port8080 protocolHTTP/1.1 connectionTimeout20000 maxConnections10000 acceptCount200 keepAliveTimeout30000 /这里的参数不要死搬主要看你的机器内核数和预期压力。maxThreads超过800以上就需要非常谨慎因为一个线程默认占用一个栈空间通常1MB800线程本身就占近1GB虚拟内存了。JVM内存参数也要同步调比如-Xms1g -Xmx2g -XX:MaxMetaspaceSize256m压测看容量和稳定性结论时最好先固定在一个固定的JVM堆配置下跑方便对比。4.2 JMeter测试计划组件清单压测脚本的结构建议从最小集合开始不要一上来就堆一堆监听器和后置脚本。以下是我在这个实例中用到的组件清单组件顺序按JMeter测试计划树从上到下排列组件名称类型关键配置用户自定义变量配置元件服务端地址、JMX连接参数、压测线程数HTTP请求默认值配置元件协议http服务器名${host}端口8080Tomcat监控线程组线程组线程数1循环次数无限定时器间隔2秒用户登录线程组线程组并发数按估算设置Ramp-up 30秒登录请求SamplerPOST /api/user/loginJSR223前置处理器登录请求下前置处理器生成时间戳、随机数JSR223后置处理器登录请求下后置处理器解析token并存入vars业务请求SamplerGET /api/user/info请求头带AuthorizationJSR223断言业务请求下断言校验code字段和关键业务字段聚合报告监听器使用非GUI模式时自动保存csv简单数据写入器监听器保存完整采样数据快速定位异常组件树建好之后有一个不容忽视的细节压测过程中千万不要把聚合报告和查看结果树同时挂上查看结果树会在内存里保存每个采样完整响应压测三五分钟就能把JMeter堆撑爆严重干扰压测结果。正确做法是压测结束后再打开结果文件分析。4.3 关键Groovy脚本串联登录请求的前置脚本负责生成动态参数也就是前面3.1节那段代码。登录请求的后置脚本负责解析token也就是3.2节那段代码。业务请求的JSR223断言脚本按3.3节走。三个脚本组合起来就是完整的链路。这里我补充一个容易被忽视的小细节JSR223脚本在JMeter中的编译缓存要显式开启。在jmeter.properties里找到关于jsr223的配置重点确认jsr223.init.enginegroovy jsr223.compiler_trimStacktracetrue jsr223.cache_size2000如果这个cache_size是0Groovy脚本每次执行都会重新编译在高并发下会浪费大量CPU。我当时在排查压测机CPU异常居高不下时最后发现就是缓存配置被改动过改回2000后CPU立刻下降了一个档次。4.4 执行压测与解读报告在命令行模式执行压测不要用GUIjmeter -n -t plan/tomcat-login.jmx -l report/login-results.jtl -e -o report/html-report -j report/jmeter.log其中-e和-o是生成HTML图形化报告的参数压测结束之后会输出一个非常直观的页面里面包含吞吐量、响应时间分位数、错误率等图表。聚合报告和HTML报告结合起来看重点盯这几个指标吞吐量Throughput是否达到目标如果上不去且Tomcat忙线程数已经接近maxThreads说明服务端全线程都在处理旧请求。平均响应时间和TP99TP99比平均值更能反映毛刺如果TP99是平均值的数倍强烈暗示Tomcat连接池或线程池排队严重。错误率曲线观察错误率是不是在某个并发点突然抬升这个拐点很可能就是Tomcat崩溃的临界点。Tomcat线程池监控如果currentThreadsBusy持续贴着maxThreads跑并且heap_used_mb还在稳步走高基本可以判定服务端需要扩容或者优化业务代码。报告解读这里最怕的就是只看QPS高就下结论。有一次我们压测一个接口聚合报告TPS有850但JMX监控显示Tomcat忙线程数一直是390/400再细看响应时间TP99已经从200ms飙到1.6秒这说明吞吐量是靠排长队堆出来的并不是系统真实处理能力。遇到这种数据正确的结论是容量远未达标。5. 常见问题与排查技巧实录5.1 Groovy脚本跑不通的五类报错Groovy脚本第一次写大概率不会一次通过我把实际踩过的坑按出现频率排个序做成速查表报错信息可能原因解决方案Error loading JSR223 script, groovy.lang.MissingPropertyException: No such property: vars for class脚本运行环境没有正确注入变量最常见是把独立脚本粘到了非JSR223元件里确认放置位置是JSR223前置处理器/后置处理器/断言元件javax.script.ScriptException: org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failedGroovy语法错误可能是引号或括号多了少了先在本地用命令行groovy跑一遍做语法校验java.lang.OutOfMemoryError: Java heap spaceJSR223脚本内部创建了大量对象比如在循环里反复new JsonSlurper把解析器实例放在脚本开头用Field定义开启编译缓存Response code: 500脚本取出变量为空导致请求发送了非法内容在前置脚本里增加变量校验为空直接failjava.net.ConnectException: Connection refused被测Tomcat端口连不上或者并发数把Tomcat打死了先确认Tomcat存活再查压测机防火墙和连接数有一个通用调试大法在Groovy脚本里加一行log.info输出关键变量然后在JMeter日志文件里跟踪。比如验证token是否解析成功直接打印token前20个字符一目了然。这是我排查脚本问题效率最高的手段比反复看监听器响应数据快得多。5.2 Tomcat压测结果失真连接数、KeepAlive与GC很多人压测结果不可信不是脚本问题而是没有处理好几个隐形变量。最典型的是操作系统的文件句柄限制Tomcat在高并发下会使用大量TCP连接如果在CentOS环境上没有调高ulimit -n连接数一到上限就会大量报错。压测前检查一下压测机和被测机的文件句柄数压测机往往也需要调高到65535以上。第二个是KeepAlive配置必须和真实业务对齐。如果线上业务用的HTTP长连接压测脚本却将Connection: close写死那Tomcat每秒都要处理额外的握手和挥手开销压测结果会明显偏低。反过来长连接压测也要注意JMeter默认对每个线程复用连接这其实是合理模拟长连接不需要刻意改成短连接。第三个是GC停顿问题。Tomcat的JVM在内存分配回收时会有短时间的停顿如果用默认的串行GC或者堆设置太小压测进行到中后期性能曲线会周期性掉坑。这就是为什么压测前固定好-Xms、-Xmx和GC算法非常重要。我一般会先把Tomcat的GC日志打开方便后面排查这类周期性性能抖动。5.3 压测数据有效性的判断方法压测结束不等于拿到有效结论。我积累了一个有效性三问流程第一问压测期间JMeter自身有没有成为瓶颈。最简单的方法是看压测机的CPU使用率如果JMeter所在机器的CPU长期在90%以上说明压力源已经饱和测出来的数据不准确。这时候要么增加压测机节点做分布式压测要么优化脚本减少不必要的开销。第二问被测Tomcat有没有在压测过程中发生崩溃重启。因为一旦Tomcat重启重启前的时间段数据就全部没意义。用JMX脚本监控java.lang:typeRuntime的Uptime属性如果Uptime突然重新归零基本就是重启了。第三问压测结果有没有表现出明显的临界突变。正常系统的性能曲线在并发上升过程中会有一个拐点超过拐点后响应时间急剧上升而吞吐量停止增长。取拐点前的数据作为容量基线拐点后的数据也只能说明系统已经过载不能拿来做容量规划。把这三问全过一遍压测报告里的每个数字才敢往外发。写在后面的一点个人体会做了这么多次Tomcat压测我最大的体会是性能测试不是把JMeter跑起来就完事脚本的可靠性、监控的完整性、参数设置的合理性三者缺一不可。Groovy脚本帮我把前三者从能跑提升到了可控。如果你现在还停留在用JMeter自带元件拼压测脚本的阶段不妨从今天这个用户登录接口的例子入手逐步把前置参数、后置解析、JSR223断言和JMX监控这些环节换成Groovy实现压测数据的可信度会有一个质的提升。顺带说一句脚本写完之后记得多在非GUI模式下跑几轮小并发冒烟测试把断言和监控符号确认停当之后再开全量压力能省下后续一大把排查时间。