1. 项目概述与问题根源最近在帮团队做接口自动化脚本的性能优化发现一个挺普遍但容易被忽略的问题在JMeter脚本里我们经常用${__UUID}这个内置函数来生成唯一的请求标识。但很多脚本的设计是把这个函数直接放在HTTP请求的“路径”或者“参数”里。比如一个查询用户详情的接口路径可能是/api/user/${__UUID}。这看起来没问题对吧但当你设置线程组循环多次或者在一个事务控制器里包含多个采样器时问题就来了这个${__UUID}会在每次被引用时都重新生成一次。我遇到的实际案例是一个“创建订单并支付”的业务流在一个事务控制器下有创建订单、查询订单、发起支付三个HTTP请求每个请求都需要在Header里带同一个“流水号”。脚本里图省事在每个请求的Header管理器里都写了${__UUID}。用View Results Tree监听器一看好家伙三个请求的流水号全不一样业务逻辑直接断链测试根本跑不通。这时候很多人的第一反应就是“上BeanShell” 写个BeanShell PreProcessor把生成的UUID存到一个变量里后面所有请求都引用这个变量。这方法确实能解决问题但代价是巨大的性能开销。BeanShell是解释执行的在高压并发下它会成为性能瓶颈严重拉低TPS让你的压测结果失真。所以我们面临的核心需求是如何在不使用BeanShell、JSR223等脚本元件的前提下确保一个UUID或其他需要动态生成的值在JMeter的一次迭代Iteration中只生成一次并且能在该迭代内的所有采样器中共享使用这不仅是保证业务正确性的需要更是做高保真性能测试的基本功。下面我就把几种经过实战检验的优化方案拆开揉碎了讲清楚。2. 核心思路理解JMeter的变量作用域与执行顺序要解决问题得先理解JMeter的“运行逻辑”。很多人把JMeter脚本看成从上到下顺序执行的一行行代码这其实不准确。JMeter元件是有层次结构和执行顺序的。2.1 一次迭代的生命周期当你启动一个线程它会执行线程组里定义的所有内容。如果设置了循环次数那么每执行一遍线程组内的完整内容就是一次“迭代”。在一次迭代中元件的执行顺序大致遵循配置元件如CSV Data Set Config前置处理器Pre Processors定时器Timers采样器Sampler后置处理器Post Processors断言Assertions监听器Listeners2.2 变量的生成与固化时机关键点来了${__UUID}这样的函数是在它被“解析”的时刻执行的。如果你在三个不同的采样器的参数里写了三个${__UUID}那么JMeter在构建这三个请求时会分别解析三次生成三个不同的值。我们的目标就是要把这个“生成”的动作提前到一次迭代中最早可能的位置并且只做一次然后把结果“固化”到一个变量里。2.3 为什么避免BeanShellBeanShell PreProcessor虽然可以放在事务控制器开头来生成变量但它本质是一个脚本解释器。在每秒数千上万的并发请求下解释执行脚本的CPU开销是不可忽视的。我们的性能测试脚本本身应该尽可能“轻”避免引入不必要的计算这样才能把压力真正打到被测系统上而不是消耗在JMeter自身的脚本解析上。因此寻找JMeter原生、轻量级的解决方案是优化的正确方向。3. 方案一利用“用户参数”预处理器生成静态UUID这是最直观、配置最简单的方法。User Parameters预处理器通常用于为不同的用户线程设置不同的参数但它有一个非常有用的特性你可以勾选“每次迭代更新一次”。3.1 具体操作步骤在需要共享UUID的采样器、事务控制器或线程组的上一级添加一个User Parameters预处理器。比如如果几个请求都在一个“事务控制器”下就把User Parameters加在这个事务控制器下面作为其子元件。在User Parameters的表格中添加一行。名称填你想要的变量名例如myTransactionId。用户_1的值一栏填入JMeter函数${__UUID}。最关键的一步务必勾选底部的Update Once Per Iteration选项。3.2 原理与效果这样配置后JMeter会在每次迭代开始执行到这个User Parameters元件时计算一次${__UUID}函数并将结果赋值给变量myTransactionId。在这次迭代后续的所有采样器中你都可以使用${myTransactionId}来引用这个已经固定下来的值。无论你在多少个请求里引用它值都不会变。3.3 实操心得与避坑指南注意User Parameters的位置至关重要。JMeter的执行是深度优先的。如果你把它放在“线程组”一级那么每次迭代开始都会执行。但如果你的业务流被分成多个“事务控制器”或“逻辑控制器”你需要确保User Parameters在结构上覆盖所有需要共享该变量的采样器。通常放在线程组内、第一个业务请求之前是最稳妥的。一个常见坑不要把它放在“HTTP请求默认值”这类配置元件里面或后面。配置元件虽然最先执行但User Parameters作为预处理器在它所属的层级里其执行顺序依然晚于同级的配置元件。为了确保万无一失可以把它放在线程组下的第一个位置。3.4 方案局限性这个方案美中不足的是User Parameters界面上的值是基于“用户”线程来分列的。虽然我们只用一个用户列也能工作但在管理大量不同参数时界面会显得有点冗余。它更适合参数数量不多、且明确需要按迭代更新的场景。4. 方案二巧用“仅一次控制器”与“用户定义的变量”如果你希望这个UUID不仅在单次迭代内唯一甚至希望在整个线程的生命周期所有迭代中只生成一次或者你的逻辑更复杂那么可以组合使用Once Only Controller和User Defined Variables。4.1 架构设计思路仅一次控制器里的元件在一个线程的整个生命周期内只会执行一次。我们可以把生成UUID的动作放在这里面。但是仅一次控制器内部通常放采样器如登录请求。我们不能直接把${__UUID}函数赋值给一个变量。这时需要借助一个“跳板”。4.2 详细配置流程在线程组开头添加一个仅一次控制器。在仅一次控制器内添加一个用户定义的变量配置元件。这个元件通常用于定义静态变量但我们可以“欺骗”它一下。在用户定义的变量中创建一个变量例如globalUUID但先不填值或者填一个占位符。在仅一次控制器内用户定义的变量之后添加一个Debug Sampler和一个JSR223 PostProcessor作为Debug Sampler的后置处理器。虽然我们说要避免脚本但这里仅执行一次对整体性能影响微乎其微。在JSR223 PostProcessor中语言选择groovy性能远优于BeanShell写入脚本vars.put(globalUUID, UUID.randomUUID().toString());。这样就将生成的UUID存入了globalUUID变量。在线程组后续的所有迭代中你都可以直接使用${globalUUID}。4.3 方案解析与变通这个方案的核心理念是利用“仅一次控制器”确保生成动作只发生一次利用后置处理器将动态值赋给一个变量。虽然用到了JSR223但因为只执行一次其性能损耗在压测中可忽略不计是一种合理的折中。如果你连这一次的脚本都不想用还有一个“纯配置”的变通方法在仅一次控制器里放一个极其简单的HTTP请求比如访问一个不存在的本地端口或一个无害的静态页面然后使用正则表达式提取器或边界提取器从这个请求的响应中提取一个UUID。你可以事先准备好一个包含UUID的静态响应文件用HTTP请求访问file://协议本地文件来获取。但这方法更绕维护成本高不如直接用一次Groovy脚本来得清晰高效。5. 方案三通过“随机变量”配置元件实现这是一个非常巧妙且完全原生、无脚本的方案利用了Random Variable配置元件的一个隐藏特性。5.1 配置方法在线程组内添加一个Random Variable配置元件。进行如下配置变量名称例如fixedUUID。输出格式留空。这个格式是针对数字随机的对UUID无效我们必须留空才能使用函数。最小值、最大值、随机种子这些也都留空或填0。Per Thread (User)这个属性是关键必须勾选Per Thread (User)。在Random Variable元件的值这一栏填入${__UUID}函数。5.2 原理解析Random Variable元件的设计初衷是生成随机数。但它有一个核心逻辑当勾选“Per Thread (User)”时它会在每个线程初始化时计算一次“值”然后在整个线程运行期间保持不变。这个“值”可以是一个随机数也可以是我们通过函数动态生成的一个字符串比如UUID。JMeter会在初始化阶段解析${__UUID}函数得到结果然后将这个结果作为该变量的“固定”值供线程后续所有迭代使用。5.3 效果验证与对比你可以添加一个Debug Sampler和View Results Tree来验证。设置线程循环5次你会看到在第一次迭代中fixedUUID变量被赋予了一个UUID值在后续的第2到第5次迭代中这个值始终保持不变。这与User Parameters每次迭代更新的行为是不同的。Random Variable方案是“每线程固定”而User Parameters勾选每次迭代更新是“每迭代固定”。5.4 适用场景与选择需要“每线程一个固定UUID”例如模拟一个用户会话在整个压测过程中用一个固定的ID标识这个用户。选择Random Variable方案。需要“每次迭代一个新UUID”例如模拟用户每次操作都生成一个新的订单号。选择User Parameters勾选每次迭代更新方案。6. 方案四基于“循环控制器”与变量引用的高级模式对于更复杂的场景比如一个迭代内包含多个逻辑分支通过If Controller控制且每个分支都需要同一个UUID我们需要更精细的控制。这时可以结合Counter配置元件和Test Plan级别的用户定义变量。6.1 设计一个“UUID生成器”逻辑段在线程组的最开始放置一个If Controller。条件可以设为${__jexl3(${myUUID} || ${__jm__My UUID Generator__idx} 0)}。这个条件的意思是如果myUUID变量为空或者名为“My UUID Generator”的循环控制器是第一次执行则进入此控制器。在这个If Controller内放置一个Loop Controller命名为“My UUID Generator”循环次数设为1。在这个Loop Controller内添加一个User Parameters预处理器定义变量myUUID的值为${__UUID}并勾选“每次迭代更新一次”。由于父级Loop Controller只循环一次所以这个UUID在此逻辑段内生成一次。这个If Controller后面跟着你主要的业务逻辑采样器。6.2 实现原理这个模式创建了一个“懒加载”的UUID生成器。第一次迭代时myUUID为空条件成立进入生成器生成UUID。后续迭代中myUUID已有值条件不成立跳过生成器直接使用已有的变量值。Loop Controller循环次数为1确保了生成动作只发生一次。__jm__My UUID Generator__idx是JMeter为循环控制器自动生成的索引变量第一次循环时为0。6.3 复杂场景下的优势这种模式的优势在于灵活。你可以通过调整If Controller的条件来控制UUID在何时被重置和重新生成。例如你可以把它和Counter结合实现每执行N次业务迭代才更换一次UUID。这对于模拟“用户会话超时后重新登录获得新Token”之类的场景非常有用。7. 性能对比与方案选型建议为了给你直观的参考我将上述几种方案在一个简单的压测场景100线程100次循环下进行了对比主要关注TPS和资源消耗。方案描述优点缺点适用场景预估性能损耗BeanShell在BeanShell PreProcessor中生成并存储变量功能强大逻辑灵活性能极差解释执行开销大是性能瓶颈不推荐用于压测仅用于调试或极低并发功能测试高User Parameters预处理器勾选“每次迭代更新”配置简单原生支持性能最优界面针对多用户设计管理多参数稍显繁琐最常用。需要每次迭代使用新UUID的业务流如每次迭代创建新订单可忽略不计Random Variable配置元件勾选“Per Thread”值填${__UUID}原生支持性能最优实现“每线程固定值”理解其“每线程固定”的特性需要一定经验需要在整个线程生命周期固定UUID的场景如模拟固定用户会话可忽略不计Once Only UDV组合仅一次控制器与用户定义变量实现“全局唯一”或“每线程唯一”需要借助一次脚本JSR223配置稍复杂需要在整个测试计划中生成一个唯一标识的场景极低仅一次执行Loop If 模式通过逻辑控制器组合实现条件生成控制逻辑最灵活可实现复杂规则配置最复杂可读性降低需要根据复杂条件决定是否生成新UUID的高级场景低选型决策树问你的UUID需要每次迭代都变吗是- 选择方案一User Parameters勾选每次迭代更新。否- 进入第2步。问你希望一个线程虚拟用户在整个运行过程中都使用同一个UUID吗是- 选择方案三Random Variable勾选Per Thread。否- 进入第3步。问你需要生成一个全局唯一的ID或者生成逻辑非常复杂吗是- 选择方案二Once Only Controller组合或方案四高级逻辑控制器模式。否- 回顾需求通常前两种方案已覆盖绝大多数场景。8. 常见问题排查与实战技巧在实际操作中你可能会遇到一些“诡异”的情况这里分享几个排查技巧。8.1 变量值为空或未更新症状在后续采样器中引用${myUUID}发现值是空的或者还是${__UUID}这个字符串本身。排查使用Debug Sampler和View Results Tree。在Debug Sampler中勾选 “JMeter Properties” 和 “JMeter Variables”运行后查看响应数据确认你的变量是否被正确创建和赋值。检查变量作用域。JMeter变量是线程局部的且默认在当前元件及其子元件中有效。确保你引用变量的位置在生成变量的元件的下级或同级且执行顺序在后。跨线程组是无法直接引用变量的。检查函数语法。在User Parameters或Random Variable的值字段中函数写法必须是${__UUID}注意是双花括号。单花括号{__UUID}不会被解析。8.2 性能测试中UUID生成成为瓶颈症状压测时TPS上不去使用PerfMon或JMeter自身监听器发现JMeter客户端CPU很高。排查首先彻底放弃BeanShell。用JSR223并选择groovy语言性能有数量级提升。其次评估是否真的需要如此高的UUID唯一性。对于某些测试使用${__threadNum}线程号结合${__iterationNum}迭代次数或${__time(,)}时间戳来构造一个唯一标识其性能开销远低于UUID生成算法。使用方案三Random Variable或方案一User Parameters它们是JMeter原生、编译后的函数实现性能最高。8.3 在分布式压测中保持唯一性问题使用多台JMeter从机进行分布式压测时如何保证生成的UUID全局唯一解决方案单纯靠JMeter函数很难保证。一个可靠的实践是在构造UUID时融入从机IP和线程ID。例如可以使用JSR223 PreProcessorGroovyimport java.net.InetAddress String hostname InetAddress.getLocalHost().getHostAddress() String threadId ctx.getThreadNum() as String String uniqueId hostname - threadId - UUID.randomUUID().toString() vars.put(distributedUUID, uniqueId)这样生成的ID包含了主机和线程信息能有效避免多机冲突。虽然用到了脚本但考虑到分布式压测本身规模大这一点点脚本开销是可接受的换取的是数据准确性的保证。8.4 将优化后的脚本模块化当你找到最优方案后建议将其模块化。可以将生成UUID的User Parameters或Random Variable配置元件放在一个Test Fragment测试片段中或者使用Module Controller来引用。这样在多个测试计划中都可以复用这个标准的UUID生成模块保证团队脚本风格和性能的一致性。最后我想强调的是性能测试脚本的优化往往就藏在这些细节里。一个不当的BeanShell脚本可能让测试结果失去参考价值。掌握JMeter原生的、高效的控制变量方法不仅能解决业务逻辑正确性问题更是迈向专业性能测试工程师的重要一步。从我个人的经验来看方案一User Parameters和方案三Random Variable足以应对95%以上的需求它们简单、高效、稳定是你应该优先掌握和使用的利器。下次在脚本里看到${__UUID}不妨先停下来想一想它真的需要在每个地方都重新生成吗