ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JMeter JSR223取样器核心解析:原理、实战与性能优化

JMeter JSR223取样器核心解析:原理、实战与性能优化 做性能测试这些年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类库比如自己写的工具类、第三方SDKJSR223脚本里可以直接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每个都不要忽略变量名类型作用logLogger打印日志建议用log.info/warn/errorvarsJMeterVariables操作JMeter变量get/put线程内可见propsJMeterProperties操作JMeter属性全局可见可跨线程ctxJMeterContext当前线程上下文可以获取线程组、线程号等信息samplerSampler当前取样器对象大部分场景不需要直接用prevSampleResult上一个取样器的结果对象可以做断言和结果修正OUTPrintWriter输出到控制台不推荐在压测时用容易刷屏这里有个容易踩的坑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拼接后加盐再做MD5import 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 跨线程共享计数器实现真正的限流场景模拟有时候压测场景需要模拟不同线程间的互斥逻辑比如一个活动库存总量只有1000100个线程去抢每个线程抢到后库存减一总量不能超。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的输出通常是一串形如[userId10001, userId10002]的数据要转成SQL的IN子句或者JSON数组用正则提取会一团乱但Groovy几行就解决// 假设JDBC Request把结果存到了变量userIdList里 def raw vars.get(userIdList) // 把形如[userId10001, userId10002]的字符串中的数字全部提取出来 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里常用的三种引擎拉出来对比一下对比维度BeanShellGroovyJavaScript (Nashorn)执行方式解释执行编译为字节码后执行解释执行JMeter编译缓存无支持有限支持高并发性能较差优秀一般Java类库支持一般非常完善一般语法友好度靠Java但API老旧简洁支持闭包和GPath熟悉前端的人容易上手社区推荐度老版本遗留官方强烈推荐已逐渐被Java模块移除JMeter从3.1版本开始官方文档就明确建议使用Groovy语言来编写JSR223脚本理由就是性能和缓存机制。BeanShell在高并发下的性能开销太明显而且JMeter官方对BeanShell的支持长期处于“基本不再维护”的状态。4.2 为什么Groovy是压测脚本的主流选择Groovy的优势在于它和Java无缝兼容你可以直接import任何Java类库同时它的语法比Java更简洁支持字符串模板、闭包、集合的各种链式操作写出来的脚本短小易读。举个例子同样是从响应报文里提取tokenJava风格写10行还要考虑空指针。Groovy一行搞定def token (resp ~ /token:([^])/)[0][1]更重要的是Groovy的编译缓存机制。JMeter会检测脚本内容有没有变化没变化的话第二次循环开始直接执行缓存的字节码这个差距在百万级请求场景下非常明显。4.3 “Cache compiled script if available”为什么必须开启这个选项默认是勾选的但有次我发现有人把它取消了原因是修改脚本后JMeter没生效以为缓存导致不更新。其实这是一个误区JMeter在脚本保存后会自动校验脚本内容是否改变变了就会重新编译。取消缓存等于放弃性能优势得不偿失。如果你真的遇到改了脚本没生效的情况排查方向应该是确认当前线程组是不是真的执行到了这个取样器。确认有没有勾选了“仅一次控制器”或“循环次数”设置导致脚本没重新进入。用ctrlF5强制清空缓存重跑而不是直接取消缓存编译选项。5. 高频报错与性能优化实录5.1 常见问题和解决对照表这里整理了一些我在实际脚本开发中遇到的高频问题很多都是从网上被问爆的热搜词里提炼出来的。问题现象原因分析解决思路脚本报错Script compilation failedGroovy语法错误或引用的类不存在把脚本粘贴到IDEA里先跑一遍确认语法正确日志里中文乱码JMeter编码设置不对在jmeter.properties里设置sampleresult.default.encodingUTF-8高并发下CPU飙高使用了BeanShell或脚本里有大量日志迁到Groovy关掉日志启用编译缓存变量值在下一个请求里取不到变量作用域或写入时机有问题确认用vars.put写入、HTTP请求里用${}引用、执行顺序正确脚本里new的Java对象报ClassNotFound依赖的jar没放进JMeter的lib目录把jar放到lib/ext下重启JMeterjava.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前置处理器可以组合出一套完整的“脚本链”。前置处理器负责生成参数取样器负责发起请求断言负责业务校验层层解耦比把所有逻辑塞进一个取样器里清晰得多。我在实际项目中基本都采用这个结构维护成本很低推荐你也试试。
返回列表