做性能测试这几年,Jmeter 脚本里最让我头疼的不是写断言、不是配并发,而是那种"在 BeanShell 里跑得好好的,一切到 JSR223 Groovy 就给我整花活"的字符串替换。你以为是语法问题,其实是引擎的底层逻辑在打架。这篇把我在切换过程中踩过的坑、排查的思路、以及最终的稳定写法一次性说清楚,给同样在 Jmeter 里和脚本死磕的朋友做个参考。
1. 先搞懂底层差异:BeanShell 和 JSR223 并不是"换个马甲"的关系
很多新手以为 BeanShell 和 JSR223 的区别只是"JSR223 性能好一点",其实这是天大的误解。这俩一个是解释器,一个是框架,底层逻辑完全不一样。
1.1 BeanShell 的本质:一个迷你 Java 解释器
BeanShell 是 Jmeter 早期内置的脚本引擎,语法上非常贴近 Java,但它是"解释执行"的,也就是说,你写一行,它现场解析一行。它的优点是没有编译期,改起来方便,缺点也显而易见:性能极差,尤其是循环次数多的时候,BeanShell 会严重拖慢整个压测的吞吐量,而且它不会缓存编译结果,每次迭代都要重新解析一遍脚本。我实测过,同样一个字符串处理逻辑放在 BeanShell 里跑,TPS 能掉整整一个量级。
更麻烦的是,BeanShell 对 Java 新特性支持得很差。什么 Lambda、Stream API、String.join 这些在 Java 8 之后的语法,BeanShell 基本全都不认识。我早期写过一段用String.join拼接参数的脚本,在 BeanShell 里直接抛解析错误,最后只能老老实实改成StringBuilder。
1.2 JSR223 的真正含义:一个规范,而不是一种语言
JSR223 是 Java 的脚本规范,它定义的是"JVM 上如何统一地加载和执行第三方脚本引擎",所以 JSR223 Sampler 本身不是语言,Groovy、JavaScript、Python 都可以塞进去跑。Jmeter 官方文档也好,社区大佬们的经验也好,一致推荐的是 JSR223 + Groovy。
Groovy 和 BeanShell 最大的区别在于:Groovy 是编译执行的,而且 Jmeter 支持对 JSR223 脚本做缓存编译(就是那个 "Cache compiled script if available" 的选项)。勾选之后,Groovy 脚本只编译一次,后续迭代直接跑编译后的字节码,性能上比 BeanShell 快了几十倍不止。这也是官方推荐 JSR223 的根本原因——不是因为 JSR223 本身快,而是因为它能配合 Groovy 做缓存。
所以结论说清楚:你在 BeanShell 里写的代码,复制到 JSR223 Groovy 里,大概率跑得起来,但行为不一定一致。字符串替换这个操作,恰恰就是最容易暴露这种不一致的地方。
2. 字符串替换最坑的三个点:${}、$1、反斜杠
说句实话,字符串替换在 Java 里根本不算难题,但在 Jmeter 的脚本环境里,它就是能给你整出三种完全不同的解析方式。我来把这几个坑一个个拆开讲,每个都是我实际撞过的。
2.1 最经典的坑:${}被 Jmeter 提前篡改
这个问题我愿称之为"Jmeter 脚本第一大坑",没有之一。在 BeanShell 和 JSR223 脚本里,只要你写了${变量名},Jmeter 会在脚本执行之前,先做一次自己的变量替换。这听起来好像是方便了你,但如果你恰好要在正则替换里写$相关的模式,或者你的字符串里面天然包含${...}这种结构,那问题就来了。
举个例子,我在做某个接口的压测时,需要把请求体里的占位符${orderId}替换成实际的订单号。当时在 BeanShell 里写的代码:
String body = "${orderId}_abc_${userId}"; String result = body.replace("${orderId}", "123456");表面上看没毛病,但 Jmeter 在执行脚本前,会先把${orderId}替换成当前线程的变量值,这个替换发生在字符串常量定义之前。如果 orderId 变量本身是空或者值为空字符串,你的body变量拿到手可能就不是你想的那串字面量了。更隐蔽的是,如果你在正则表达式里写了$符号,比如replaceAll("\\$\\{orderId\\}", ...),那 Jmeter 的预处理会把整个正则表达式里面的${...}也都替换掉,导致你的正则直接废掉。
实测下来的结论就是:在 BeanShell 和 JSR223 脚本里,尽量避免直接写${变量}来接参和做字符串拼接。与其依赖 Jmeter 的预处理,不如老老实实用vars.get("orderId")从上下文中拿变量,然后手动拼接:
String orderId = vars.get("orderId"); String body = orderId + "_abc_" + userId;这样写,不但避开了预处理替换的坑,还能保证脚本在 BeanShell 和 Groovy 里行为一致。这个地的坑,想不踩都难。
2.2$1在 Groovy 里触发 GString 插值
这个坑是我在从 BeanShell 切到 Groovy 后遇到的最诡异的 bug,没有之一。Groovy 里定义字符串有两种方式:单引号字符串不会做插值,双引号字符串是 GString,会对$开头的表达式做插值。也就是说,当你在 Groovy 的replaceAll里写:
result = body.replaceAll("(\\d{4})-(\\d{2})", "$1年$2月");这段代码在 BeanShell 里跑,结果完全正确,$1 和 $2 会被正则引擎解析为捕获组。可到了 Groovy 里,同样的代码,$1会被 Groovy 当成 GString 的占位符,尝试去解析一个名字叫1的变量,然后直接报错,或者更阴险的是——当$1这种写法在 GString 里被当作字面量处理时(取决于上下文),返回的结果根本不是你要的。
解决方式有几种。第一种是用单引号包整个替换字符串:
result = body.replaceAll("(\\d{4})-(\\d{2})", '$1年$2月')单引号不参与 GString 插值,$1能原样传到正则替换逻辑里。第二种是用Matcher.quoteReplacement()把替换串预处理一下,这是最保险的做法,也是我后来固定下来的方案:
result = body.replaceAll("(\\d{4})-(\\d{2})", Matcher.quoteReplacement("\$1年\$2月"))第三种是用 Groovy 的闭包重载版本:
result = body.replaceAll("(\\d{4})-(\\d{2})") { it[1] + "年" + it[2] + "月" }闭包版本完全绕开了$符号的问题,因为它在匹配后回调,直接拿it(就是匹配结果)来构造替换内容。这三种方式在 Groovy 里都能用,我建议你对$敏感字段多的业务场景,直接上闭包版,干净利落。
2.3 正则反斜杠的转义地狱
Java 字符串里,反斜杠本身就是转义符。写正则的时候,\d在正则语义里是数字,但在 Java 字符串里必须写成\\d。到了 Groovy 里,因为这个字符串可以被 GString 再处理一层,转义次数可能还会翻倍。我在 BeanShell 里写习惯了一层的转义,切到 Groovy 时经常发现表达式不生效,不报错,就是替换结果不对。
比如说,要替换字符串里的连续数字,BeanShell 里写:
result = body.replaceAll("\\d+", "N");这行在 Groovy 里其实也能跑,因为正则引擎收到的模式是一样的,都是\d+。但如果你在 Groovy 里写的是:
result = body.replaceAll("\d+", "N")Groovy 会先把\d解成d(因为 Groovy 字符串里\d不是合法转义,不同版本的 Groovy 对非法转义的处理还不一样),正则匹配到的就不是数字,而是"d"字符本身。更迷惑的是旧版 Groovy 不报错,新版 Groovy 直接抛编译异常,同样的代码在不同 Jmeter 版本上表现完全不一样,排查成本极高。
我的经验是:在 Groovy 里做任何带正则的替换,替换模式的字符串一律用斜杠字符串(slashy string)。Groovy 原生支持/正则/这样的写法,斜杠字符串不需要转义反斜杠,\d直接写\d:
result = body.replaceAll(/\d+/, "N")这个写法能让你从反斜杠地狱里彻底解放出来。如果你的 Groovy 版本不支持斜杠字符串(一般也不会,Groovy 2.x 都支持),那就老老实实数清楚层数,Java 一层、Groovy 插值一层、正则一层,三层转义自己去算。
3. 脚本切换必踩的作用域与类型坑
字符串替换的坑不是孤立的,它和脚本里怎么取变量、怎么传参强相关。从 BeanShell 切到 JSR223 以后,我明显感觉到"取数"的方式和"类型"的感知完全变了,这两个地方的坑比正则还频繁。
3.1 vars.get() 拿到的到底是什么
BeanShell 里拿 Jmeter 变量,最常见的就是vars.get("xxx"),这个方法返回的是一个String类型的对象。问题来了,很多人在 BeanShell 里会这样写:
String price = vars.get("price"); if (price > 100) { // 判断价格 }这段在 BeanShell 里其实不会报错,因为 BeanShell 的自动类型转换比较宽松,String 会被强转成数值做比较。但到了 Groovy 里,同样的代码直接抛异常,因为 Groovy 的类型系统虽然动态,但对 String 和 Integer 的比较是敏感的,它不会自动把"100"和100画等号,你需要在拿变量之后,用.toInteger()或者.toBigDecimal()完成显式转换。
更隐蔽的是,vars.get()返回的是 String,而你在 Groovy 里定义的字符串变量可能是一个 GString。当你做替换和比较时,GString 和 String 的equals()行为在大部分时候是一致的,但一旦牵扯到格式化、拼接、正则替换,GString 的惰性求值就会掺一脚。我在一次脚本里用 GString 拼接了请求体,然后用replaceAll去替换其中的占位符,结果因为 GString 的字符串池结构问题,替换结果总是带着一股子"没完全替换干净"的味道,后来把拼接全部改成返回 String 类型的方法才消停。
所以,在 JSR223 Groovy 脚本里,我给自己定了几条规矩:从vars.get()拿到的东西,严格按照 String 类型对待;要做数值比较,就显式转成 int、long、double;要拼请求体,用StringBuilder或者String.format来构造,不依赖 GString 的隐式字符串化。这几条做到了,类型相关的坑基本能避开九成。
3.2 缓存编译机制带来的"变量不更新"问题
这个坑是切到 JSR223 之后才有的,BeanShell 时代根本没有这个概念。JSR223 Sampler 里有个选项叫 "Cache compiled script if available",默认是勾选的,加上之后,Groovy 脚本在第一次执行的时候被编译成字节码,后面所有线程和迭代都复用这份字节码。性能是上来了,但脚本里如果写了:
String userName = "${userName}";这行代码会在脚本编译阶段就被 Jmeter 替换成具体的值,而且只替换一次。后续迭代里 userName 变量即使被别的线程组更新了,这个脚本里拿到的还是第一次编译时的那份值。这导致一种特别诡异的故障:单线程跑的时候结果全对,一上多线程并发,大量请求用的都是同一个用户名。
我以前在 BeanShell 里写惯了${userName}这种预处理引用,切到 JSR223 后第一次遇到这个问题,排查了整整一天。后来我把脚本里所有的${...}引用全部改成vars.get("userName"),问题立刻消失。因为vars.get()是运行期调用,它每次迭代都会去 Jmeter 上下文中实时取值,不受编译缓存影响。
再补充一个性能层面的细节:在 Groovy 脚本里用vars.get()去拿变量,官方推荐的用法是在脚本最前面把常用变量抽取成局部变量,比如:
def userName = vars.get("userName") def orderId = vars.get("orderId") as String这样每次迭代虽然还是会通话上下文的 get 方法,但值已经在局部缓存,后续的字符串处理和替换不会再反复触发上下文查找,性能和可读性都会好很多。
3.3 String 和 GString 的"等值不同性"导致断言失败
有一次我在调试一个断言脚本,逻辑很简单,对响应体里的某个字符串做替换,然后和期望值做比较:
if (response.replaceAll("\\s+", "") == expectedValue) { AssertionResult.setFailure(false); }BeanShell 里跑,断言能过。切到 Groovy 里,同样的逻辑,断言直接失败。我把两边打出来的结果用log.info一输出,肉眼看上去一模一样,但equals()就是返回 false。后来我用each遍历比较字符,才发现问题出在 GString 上——replaceAll的返回值是 String 没错,但expectedValue在 Groovy 里如果是从另一个 GString 拼接过来的,它的equals()和 String 的equals()在底层实现上有细微差别,尤其在字符串内部有不可见字符或者转义序列的时候,肉眼根本分辨不出来。
解决方式很简单,把参与比较的 GString 强制.toString()转成 String,再去做equals。我在脚本里统一要求:所有需要参与比较的变量,必须经过.toString()显式转换。这招看着笨,但在 JSR223 Groovy 环境里,能省去大量无意义的排查时间。
4. 实操:从 BeanShell 切到 Groovy 的正确改造姿势
理论说了一堆,还是要落到手上的活儿。我把之前线上压测脚本从 BeanShell 迁移到 JSR223 Groovy 的过程整理成了一套标准操作,每一步都有对照,直接照着改就行。
4.1 常量字符串和正则模式的写法对照
先看字符串定义层级的改造。BeanShell 里你可能是这么写的:
String param1 = "{\"orderId\":\"${orderId}\"}"; String pattern = "\\d{4}-\\d{2}"; String replacement = "********";在 BeanShell 阶段,Jmeter 会自动把${orderId}替换成具体值,虽然慢,但结果是对的。到了 JSR223 Groovy,这套写法要改造成:
def orderId = vars.get("orderId") def param1 = '{"orderId":"' + orderId + '"}' def pattern = /\d{4}-\d{2}/ String replacement = '********'这里有几个明确的改动点:变量引用改成vars.get(),字符串拼接用单引号 String 避免 GString 插值干扰,正则模式用斜杠字符串,替换串用单引号包住,防止$被 GString 解析。这三处改完,90% 的字符串替换坑就已经避开了。
4.2 替换逻辑的三种写法对比
在 Groovy 里做带正则的替换,我按推荐程度从高到低给你排一下:
第一种,斜杠字符串正则加单引号替换串:
result = body.replaceAll(/\$\{orderId\}/, '123456')这个写法适合替换模式比较简单的场景,${orderId}在正则里要用\$\{来转义,但因为斜杠字符串不处理反斜杠,所以直接写就行。
第二种,闭包版本,适合替换结果需要二次加工的:
result = body.replaceAll(/orderId=(\d+)/) { all, number -> "orderId=" + (number.toInteger() + 1) }闭包版的优势是$完全不用管,它天然避开了 GString 插值,而且能直接对捕获组做计算。我后来所有需要"替换后还要加点业务逻辑"的场景,全部用这种写法。
第三种,最通用的稳妥写法,用Matcher.quoteReplacement处理替换串:
result = body.replaceAll(regex, Matcher.quoteReplacement(replacement))这个写法适合替换串本身包含$和\的场景。比如替换 URL 里的查询参数,value 是 Base64 加密串,里面全是=和+,直接用replaceAll替换,正则引擎会把$和\当特殊字符处理,轻则结果不对,重则直接抛异常。用quoteReplacement包一层,所有特殊字符都被转义成字面量,稳得一批。
4.3 一个完整迁移实例:响应报文日期格式化
拿一个我实际做过的例子:接口返回的报文字段是createTime=2024-05-18 12:30:45,我需要把它替换成createTime=2024年05月18日然后再塞到下一个请求里。BeanShell 里我写过一版:
String response = prev.getResponseDataAsString(); String newTime = response.replaceAll("(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}:\\d{2}:\\d{2})", "$1年$2月$3日"); vars.put("newResponse", newTime);这版在 BeanShell 里跑的好好的,一到 Groovy 就各种错。我最终的 Groovy 版本长这样:
def response = prev.getResponseDataAsString() def newTime = response.replaceAll(/(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2}:\d{2})/) { whole, y, m, d, time -> y + '年' + m + '月' + d + '日' } vars.put('newResponse', newTime.toString())改动点拆开来看:
- 正则模式用斜杠字符串,反斜杠不用双写。
- 替换逻辑用闭包实现,函数式写法直接操作捕获组。
vars.put之前,闭包返回的是 GString,强制.toString()确保放进上下文的是干净的 String。
这套代码我跑了三轮压测,几百万个请求,没再出过字符串替换相关的错。你要是在做迁移,按这个模板改,基本不会踩大坑。
4.4 循环内替换的写法,注意别反复编译正则
在压测脚本里,循环 + 字符串替换是日常操作。BeanShell 时代因为不缓存,正则引擎反复编译也就那样了,但到了 Groovy,如果你在循环体内写这样:
for (int i = 0; i < 100; i++) { result = body.replaceAll(/(\d{4})/, 'N') }每一次迭代虽然走的是缓存的编译字节码,但replaceAll内部对正则模式的编译是每次都会重新做的,除非你把正则模式预先编译成Pattern对象:
Pattern p = Pattern.compile(/\d{4}/) for (int i = 0; i < 100; i++) { result = p.matcher(body).replaceAll('N') }这么做能在高并发场景下省下一大笔正则编译开销。我在自己的脚本里凡是有循环替换的,一律先Pattern.compile,这也算是一个藏在字符串替换背后的性能优化点。
5. 常见问题速查表与排查思路
踩的坑多了,我把它们按症状和原因整理成了速查表,每次排查直接对照,效率能高很多。
| 症状 | 根本原因 | 解决思路 |
|---|---|---|
脚本里${var}的值是空或旧值 | Jmeter 预替换或编译缓存 | 改用vars.get()运行期取值 |
Groovy 中$1、$2失效或报错 | GString 插值占用$符号 | 替换串用单引号,或使用闭包版本 |
\d、\s正则匹配不到内容 | 反斜杠被字符串层消解 | 用斜杠字符串包裹正则模式 |
| 替换结果和肉眼看到的不一致 | GString 与 String 的 equals 差异 | 参与比较的变量统一.toString() |
| 高并发下所有请求用了同一个值 | JSR223 缓存编译 | 脚本内禁止直接写${},全部vars.get() |
replaceAll抛非法转义异常 | 替换串里的$被正则引擎解析 | 用Matcher.quoteReplacement() |
| 循环内替换导致 CPU 异常高 | 正则被反复编译 | Pattern.compile预编译后使用 |
| 脚本量一大,Groovy 编译超时 | 脚本过于复杂 | 拆分成多个 JSR223 脚本或方法调用 |
这套速查表是我在实际工作中一点点填出来的,基本上每次遇到问题,先看对应行原因,再按"解决思路"那列去改,都能在五分钟内定位问题。
除此之外,我再分享两个排查小技巧:
第一,优先看 JMeter 的日志输出。在 JSR223 Sampler 里加几行log.info把替换前后的字符串打出来,很多问题立刻就暴露了。你看着一样的字符串,日志里打印出来可能就有隐藏字符的差异。
第二,先做最小复现。如果替换结果不对,不要直接在压测脚本里反复琢磨,单独建一个 JSR223 Sampler,写死字符串常量去测,排除 Jmeter 变量作用域的影响。定向修复后再合到主脚本里,效率会翻倍。
最后再分享一个我个人的体会:BeanShell 到 JSR223 的切换,本身不是一件多复杂的事,但假如你像我一样,脚本里存在大量历史遗留的${}引用和正则替换,建议先做全量排查再动手迁移,不要边压测边改,不然线上出问题的时候,你根本分不清是压力没给够、逻辑写错了、还是脚本引擎在作怪。我后来给自己定了个规矩:凡是在 JSR223 里跑的正则替换,一律先看一眼替换串里有没有$和\,有就直接上quoteReplacement,没这些字符也统一用闭包版处理。这套规矩执行下来,字符串替换相关的故障率基本降到了零。