做性能测试这些年,Jmeter里各种取样器我基本都折腾过,但要说最值得花时间深入研究的一个取样器,JSR223取样器绝对排第一。它不像HTTP请求那样开箱即用,也不像JDBC Request那样功能明确,但一旦你用熟了,就会发现它是整个Jmeter里最灵活、最能解决“测不了”问题的万能工具箱。这篇文章想把它彻底讲透,从原理到实操,从踩坑到优化,一次说完。
文章适合正在用Jmeter做接口测试、压测脚本开发、或者被各种“变态”业务报文折磨的测试工程师。无论你是刚接触JSR223取样器的新手,还是已经写了不少脚本的老手,里面都有可以直接抄作业的代码和思路。我尽量少讲废话,多上干货。
1. 为什么是JSR223取样器,先搞清楚它解决什么问题
1.1 一次压测事故让我重新认识它
先讲个真实经历。有次做线上压测,脚本里用了不少BeanShell取样器来做参数加密、动态报文拼接。单机跑200并发的时候还没啥感觉,等压到500并发,压力机CPU直接飙到90%以上,QPS却上不去,服务端负载压根没被打满,瓶颈全在客户端脚本上。后来把BeanShell全部换成JSR223取样器加Groovy脚本,同样500并发,压力机CPU降到了30%左右,QPS翻了一倍多。
那次之后我才真正意识到,取样器不只是“发请求”的工具,它在高并发下自身也会成为瓶颈。而JSR223取样器之所以性能好,核心在于JSR223是一套Java平台上的标准脚本接口规范,JMeter可以通过它加载Groovy等现代化脚本引擎,并且对编译后的脚本做缓存,执行效率接近原生Java代码。
1.2 JSR223到底是什么,和BeanShell有什么本质区别
JSR223全称是“Scripting for the Java Platform”,是Java社区制定的一套标准接口,让JVM上的Java程序可以统一调用各种脚本语言,比如Groovy、JavaScript、Jython等。JMeter里的JSR223取样器、JSR223前置处理器、JSR223断言、JSR223监听器,底层都是基于这套接口实现的。
对比BeanShell,两者的本质区别在于执行模型:
- BeanShell是解释执行,每次运行都要逐行解析脚本,而且JMeter老版本对BeanShell没有编译缓存机制,高并发下CPU开销非常大。
- JSR223 + Groovy则是先编译成字节码再执行,JMeter从3.1版本开始推荐使用Groovy,并且在检测到脚本没有变化时,会直接缓存编译后的结果,后续循环不再重复编译。
这也是为什么我在文章开头就强调:如果你还在用BeanShell写加密、拼报文、做断言,真的建议尽早迁到JSR223加Groovy。不是BeanShell不能用,而是在性能消耗上差了好几个级别。
1.3 哪些场景必须用JSR223取样器
根据我平时在项目里负责压测脚本开发的经验,JSR223取样器在下面这些场景几乎是不可替代的:
- 请求参数需要复杂计算,比如MD5/HMAC加签、AES加解密、时间戳动态生成。
- 请求体的结构不是固定模板,而是依赖前置接口返回的数据,需要动态拼接嵌套JSON或XML报文。
- 需要调用Java类库,比如自己写的工具类、第三方SDK,JSR223脚本里可以直接import使用,而普通取样器做不到。
- 需要在请求前后执行特定逻辑,比如从文件里读取CSV数据并按线程分块取用、根据条件跳过某些请求、标记请求成功失败等。
- 自定义Mock服务或者模拟器场景,直接用JSR223实现一套轻量但灵活的处理逻辑。
这些场景你去翻JMeter自带的取样器,没有一个能单独搞定,但JSR223取样器一个就够了。
2. 手把手搭建第一个JSR223取样器
2.1 创建步骤和界面字段解析
在测试计划里添加取样器的路径是:右键线程组 -> 添加 -> 取样器 -> JSR223取样器。添加之后你会看到几个关键字段。
- Language:脚本语言,默认是groovy。建议保持默认,后面会解释为什么。
- Parameters:向脚本传递参数的地方,多个参数用空格分隔,脚本里通过Parameters变量接收。
- Script:脚本主体区域,核心逻辑都写在这里。
- 下面的框还有一个“Cache compiled script if available”选项,JMeter高版本默认勾选。这个必须勾选,它决定了编译缓存是否生效,直接关系到高并发下的性能。
很多人在Parameters里传参后不知道怎么在脚本里取,这里说清楚:Parameters拿到的是原始字符串,也就是你填的那一整行,你需要自己用split或者正则解析。更推荐的方式是把参数放到JMeter变量里,脚本里直接用vars.get()取用,这样更清晰。下面给一个最简单的示例:
// 从JMeter变量里取值 def userId = vars.get("userId") def timestamp = System.currentTimeMillis() // 把计算结果放回变量,方便后面的HTTP请求引用 vars.put("genTimestamp", String.valueOf(timestamp)) log.info("userId=" + userId + ", timestamp=" + timestamp)2.2 脚本里可用的核心变量,务必要记牢
写JSR223脚本,本质上是在JMeter提供的脚本环境里操作内部对象。这些内置变量是官方公开的API,每个都不要忽略:
| 变量名 | 类型 | 作用 |
|---|---|---|
| log | Logger | 打印日志,建议用log.info/warn/error |
| vars | JMeterVariables | 操作JMeter变量,get/put,线程内可见 |
| props | JMeterProperties | 操作JMeter属性,全局可见,可跨线程 |
| ctx | JMeterContext | 当前线程上下文,可以获取线程组、线程号等信息 |
| sampler | Sampler | 当前取样器对象,大部分场景不需要直接用 |
| prev | SampleResult | 上一个取样器的结果对象,可以做断言和结果修正 |
| OUT | PrintWriter | 输出到控制台,不推荐在压测时用,容易刷屏 |
这里有个容易踩的坑:vars和props的作用范围完全不同。vars是线程内共享的,一个线程里你put进去的变量,同线程的其他取样器和断言都能读到;但不同线程之间读不到。props是JVM级别的属性,所有线程都能访问。跨线程传递数据,比如多个线程需要共享一个计数器的值,必须用props。
2.3 第一个实用脚本:动态生成手机号和唯一流水号
光看语法说明印象不深,直接给一个实际压测经常用到的脚本。比如你压一个注册接口,不能用同一个手机号反复注册,那就需要动态生成不重复的手机号:
import java.text.SimpleDateFormat // 生成手机号:常用号段 + 8位随机数 def prefixes = ["130","131","132","133","135","136","137","138","139","150","151","152","153","155","156","157","158","159","186","187","188","189"] def prefix = prefixes[(int)(Math.random() * prefixes.size())] def suffix = String.format("%08d", (int)(Math.random() * 100000000)) def phone = prefix + suffix // 生成依赖时间戳的流水号 def timestamp = System.currentTimeMillis() def dateFormat = new SimpleDateFormat("yyyyMMddHHmmss") def seq = dateFormat.format(new Date(timestamp)) vars.put("regPhone", phone) vars.put("reqNo", "REQ" + seq + String.format("%04d", (int)(Math.random() * 10000))) log.info("生成手机号: " + phone + ", 流水号: " + vars.get("reqNo"))跑一次就会在JMeter日志里看到打印的手机号和流水号。HTTP请求里直接引用${regPhone}、${reqNo}就可以。
2.4 调试技巧:日志查看与结果判定
JSR223脚本不像HTTP请求那样在察看结果树里能直观看到请求数据,调试主要靠两招。
第一招,log.info输出关键变量,然后到jmeter.log或者运行日志里看结果。这里注意,压测的时候一定要把不必要的信息级别调高,或者关掉脚本里的log输出,否则日志刷屏会严重影响压力机的性能。
第二招,配合Debug Sampler使用。在线程组里加一个Debug Sampler,勾选JMeter Variables,运行后在察看结果树里能看到当前线程的所有变量值。这招在排查参数拼接问题时特别高效。
还有一个小技巧:如果想让脚本执行结果直接反映到取样器的成功失败状态,可以直接修改prev对象。比如接口返回的HTTP状态码是200,但业务code是失败,你可以通过下面的方式把这个请求标记为失败:
def response = prev.getResponseDataAsString() if (response.contains("\"code\":500")) { prev.setSuccessful(false) prev.setResponseMessage("业务失败:" + response) }3. 进阶实战:从参数加签到跨线程数据处理
3.1 接口加签不再求人,一个脚本全搞定
做接口压测经常遇到需要加签的接口,很多测试同学一碰到这个就头疼,其实用Groovy脚本非常方便。下面以常见的MD5签名方式为例,假设签名规则是把appId、timestamp、body拼接后加盐,再做MD5:
import java.security.MessageDigest // 自定义MD5方法 def md5(String input) { def md = MessageDigest.getInstance("MD5") byte[] digest = md.digest(input.getBytes("UTF-8")) return digest.collect { String.format("%02x", it) }.join() } // 准备签名参数 def appId = "test_app_001" def secret = "your_secret_key_here" def timestamp = String.valueOf(System.currentTimeMillis()) def body = '{"userId":"10001","amount":99.50,"channel":"APP"}' // 按照约定规则拼接并计算签名 def signSource = appId + timestamp + body + secret def sign = md5(signSource) vars.put("signBody", body) vars.put("signValue", sign) vars.put("signTimestamp", timestamp) log.info("签名字符串: " + signSource) log.info("生成签名: " + sign)这个脚本放到线程组的JSR223取样器里,后面的HTTP请求就可以用${signBody}、${signValue}、${signTimestamp}来引用。如果用的是HmacSHA256,只需要把MessageDigest换成Mac类,代码结构一模一样。
3.2 嵌套JSON报文拼接,告别字符串地狱
在做接口压测时,经常遇到多层嵌套的JSON请求体。很多人习惯用字符串拼接,结果一个引号错位就是半天。用Groovy的JsonSlurper和JsonOutput可以非常优雅地解决:
import groovy.json.JsonOutput import groovy.json.JsonSlurper // 构造基础报文,这里模拟一个下单接口的请求体 def orderMap = [ orderId: vars.get("reqNo"), userId: vars.get("regPhone"), amount: 100.00, items: [ [ skuId: "SKU001", count: 1, price: 50.00 ], [ skuId: "SKU002", count: 2, price: 25.00 ] ], address: [ province: "浙江省", city: "杭州市", detail: "西湖区某街道100号" ] ] def requestBody = JsonOutput.toJson(orderMap) vars.put("orderBody", requestBody) log.info("生成请求报文: " + requestBody)实际压测的时候,items里的数据可以根据循环次数动态生成,比如用for循环往列表里塞数据,完全不需要手工维护一堆字符串模板。
3.3 跨线程共享计数器,实现真正的限流场景模拟
有时候压测场景需要模拟不同线程间的互斥逻辑,比如一个活动库存总量只有1000,100个线程去抢,每个线程抢到后库存减一,总量不能超。JSR223取样器可以结合props和Java的原子类实现:
import java.util.concurrent.atomic.AtomicInteger // 在setUp线程组里初始化库存(只执行一次) props.put("stock", new AtomicInteger(1000)) // 在业务线程组里扣减库存 def stock = props.get("stock") int remain = stock.decrementAndGet() if (remain < 0) { // 库存不足,标记失败 prev.setSuccessful(false) prev.setResponseMessage("库存不足,剩余: " + remain) vars.put("stockStatus", "FAIL") } else { vars.put("stockStatus", "SUCCESS") log.info("剩余库存: " + remain) }这个脚本的核心是利用AtomicInteger的原子性,确保多线程并发下计数的准确性。如果你用普通的int加props,在高并发下会出现数据竞争,统计结果不准。
3.4 配合JDBC查询结果做参数化,不再手工整理数据
网络热搜词里有一条是“jmeter将jdbc request查询出的数据作为下一个接口的参数”,这里顺带把JSR223处理JDBC结果集的方法写一下。JDBC Request的输出通常是一串形如[userId=10001, userId=10002]的数据,要转成SQL的IN子句或者JSON数组,用正则提取会一团乱,但Groovy几行就解决:
// 假设JDBC Request把结果存到了变量userIdList里 def raw = vars.get("userIdList") // 把形如[userId=10001, userId=10002]的字符串中的数字全部提取出来 def idList = (raw =~ /\d+/).collect { it[0] } // 拼成SQL IN子句 def inClause = idList.collect { "'" + it + "'" }.join(",") vars.put("userIdInClause", inClause) // 也可以拼成JSON数组 def jsonArray = groovy.json.JsonOutput.toJson(idList) vars.put("userIdJsonArray", jsonArray) log.info("IN子句: " + inClause)这样JDBC查出的数据就能直接复用到下一个接口的查询条件里。
4. 脚本语言怎么选,Groovy是不是唯一解
4.1 BeanShell、Groovy、JavaScript,三种引擎横评
很多初学的同学会纠结到底选哪种脚本语言。我把JMeter里常用的三种引擎拉出来对比一下:
| 对比维度 | BeanShell | Groovy | JavaScript (Nashorn) |
|---|---|---|---|
| 执行方式 | 解释执行 | 编译为字节码后执行 | 解释执行 |
| JMeter编译缓存 | 无 | 支持 | 有限支持 |
| 高并发性能 | 较差 | 优秀 | 一般 |
| Java类库支持 | 一般 | 非常完善 | 一般 |
| 语法友好度 | 靠Java,但API老旧 | 简洁,支持闭包和GPath | 熟悉前端的人容易上手 |
| 社区推荐度 | 老版本遗留 | 官方强烈推荐 | 已逐渐被Java模块移除 |
JMeter从3.1版本开始,官方文档就明确建议使用Groovy语言来编写JSR223脚本,理由就是性能和缓存机制。BeanShell在高并发下的性能开销太明显,而且JMeter官方对BeanShell的支持长期处于“基本不再维护”的状态。
4.2 为什么Groovy是压测脚本的主流选择
Groovy的优势在于它和Java无缝兼容,你可以直接import任何Java类库,同时它的语法比Java更简洁,支持字符串模板、闭包、集合的各种链式操作,写出来的脚本短小易读。
举个例子,同样是从响应报文里提取token:
- Java风格写10行,还要考虑空指针。
- Groovy一行搞定:
def token = (resp =~ /"token":"([^"]+)"/)[0][1]
更重要的是Groovy的编译缓存机制。JMeter会检测脚本内容有没有变化,没变化的话第二次循环开始直接执行缓存的字节码,这个差距在百万级请求场景下非常明显。
4.3 “Cache compiled script if available”为什么必须开启
这个选项默认是勾选的,但有次我发现有人把它取消了,原因是修改脚本后JMeter没生效,以为缓存导致不更新。其实这是一个误区:JMeter在脚本保存后会自动校验脚本内容是否改变,变了就会重新编译。取消缓存等于放弃性能优势,得不偿失。
如果你真的遇到改了脚本没生效的情况,排查方向应该是:
- 确认当前线程组是不是真的执行到了这个取样器。
- 确认有没有勾选了“仅一次控制器”或“循环次数”设置导致脚本没重新进入。
- 用ctrl+F5强制清空缓存重跑,而不是直接取消缓存编译选项。
5. 高频报错与性能优化实录
5.1 常见问题和解决对照表
这里整理了一些我在实际脚本开发中遇到的高频问题,很多都是从网上被问爆的热搜词里提炼出来的。
| 问题现象 | 原因分析 | 解决思路 |
|---|---|---|
脚本报错Script compilation failed | Groovy语法错误或引用的类不存在 | 把脚本粘贴到IDEA里先跑一遍,确认语法正确 |
| 日志里中文乱码 | JMeter编码设置不对 | 在jmeter.properties里设置sampleresult.default.encoding=UTF-8 |
| 高并发下CPU飙高 | 使用了BeanShell或脚本里有大量日志 | 迁到Groovy,关掉日志,启用编译缓存 |
| 变量值在下一个请求里取不到 | 变量作用域或写入时机有问题 | 确认用vars.put写入、HTTP请求里用${}引用、执行顺序正确 |
| 脚本里new的Java对象报ClassNotFound | 依赖的jar没放进JMeter的lib目录 | 把jar放到lib/ext下,重启JMeter |
java.io.IOException: error writing to server | 服务端提前关闭连接或连接池耗尽 | 检查Keep-Alive设置、超时参数、服务端并发连接数限制 |
| 同一个CSV文件每个线程分块取值失败 | 对CSV Data Set Config的共享模式理解不透 | 设置“共享模式=当前线程”,或者直接用Groovy按线程号读取 |
| 响应结果和预期不符但请求显示成功 | 只做了HTTP状态码断言,没做业务断言 | 在JSR223断言里通过prev.setSuccessful控制状态 |
5.2 热词中那个IOException到底怎么排查
搜索热词里出现了“jmeter java.io.ioexception: error writing to server”,这个报错在压测中非常常见,它的本质是JMeter客户端往服务器写请求时,TCP连接已经被服务端关闭了。
实际排查时我一般按这个顺序来:
- 先看服务端日志,确认是不是服务端主动断开了连接,比如网关超时时间设置过短、后端线程池队列满了直接拒绝。
- 查看JMeter的Keep-Alive设置,如果HTTP请求取样器里勾选了Keep-Alive,而服务端很快关闭了空闲连接,就会在下次请求时出现这个IOException。
- 检查压测机的文件句柄数和端口资源,高并发时会因为本地端口耗尽出现无法建立连接,报错信息通常也是write error。
解决方案一般是在HTTP取样器里关闭Keep-Alive(或者在HTTP头管理器里加Connection: close),同时调大操作系统的临时端口范围。如果服务端能承受,保持长连接当然性能更好,但要用HTTPClient4的实现方式,并对连接做健康检查。
5.3 脚本性能优化的几个实际经验
脚本写多了之后,你会发现除了语言选型,脚本本身的写法对性能影响也不小。分享几条我总结的经验。
第一,不要在脚本里频繁创建大对象。比如每次请求都new一个JsonSlurper再解析一个几KB的响应,积少成多会造成明显的GC压力。如果脚本是放在循环里执行的,尽量把可以复用的对象提升到脚本外层。
第二,谨慎使用正则表达式处理响应报文。正则的编译和回溯开销很大,能用JSONPath就用JsonSlurper,能用indexOf就用indexOf。千万不要为了省事写一个几百字符的正则去截取一大段HTML。
第三,日志级别要管控。压测时log.info输出几十万条日志,压力机磁盘IO和CPU都会受影响。日志留着在调试阶段用,正式压测要么注释掉,要么把日志级别调整为ERROR。
第四,尽量用vars.put而不是字符串拼接去赋值。虽然vars本身也有开销,但比频繁创建新的字符串对象开销低得多。在循环十万次的场景下,这种细节差异会明显放大。
5.4 从“脚本能用”到“脚本好用”的三层进阶
最后说一个我自己的切身体会。刚接触JSR223时,只要能跑通就觉得万事大吉,后来慢慢意识到脚本质量分三层:
第一层是能用。脚本能生成数据、能拼报文、能取响应值,接口跑起来绿油油一片。这一层满足日常功能测试和简单压测是够的。
第二层是好维护。脚本里不堆魔法数字,公共的加密方法、加签规则抽取成函数,关键逻辑有注释,变量命名规范。这个阶段最大的收益是换人了也能接得住脚本,不用每次都要靠原作者来解释。
第三层是高性能。脚本充分考虑在高并发下的开销,能用缓存就用缓存,能少创建对象就少创建对象,日志输出严格管控,断言逻辑高效。这一层决定的是压测结果的可信度,以及压力机能不能支撑足够大的并发量。
说实话,当初我在一个项目里连续优化了一周脚本性能,把压测机的最大支撑线程数从3000提到了8000以上,服务端的性能瓶颈这才真正暴露出来。如果脚本本身在吃性能,你测出来的就不是服务器的极限,而是压力机自己的极限。
最后再分享一个小技巧:JSR223取样器配合JSR223断言和JSR223前置处理器,可以组合出一套完整的“脚本链”。前置处理器负责生成参数,取样器负责发起请求,断言负责业务校验,层层解耦,比把所有逻辑塞进一个取样器里清晰得多。我在实际项目中基本都采用这个结构,维护成本很低,推荐你也试试。