
接口脚本写到一半最容易被卡住的往往不是断言写得不对而是数据传不过去登录接口明明拿到了 token跑到业务线程组里引用却是空的参数化文件里准备好的用户 ID 列表分散在不同线程组里死活读不到一个接口的响应字段下一个线程组要用复制粘贴半天最后发现根本没生效。这些问题的根子八成都在同一个地方——没搞清楚 Jmeter 全局变量到底是怎么存的、怎么取的。这篇就围绕Jmeter 设置全局变量、引用全局变量这件事把变量的作用域、属性Properties的机制、setUp 线程组的搭法、函数读取和脚本读取的取舍、命令行参数注入、分布式压测下的边界以及我自己真实踩过的几个坑从头到尾捋一遍。内容偏向实操默认你已经能跑通一个最简单的 jmx 脚本但如果你刚接触 Jmeter跟着抄也不会有太大障碍关键处我会补上必要的背景。适合做接口测试、性能压测、以及需要多个线程组串联执行的场景。1. 先把变量和属性两套东西分清楚1.1 变量是线程本地的每个线程各有一份副本Jmeter 里的变量底层是JMeterVariables这个对象它挂在线程上下文JMeterContext上而上下文是跟着线程走的。也就是说线程 A 里vars.put(token, abc)线程 B 里vars.get(token)拿到的就是 null。同一个线程组里线程数设成 20那 20 个线程各自持有一份名为 token 的变量互不干扰也互不可见。这里有个特别容易产生误解的设计你在测试计划根节点下挂一个用户定义的变量配置元件看上去位置在最高层像是全局的。实际上它的执行方式是——每个线程启动时各自把这个配置元件读一遍把里面的键值对复制到自己线程的变量表里。所以它本质上是每个线程都有一份初始值相同的副本而不是一份共享数据。你在某个线程里改了它别的线程完全不知道。1.2 属性是 JVM 级别的所有线程共用一份Jmeter 的属性Properties走的是另一条路。它是java.util.Properties的一个实例由JMeterUtils.getJMeterProperties()统一持有整个 JVM 里就一份。启动时从bin/jmeter.properties读进来之后所有线程、所有线程组共享同一份数据。Jmeter 提供了几个函数作为它的语法糖__setProperty负责写__property负责读__P是__property的简写。在脚本里则分别用props.put / props.setProperty和props.getProperty来操作。这才是真正意义上的全局变量。对比项变量Variables属性Properties底层容器JMeterVariablesjava.util.Properties作用域单个线程整个 JVM同一进程内所有线程引用写法${name}${__P(name)}/${__property(name)}脚本操作vars.put / vars.getprops.setProperty / props.getProperty跨线程组可见不可见可见同一 JVM 内跨进程可见不可见不可见分布式场景要注意生命周期线程结束后释放跟随 JVM直到被覆盖或删除1.3 一个最小验证实验为什么 ${token} 在第二个线程组是空的我建议你亲手做一次这个实验比看十篇文章都管用。新建测试计划勾上独立运行每个线程组Run Thread Groups consecutively放两个普通线程组。线程组 A 里放一个 JSR223 Sampler语言选 groovy写两行vars.put(myVar, 我是变量) props.setProperty(myProp, 我是属性)线程组 B 里放两个 Debug Sampler勾选显示 Jmeter 属性Show JMeter Properties再配一个查看结果树。跑完之后你会看到Debug Sampler 的变量列表里没有 myVar但属性列表里能翻到 myProp。这个结果就把两套机制的区别摆在面前了。反过来说如果你在两个线程组里都引用${myProp}想直接取值那也是取不到的——属性必须用__P或__property函数去读${}只认线程变量。这一条我见过太多人栽在上面脚本里写了个${token}属性明明存进去了就是引用不出来最后怀疑人生地去翻日志。2. 用 setUp 线程组把局部数据升格成全局属性2.1 为什么优先选 setUp Thread Group 而不是普通线程组Jmeter 的线程组有三种普通线程组、setUp Thread Group、tearDown Thread Group。setUp 组的执行规则是——一定先于其他所有线程组执行并且要等它里面的所有线程全部跑完其他线程组才会启动。tearDown 组则相反等所有线程组结束后才跑。这个先跑完再放行的特性正是跨线程组共享数据最需要的保证。如果你用一个普通线程组来做初始化就得依赖独立运行每个线程组这个复选框被勾上而且还得保证初始化组的顺序排在最前面。一旦有人手滑取消勾选多个线程组会同时启动初始化可能还没写完业务线程组已经开始读了读到空值不说还会报一堆莫名其妙的错。所以我的习惯很固定凡是先准备数据、后面大家用的逻辑一律放进 setUp 线程组线程数设成 1循环次数 1。这个组天生就是个前置准备的角色语义清晰也不依赖测试计划级别的配置项。注意setUp 线程组的线程数一定要设成 1。设成多个线程的话里面每个线程都会去写一遍同名属性如果写入的值不一样比如每个线程登录的是不同账号最后谁覆盖谁完全随机非常难排查。2.2 提取器 JSR223 写入属性的最小闭环整个链路拆开就三步发起请求拿响应、从响应里提取出需要的值、把这个值写进属性。第一步正常发登录请求。第二步在请求下面挂一个 JSON 提取器或者正则表达式提取器命名比如login_tokenMatch No. 填 1你要的是第一个匹配。这一步拿到的是线程变量属于 setUp 组内部。第三步在提取器下面再挂一个 JSR223 Sampler语言选 groovy脚本缓存勾上写def token vars.get(login_token) if (token null || token.trim().isEmpty()) { log.error(登录 token 提取失败后续依赖 token 的线程组会全部失败请检查登录接口) // 视情况直接失败避免后面几百个线程白跑一轮 // assert false : token 为空 } else { props.setProperty(gv_token, token) log.info(全局属性 gv_token 已写入长度: token.length()) }这个写法里有两个我强烈建议保留的习惯。一是写之前先判空。登录接口挂了、限流了、返回结构变了提取器就是空的此时如果不拦截后面所有线程组都会拿着空 token 去发请求压测跑完看报告全是 401你还得回头猜是哪一步出了问题。二是写log.info或者log.error把关键信息落到 jmeter.log 里。排查问题的时候日志里有没有这一行决定了你是五分钟定位还是半小时。2.3 写入时的三个细节命名前缀、字符串类型、覆盖保护命名前缀。属性名千万别随手起。Jmeter 自身用了大量属性名比如sampleresult.default.encoding、log_level.jmeter、summariser.name这类。你要是手滑设了一个和内置属性同名的键等于在运行时改掉了 Jmeter 自己的行为表现可能是日志级别突然变了、结果文件编码错了、报告汇总异常而且这种问题极难往我设了个属性上面联想。我一般统一用gv_或者项目缩写加下划线做前缀比如gv_token、gv_userId、projA_orderNo。字符串类型。props.setProperty只接受字符串别的类型进去会抛类型错误或者被静默转成字符串。props.put宽松一些能塞对象进去但代价是——一旦涉及跨进程分布式这个对象在另一个 JVM 里根本不存在读出来全是问题。所以我个人在脚本里一律用setProperty需要传复杂结构就自己序列化成 JSON 字符串读的时候再解析回来这样在任何场景下行为都一致。覆盖保护。__setProperty函数有个第三个参数isNotNull。官方文档的说法是当它传 true 时只有在值非空的情况下才会去设置属性相当于给意外把已有有效值清空这种情况加了一道保险。这个参数的具体行为建议你在自己用的版本上实测确认一次写个两行脚本跑一遍就清楚了不要只信文档。另外在脚本里也可以自己实现这层保护def newVal vars.get(login_token) if (newVal ! null !newVal.isEmpty()) { props.setProperty(gv_token, newVal) } else if (props.getProperty(gv_token) null) { log.error(既没有新值也没有旧值可用) }3. 读取端的三种姿势各有各的适用场景3.1 函数式读取${__P(name,default)} 的默认值技巧最简单的方式是在 HTTP 请求的请求头或者参数里直接写${__P(gv_token)}。适合读取量不大、一两个字段的场景改起来直观不用写脚本。但这里有个必须养成的习惯永远给默认值。写成${__P(gv_token,NOT_SET)}。原因是当属性不存在时函数返回的是空字符串于是你的请求头就变成了Authorization: Bearer服务端返回 401你在结果树里看到的是一个语法正确但语义错误的请求很容易误判成token 过期了实际上压根没读到。给了默认值之后请求头会变成Authorization: Bearer NOT_SET一眼就能确认是属性没写进来。同样地用__property写${__property(gv_token,NOT_SET)}效果一样__P只是省几个字符。两个函数在功能上没有本质差别不用纠结用哪个。注意不要在同一个请求里既用${__P(...)}又用${...}去引同一份数据写混了以后排查时很难判断到底是哪个环节没生效。统一一个方式全脚本保持一致。3.2 脚本式读取先落到线程变量再正常引用如果同一个 token 在脚本里被引用几十上百次全写成函数看着乱而且不好统一改。我更喜欢在线程组的开头用一个 JSR223 预处理器把属性读进线程变量def token props.getProperty(gv_token, ) if (token.isEmpty()) { log.error(全局属性 gv_token 未读取到) } vars.put(token, token)之后请求里就统一写${token}清爽很多。这个做法还有一个附带好处如果以后要换成从别的地方取 token比如从外部配置服务拿只需要改这一处脚本所有引用点不动。需要注意的是JSR223 预处理器默认是每个采样器执行前都会跑一次也就是每一次迭代都会读一遍属性。对于同步化的场景差别不大但要追求极致的话可以把这段初始化逻辑放进一个仅一次控制器Once Only Controller里让它在每个线程的第一次迭代时执行一次后面直接复用线程变量。这两种写法我都在生产脚本里用过功能上等价区别只在执行次数。3.3 属性读取的性能开销与高频场景取舍属性查找的本质是一次哈希表查找单次开销极小。但在每秒上万请求的压测场景下每个请求都在做字符串替换、函数解析、哈希查找这些零碎开销累积起来也会体现在机器的 CPU 占用上。我的经验判断是这样如果你的压测目标在几百到几千 TPS函数式读取完全没问题不用过早优化如果目标是万级 TPS 且单机资源吃紧把属性读进线程变量、用最朴素的${token}引用能省掉一部分函数解析开销值得做。但要注意别为了这点优化把脚本改得没人看得懂可维护性在真实项目里的优先级往往更高。另外提醒一句属性值本身不要太大。它是放在 JVM 堆里的全局对象如果往里面塞一个几 MB 的 JSON 字符串每个线程读取时都可能产生一次字符串拷贝堆内存压力和 GC 抖动都会上来。这种大体量数据应该放在文件里用 CSV Data Set Config 或者脚本读文件的方式处理属性只用来传小而关键的东西token、用户 ID、订单号、traceId 这类。4. 命令行与配置文件里的另一种全局参数4.1 -J 和 -G 到底有什么区别除了脚本里动态设置Jmeter 还允许在启动时从外部注入属性这是压测脚本参数化的重要一手。-J是本地注入。写法jmeter -n -t test.jmx -l result.jtl -Jthreads200 -Jduration600这些属性会进到当前这个 JVM 的属性表里脚本中用${__P(threads,10)}就能读到。它的价值在于同一份 jmx不用改一个字符靠命令行就能切成 200 并发还是 2000 并发非常适合放进流水线做自动化也方便压测同学之间交接。-G用在分布式场景作用是把属性下发给远端执行机。简单理解就是这个键值不只本机要所有 worker 也都要。分布式下如果某个参数只有 client 有、worker 没有worker 侧读到的就是默认值很可能出现我在本地跑得好好的一到分布式数据就不对的情况。常用参数对照参数作用范围典型用法备注-J当前 JVM-Jthreads200本地压测参数化首选-G当前 JVM 远端 worker-Genvpre分布式统一下发-q追加属性文件-q my.properties一次性参数不建议写这里-p指定属性文件替代默认-p custom.properties慎用容易漏掉必要配置4.2 属性文件的优先级别把一次性数据塞进去Jmeter 启动时会读bin/jmeter.properties另外还会读bin/user.properties如果存在。命令行传入的-J/-G优先级更高会覆盖文件里的同名项。所以临时参数用命令行、长期稳定的配置用 user.properties是个比较稳妥的分工。我见过不少团队把所有参数都堆进 jmeter.properties 里包括本次压测才用的账号密码、并发数、目标地址。结果换一套环境就要改一次这个文件改完还忘了还原下次别人跑的时候一头雾水。jmeter.properties尽量不要动user.properties只放长期稳定的东西比如默认编码、结果文件格式、通用超时。业务相关的、每次都可能变的值走命令行或者走脚本内的属性设置。4.3 用属性驱动线程数、时长和循环次数把并发规模做成属性是让一份脚本适配多套压测目标的常规做法。测试计划里线程组的线程数直接写${__P(threads,50)}Ramp-up 写${__P(rampup,60)}调度器里的持续时间写${__P(duration,600)}。这样在流水线里就可以按需组合不需要为每一档并发维护一份 jmx。这里有个细节要留意线程数、循环次数这类字段在 GUI 下写表达式是能正常解析的但它们的解析时机是在线程组启动时而不是每次迭代。也就是说指望在压测过程中动态改线程数是做不到的需要更复杂的方案。压测中途要加压力通常是用多台执行机的方式去扩展。5. 分布式压测下的伪全局真相5.1 每个 worker 是独立 JVM属性不会跨进程同步这是我最想强调的一点。你在单机上验证得很爽的那套setUp 写属性、业务组读属性到了分布式环境要重新理解一遍。分布式压测的运作方式是client 把 jmx 分发到各个 worker每个 worker 在自己的 JVM 里独立跑一遍这个测试计划。属性是 JVM 级别的所以 worker A 设的属性worker B 完全看不见client 自己也看不见。如果 setUp 线程组是测试计划的一部分那么每个 worker 都会跑一遍 setUp各自拿到各自的 token各自写进各自的属性表然后在自己的 JVM 里被自己的业务线程读走。这种各跑各的大多数时候是够用的——毕竟请求是各个 worker 发出去的各用各的凭证也合理。唯一的副作用是登录请求会被放大 N 倍N 等于 worker 数量如果你的登录接口有风控或者限流得提前评估一下。5.2 需要全局唯一的数据时属性能帮的忙很有限麻烦的场景在于需要一份全局唯一、大家共用同一份的数据。比如要压测同一张订单被并发提交这个场景所有 worker 必须用同一个订单号或者要做单点登录的凭证服务端同一个账号只会保留最后一个有效 session多个 worker 各自登录会互相把对方踢下线。这两种情况都不是属性机制能解决的因为它不跨进程。可行的路子大致有三条一是用-G在启动阶段把这批数据提前注入让每个 worker 一开始就持有相同的值二是把数据落到一个共享的外部位置比如数据库、共享文件、或者一个简易的配置接口让各 worker 在 setUp 阶段去拉三是用 client 端先准备好数据再通过属性文件分发。选哪条取决于数据是不是动态的。静态的、提前能确定的值-G就够动态生成的走外部共享存储更稳。5.3 日志和结果文件在分布式下更要盯紧分布式跑起来之后排查难度会明显上升因为你在 client 的控制台看不到 worker 的详细日志。我的做法是关键节点属性写入、属性读取失败都打log.error跑完先把各个 worker 的 jmeter.log 收上来扫一遍关键词确认没有异常堆栈再去看结果文件。结果文件也别只看最终报告。-l输出的 jtl 文件里每个采样器的响应数据、请求头都是可以看的。属性没读到导致的失败请求头里通常会留下明显的痕迹比如Authorization: Bearer NOT_SET这种。养成扫一眼失败请求的请求头比对着响应码猜要快得多。6. 我真实踩过的五个坑和排查链路6.1 GUI 连续运行导致假成功这个坑我吃过一次印象很深。GUI 里跑完一轮改了脚本直接再点一次运行。业务线程组里读到了正确的 token图表也很漂亮。但实际上那一轮 setUp 里的登录接口因为改了域名挂了属性根本没写入——读到的是上一轮留在 JVM 里的旧值。原因很简单GUI 模式下Jmeter 跑测试是复用同一个 JVM 的属性表不会在两次 Run 之间被清空。这个问题在命令行模式下不存在因为每次都是新起一个 JVM。排查链路是这样的先看结果树发现业务请求返回的都是 401第一反应是接口问题再去看 setUp 组的结果发现登录请求挂了然后想登录挂了为什么业务组还能读到 token——顺藤摸瓜才意识到是脏属性。两个防御手段。一是在 setUp 里写入之前先删props.remove(gv_token)保证每轮的起点是干净的。二是正式压测一律走命令行模式GUI 只用来调试脚本。这两条我现在都在用再没翻过车。6.2 取消串行执行带来的竞态独立运行每个线程组这个复选框默认是勾上的很多人为了让多个线程组同时加压会把它取消。取消之后所有线程组同时启动于是 setUp 组的登录请求刚发出去业务组已经在读 token 了十有八九读到空。这个问题的排查过程往往挺绕的因为它是间歇性的——机器快的时候可能蒙对机器慢的时候失败率上来。定位方法是看失败的采样器时间戳如果失败请求的时间点早于或几乎等于setUp 里登录请求的完成时间那基本就是竞态无疑。解决方式很简单要么把这个复选框勾回来用串行要么把初始化逻辑从普通线程组挪进 setUp Thread Group——后者的执行顺序由 Jmeter 保证跟复选框无关更可靠。6.3 属性名和 Jmeter 自身配置撞车有一回同事在脚本里写了个属性叫log_level本意是控制自己脚本的日志详略。跑完发现 jmeter.log 的体积暴涨磁盘差点写满。原因就是这个名字和 Jmeter 的内置日志控制属性产生了冲突等于在运行时把日志级别改了。这类问题的特点是现象和原因之间几乎没有逻辑关联你根本想不到是命名引起。所以命名规范不是形式主义是实打实的防风险措施。现在我要求所有共享属性一律带前缀比如gv_、ctx_一眼能看出这是脚本自己定义的不会和内置配置混。6.4 类型丢失、体积过大和并发写覆盖类型问题前面提过再说一个具体的表现脚本里用props.put(gv_count, 10)存了个整数另一个地方读出来拼进请求体结果发出去的是10.0。因为Properties在写文件和读取的过程中会做字符串化数字类型很容易变成带小数点的形式。统一用setProperty存字符串按需自己转类型就绕开这一整类问题。体积问题也遇到过。有个脚本把从 JDBC 查询出来的几百条记录拼成一个大字符串塞进属性想给下游线程组做参数化。压测跑到一半开始频繁 Full GCTPS 曲线断崖式下跌。后来改成把数据写进临时文件下游用 CSV Data Set Config 读问题立刻消失。并发覆盖的问题更隐蔽。业务线程组里有个前置处理器每次迭代都会把当前用户 ID 写进属性。200 个线程同时跑谁的写入最后生效完全是随机的。下游读这个属性的时候读到的是某一个线程最后一次写的值不是自己线程的。这种逻辑上的错误不会报错只会让数据对不上。凡是每个线程各不相同的数据就该老老实实用线程变量或者 CSV不要往属性里放。6.5 属性到底写没写进去用 Debug Sampler 五分钟定性排查这类问题我总结出一个固定的动作顺序。第一步在可疑位置插一个 Debug Sampler勾选显示 Jmeter 属性挂到查看结果树下面。跑一遍直接在响应里翻属性表找你的键在不在、值对不对。这一步就能把写没写成功和读没读对两个问题分开省掉大量猜测。第二步如果属性明明写进去了但读出来是空的那问题基本在读取端——检查是不是用了${gv_token}而不是${__P(gv_token)}检查函数名拼写检查默认值。第三步如果属性压根没写进去看上游的提取器结果。在查看结果树里看提取器的实际取值看 JSR223 里的日志输出看是不是被判空的逻辑拦住了。第四步如果前几步都正常那就往竞态和脏数据的方向想——是不是没勾串行、是不是 GUI 复用 JVM、是不是上一轮的残留值。这个顺序我用了很多次基本上十几分钟能定性。比漫无目的地翻日志、改脚本、重跑一轮效率高得多。7. 可以直接抄的落地清单7.1 命名规范与初始化模板命名上我固定用gv_前缀加业务语义全小写加下划线比如gv_token、gv_user_id、gv_order_no。不做缩写宁可长一点。属性名里不要出现点号以外的特殊字符避免和内置键的命名风格混淆。初始化模板的结构是这样setUp Thread Group1 线程、1 循环→ 登录请求 → 提取器 → JSR223 Samplergroovy、脚本缓存勾选→ 判空并写属性 → 加一行 log。写完属性之后再加一个 JSR223 断言或者响应断言确认关键属性确实存在于属性表中不满足就直接让 setUp 组失败。让初始化失败得早、失败得明显是整个脚本健壮性的第一道防线。读取端统一用${__P(gv_token,NOT_SET)}起步跑通之后如果脚本里引用点太多再改造成前置处理器读进线程变量 ${token}的形式。7.2 清理与回收我的习惯是在 setUp 组最开始先执行一次清理[gv_token, gv_user_id, gv_order_no].each { k - if (props.getProperty(k) ! null) { props.remove(k) log.info(已清理历史属性: k) } }这保证了脚本的幂等性——不管之前 JVM 里留了什么这一轮的起点都是干净的。另一个位置是 tearDown Thread Group如果属性里存了敏感信息比如真实账号密码跑完删掉是个好习惯尤其是在共享的压测机上。7.3 什么时候不该用全局属性最后这条我觉得比怎么做更重要。全局属性本质上是进程内的一块共享内存它适合的场景是少量、关键、所有线程都需要的、生命周期横跨整个测试的数据典型就是 token、环境标识、公共请求头。不适合的场景有一长串每个线程各不相同的账号、身份证、手机号走 CSV Data Set Config大体量的查询结果走临时文件需要跨进程严格一致的数据走外部共享存储只在一个线程组内部流转的中间值老老实实用线程变量需要频繁变更的配置走命令行参数。分清楚这些边界之后你会发现真正需要全局变量的场景其实不多但每一个都是关键路径写对了能省掉大量的重复登录和重复查询。我个人的体会是脚本里属性用得越少往往说明结构越清晰一旦发现自己在往属性表里塞第七第八个键就该停下来想想是不是该换个方式组织了。