
1. 现象最开始值为什么跳着走有个朋友前段时间塞给我一张 JMeter 的脚本截图问我这计数器是不是坏了它按 2、4、6、8 这样跳着涨根本不按 1、2、3 来搞得后面选出来的用户 ID 全乱了。我一看计数器用的是${__counter(TRUE,user_serial)}下面带了个循环控制器循环里有两个 HTTP 采样器每个采样器的路径和请求体里都拼了这个变量。这不是很典型的双份展开嘛页面名字叫 counter但真正干活的时候它被同一个线程的一次循环里调用了两次。第一次展开成了 2第二次展开成了 4于是你在观察结果时看到的就是 2、4、6。不过“按 2、4、6 递增”这个表象在实际项目里不止一种成因有些是真的配置踩坑有些确实是 JMeter 历史上特定版本里容易触发的小毛病。这篇就是我在做压测脚本时把这类问题从“疑似 bug”到“确定根因”再到“封板解决”的完整排查思路顺带把能用上的检查套路都整理出来你在自己脚本里遇到同样现象可以直接按这个顺序去查。先说结论大多数情况下这不是因为工具坏了而是因为计数器的“递增”发生在每次展开时而不是每次循环或每次请求时剩下的小部分情况才是全局/线程作用域和采样器位置导致的行为偏移。搞清楚这个原理后面所有的排查都是顺藤摸瓜。2. 先搞懂 JMeter 计数器的几套“人格”2.1 函数不等于配置元素JMeter 里能产生递增数的组件有两个大家族虽然都叫 counter但工作方式差别很大__counter函数格式是${__counter(TRUE,varName)}第一个参数写TRUE表示全局共享写FALSE表示每个虚拟用户单独计数第二个参数是可选变量名用于把当前值存进变量。每次这个函数被“展开”一次计数就递增一次。计数器配置元素Counter Config Element放在线程组下的一个配置元件有起始值、递增步长、最大值这些可填参数还有一个“每个线程独立跟踪计数”的复选框。它是在迭代过程中按照测试计划里的位置去执行的如果放在循环控制器里面会按循环节奏不断被重新处理。这两个东西最容易被混淆的点在于函数是“边展开边递增”配置元素是“处理一次、递增一次”。如果你在请求里反复引用同一个函数名或者函数被套进了其他变量表达式里那你实际看到的倍数关系就变成一个请求展开了几次计数器就跳几步。2.2 “线程级”和“全局级”的区别__counter(FALSE,v)在每个虚拟用户内维护一套独立计数所有用户都从 1 开始各自递增互不干扰。__counter(TRUE,v)则是在所有线程之间共享同一个数值某个线程先展开了数字就往前走其他线程拿到的永远是“当前全局值”。这在多线程表现上很容易造成误读。举例你开了 10 个线程每个线程跑一次采样每次都去看${__counter(TRUE,id)}然后你把十个线程的响应拼在一起看可能会看到 1、3、5、7、9、2、4、6、8、10 这种“貌似跳步”的顺序。这其实只是多线程并行执行时的先后顺序问题每个线程拿到的全局值本身没有重复但采集结果显示时被线程调度的顺序打乱了。我见过不少人把这个当 bug 报出来最后把简单的日志文件按线程号一拆每行其实都是唯一且递增的。2.3 关键所有字段展开都会触发计数JMeter 的执行模型可以简化理解成“每个请求的每个字段都做一次模板渲染”。在 HTTP 采样器里路径、请求体、参数值、Header只要出现${...}表达式就会被各自计算一遍。如果这些字段里恰好拼了同一个计数器函数那么一次采样器执行就会让计数器被展开多次也就递增多次。这个“展开次数 递增次数”的规则是排查按 2、4、6 跳步的第一把钥匙。很多初用者以为计数器是在“每个采样器开始前”递增一次实际上它是在“每次表达式被计算时”递增一次。提示想验证这个行为做一个最简单的计划里面一个线程、一个循环、一个 Debug 采样器给两个不同字段分别写${__counter(,a)}和${__counter(,b)}跑完看控制台输出计数器已经从 1 跳到 3 了而你实际只发出过一个“请求”。3. 按 2、4、6 递增的真正嫌疑名单3.1 嫌疑一同一个函数在一个请求里被拼了两次这算是最常见的根因。一个 HTTP 请求往往不只有一个地方会用到变量URL 路径里拼一个请求体 JSON 里拼一个或者请求头和 URL 里同时出现。只要你用同一个${__counter(...)}表达式拼了两处那次请求就会消耗两次计数。举个例子路径/api/user/${__counter(TRUE,uid)} 请求体{userId:${__counter(TRUE,uid)}}表面上看用户希望 uid 是同一个值。实际执行时请求体里的__counter先被展开uid 变成 2路径里的__counter随后展开uid 变成 4两个字段值不一样而且计数直接跨到了 4。如果你是用一个用户自定义变量把${__counter(TRUE,uid)}包了一层比如定义变量uid${__counter(TRUE,uid)}然后在请求两处都用${uid}情况会更隐蔽变量被赋值时计数器跳一次变量字段展开时不会再次调用函数所以这种封装反而是安全的。问题往往出在“直接裸写函数表达式”又到处粘贴复制的情况。3.2 嫌疑二计数器对象放在了会反复重跑的循环控制器里计数器配置元素有一个很阴间的行为它的位置影响着它是否会被重新初始化。把它放在线程组下、循环控制器外它只会在线程开始时初始化一次之后每个迭代都接着上次的值递增把它放在循环控制器内部它就会跟着循环体的执行反复初始化。如果你配的是一个“起始值 1、递增 1”的计数器但它被放进了循环控制器内部并且循环次数还大于 1常见表现就是每轮循环计数从头开始你根本看不到 2、4、6而是看到 1、1、1、1 这种重复。如果此时再叠加一些前置/后置处理器的执行顺序也可能出现看起来像“每次循环跳到下一个值”的假象。还有一类更绕的情况计数器元素被放在setUp 线程组里然后主线程组引用它导出的变量。由于 setUp 线程组在主线程组之前整体执行主线程组看到的是一个已经完成一轮计数后的固定值不是逐请求递增的值。这种情况下的表现更像是“值不涨”而不是“按 2 的倍数涨”但误判成 bug 的比例也很高。3.3 嫌疑三步骤配置错误起点、步长不是默认值别笑这是真实踩过的坑。计数器配置元素的默认起始值是 0、递增步长是 1。有人为了“造 5000 个不重复手机号”把起始值设成 2、递增设成 2结果跑出来就是 2、4、6、8。这不算 bug纯属参数理解问题。__counter函数本身没有直接暴露步长参数但如果你在函数外面套了一个算术表达式例如${__intSum(${__counter(TRUE,base)}, 0,)}或者在变量值里写uid_user ${__intSum(${__counter(TRUE,raw)}, ${__counter(TRUE,raw)},)}那么每次展开就同时触发了两个__counter结果是 2、4、6、8。这种情况在“想要两倍偏移量”时最容易手滑想取“当前值乘以 2”却写成了“两个 counter 相加”于是实际拿到的永远是下一个奇/偶序列差。3.4 嫌疑四多线程共享计数器的排列顺差这个之前提过被误报成“跳步”的概率极高。现象是响应日志按线程交错记录时某个线程的第 N 次请求若恰好排在另一些线程的请求中间你会觉得它跳过了某个值。比如线程组两个线程全局计数器线程 A 第一次拿 1线程 B 第一次拿 2线程 A 第二次拿 3线程 B 第二次拿 4单独看线程 B 的日志会发现它的两次请求拿到的值分别是 2 和 4中间“跳了”3。这就是 2、4、6 表象的另一来源。它本身不算错因为全局计数器的设计目标就是“所有线程拿到的值不重复”而不是“每个线程连续递增”。想验证是不是这个原因很简单把你统计的序列按线程序号拆分如果每个线程内拿到的值形成了间隔稳定的等差数列比如线程 B2、4、6线程 A1、3、5那就是共享计数器的正常表现。3.5 嫌疑五版本缺陷与“隐藏的额外展开”标题里说的“实为 bug”在我经历过的项目里确实有真 bug 的场景。主要集中在两点第一表达式在用户自定义变量阶段发生过一次展开之后引用变量时在某些 JMeter 旧版本里会再次对变量定义中的函数做一次“附加求值”。那个时期有一种已知怪象变量定义里放${__counter()}然后在多个采样器里引用该变量计数器明明该每请求加 1却出现每请求加 2 甚至加 3。通常出现在较老版本比如 5.4 以前某些组合下会有被重复计算的记录。第二${__counter()}被放进JSONPath 断言、XPath 提取器或正则提取器的模板字段里时有些版本会在“匹配校验阶段”和“返回结果阶段”各展开一次等效于请求还没发出计数先跳了两格。遇到这种版本相关问题最快的定位方式是拿一个可以复现的最小计划固定版本跑一遍再跑到最新补丁版本对比。通常升级或换一种计算写法后问题就消失了。注意我不太建议在压测脚本里把关键业务变量直接裸写__counter到多处字段里。最稳的做法永远是把计数逻辑先收进一个变量再在请求里引用变量。这样即便 JMeter 内部对不同字段的展开顺序有调整变量引用也只会拿同一个值不会额外消耗计数。4. 一整套可复现的排查流程4.1 构造最小复现单线程单循环遇到“计数器跳步”第一件事绝不是去翻测试计划里的几十个采样器而是立刻构造一个最小复现计划。新建一个测试计划只放一个线程组线程数1循环次数1放一个 Debug 采样器采样器里分别输出${__counter(TRUE,a)}${__counter(TRUE,b)}跑完后看查看结果树View Results Tree如果两个字段的值分别是 1 和 2说明在你当前版本里计数器是按“每次展开 1”工作的那后续的跳步必然是因为展开次数太多。如果这个最小计划里输出就是 2、4 这种跳跃那就直接更换最新稳定版重新对照同一份脚本。若最新版本仍然这样再去检查有没有把相同函数放入过用户定义变量的高级作用域里。这一步能在 5 分钟内把“版本 bug”和“用错函数”这两类大方向分离开。4.2 用调试采样器把值“打印”到响应里排查跳步最直观的手段是让当前值出现在你能看见的地方。JMeter 自带一个调试采样器Debug Sampler非常推荐在排查阶段直接把它加进循环控制器里放在有问题的采样器前面。Debug 采样器里有几个复选框JMeter 变量打印当前线程所有变量JMeter 属性打印全局属性系统属性打印 JVM 系统属性默认只勾选JMeter 变量就够了。计数器函数如果通过varName参数存进了变量例如${__counter(TRUE,uid)}那么每次迭代时 uid 的值会直接输出到查看结果树里你能看到它到底是在递增加 1还是因为多个字段引用而在同一循环里变了好几次。把 Debug 采样器和请求采样器并列放在循环控制器内跑 3 个循环看结果树里的顺序循环1 Debug: uid1 请求路径: /api/user/2 循环2 Debug: uid3 请求路径: /api/user/4这种输出基本就能锁定“请求路径表达式导致额外展开”的嫌疑。4.3 用 JSR223 采样器输出上下文Debug 采样器能看到变量值但看不到“这到底是第几个线程、第几个循环”。更精细的做法加一个 JSR223 采样器里面写一小段 Groovy 脚本def ctx org.apache.jmeter.threads.JMeterContextService.getContext() def tn ctx.getThreadNum() def loop ctx.getVariables().getIteration() def uid ctx.getVariables().get(uid) log.info(thread tn loop loop uid uid)跑完后打开jmeter.log比对日志文件thread0 loop1 uid2 thread0 loop2 uid4 thread0 loop3 uid6如果日志显示 loop 是 1、2、3但 uid 是 2、4、6那问题一定出在“计数消耗的节拍”与“循环节拍”不一致。这时你就知道不是采样器顺序问题而是同一次循环里被多展开了一次或者计数器放在了错误的作用域里。4.4 记录线程号和循环次数的现场隔离多线程场景下的跳步经常和线程调度混在一起光看汇总结果容易被误导。所以排查时我会在 JSR223 脚本里把线程号和循环次数一起输出再按照以下规则整理先把日志按thread排序看每个线程内部的 uid 是否严格递增且不重复再看多个线程之间的 uid 是否存在“值覆盖”或“重复取值”。如果每个线程内部的 uid 严格等差递增且差值恒定那就是全局计数器在正常服务多个线程如果你发现同一个循环次数内两个线程同时打印出了完全相同的 uid那才是真重复才是需要进一步查计数逻辑的根因。这一条非常关键能避免你把多线程的正常行为误判成 bug也能避免你为了“修复”一个正常行为而把全局计数改成每线程计数导致接口数据冲突。4.5 排查时保留现场证据在压测脚本改动之前我会先复制一份原始脚本保存为counter_bug_repro.jmx不动原脚本。接着把可疑采样器单独提出来另建一个精简计划去验证。一旦确认问题根因再在原脚本上做最小修改。保留现场证据的好处是你可以随时回退也可以拿着复现计划在 JMeter 升级后重新验证是否版本 bug。团队协作时这份复现计划就是最清晰的沟通语言比在聊天工具里贴“我看它是 2、4、6 诶”要有用得多。5. 针对每种根因的解决和规避方案5.1 方案一每个线程组只保留一个计数器来源如果你发现是因为同一个函数被多处引用第一优先级的解决方式不是去“数清楚哪些字段应该保留、哪些字段应该去掉”而是干脆让每个线程组里只保留一个计数器的“事实来源source of truth”。具体操作在线程组下定义一个用户自定义变量比如uidSource${__counter(TRUE,)}请求里所有需要 uid 的地方都用${uidSource}去引用不再直接在请求字段里写${__counter(...)}。这样计数器每循环只递增一次管道里其他几处字段读到的都是同一个值。即使 JMeter 不同版本的字段渲染顺序有变化也不会导致同一请求内部 uid 不一致。注意用户自定义变量在每次循环开始时会重新计算其定义里的函数表达式所以这种写法每循环仍只消耗一次计数不会变成每字段消耗一次。5.2 方案二把计数逻辑收进 JSR223 预处理器如果你的业务场景比较特殊比如需要“按指定的步长、只允许部分采样器消耗计数”或者想确保一次迭代里绝不重复消耗我会直接绕过__counter函数改用 JSR223 预处理器的 Groovy 脚本集中管理。典型写法def current Integer.parseInt(vars.get(uidCounter) ?: 0) current 1 vars.put(uidCounter, current.toString()) vars.put(uid, current.toString())把这个预处理器放在循环控制器最前面同一次迭代里所有采样器读取${uid}时拿到的都是同一个值计数器也只增加一次。因为你完全掌控了脚本逻辑后续不管 JMeter 怎么调整字段展开顺序都不会影响计数节奏。这种做法的代价是失去了纯配置元件的“零代码”特性但换来的确定性和可解释性非常值得。团队里如果有多个采样器需要分配 ID我基本都会选择这种集中计算方式。5.3 方案三全局计数用属性而不是函数有时候你希望多个线程组之间共享一个全局序列或者想让数据在setUp 线程组里生成、在主线程组里消耗此时用__counter(TRUE,...)不是不行但它在不同线程组间的初始化节奏容易踩坑。我的习惯是用 JMeter 属性来做全局计数在线程组里放一个 JSR223 采样器脚本如下def globalUid org.apache.jmeter.services.FileServer.getFileServer().getBaseDir() def ctx org.apache.jmeter.threads.JMeterContextService.getContext() def props ctx.getProperties() def nextVal props.get(globalUid) ! null ? ((props.get(globalUid) as Integer) 1) : 1 props.put(globalUid, nextVal) vars.put(uid, nextVal.toString())这样计数状态保存为 JMeter 属性多个线程组、多个线程之间都能共享且不会因为__counter的表达式展开次数而误增。如果你后续还需要跨不同 JMeter 进程共享一个序列再把globalUid写成外部文件 锁即可。5.4 方案四正确配置和放置 Counter 元素如果你选择计数器配置元素而不是函数至少要遵守两个放置规矩放在线程组下、所有控制器之外。这样才能让它保持“每迭代递增一次”的连贯节奏。如果想让多个线程共享同一计数在元素里把“每个线程独立跟踪计数”的复选框留空如果希望每个线程从同一个起始值各自计数就勾选该复选框。举一个典型正确配置计数器配置元素 名称全局用户序列 起始值1 递增1 最大值100000 取消勾选每个线程独立跟踪计数 导出变量名uid放好以后在请求字段里全部引用${uid}。这种配置最不容易出现“按 2、4、6 跳”的现象因为整个测试计划里只有一个计算位置在消费计数节奏。5.5 方案五给固定采样器配固定的变量名还有一种隐蔽的跳步情况是同一个变量名被不同的函数实例混用了。比如请求A里面 ${__counter(TRUE,userSerial)} 请求B里面 ${__counter(TRUE,userSerial)}表面上看都叫 userSerial实际上是两个独立函数实例各自维护各自的内部状态。但当你把它们同时输出到日志时两个实例交替递增最终你会看到 userSerial 变量值一会儿是 A 实例的值一会儿是 B 实例的值数值序列完全对不上。如果你要的就是“两个采样器共用同一个全局序列”那就必须把函数只写在一个地方或者统一放在用户定义变量里让两个采样器都引用同一个变量名。如果你要的是“每个采样器内部独立序列”那就用不同的变量名例如userSerial_A和userSerial_B避免日志和断言里互相覆盖。6. 结果验证与日常使用的经验“避坑”清单6.1 结果验证模板修复之后我习惯跑一个“连续 20 次迭代”的验证计划确认输出序列是否符合预期。验证计划固定如下线程数2循环次数10JSR223 采样器打印 thread、loop、uid预期结果要根据你的配置分两种场景预期序列说明每线程独立计数FALSE线程01~10线程11~10各线程互不影响全局共享计数TRUE所有线程合计 1~20单线程可能看到间隔值全局唯一不保证单线程连续请求内多处引用同一个变量同一个请求里两次读取 uid 都相同保证请求内部一致性如果你发现序列不符合表格里的任何一种再把脚本打开重新检查一遍展开次数和计数器位置即可。6.2 清单和检查点我给自己整理了一个“计数器跳步八问”每次遇到类似问题直接按顺序过一遍计数器到底用的是__counter函数还是计数器配置元素这个计数器是否被放进了循环控制器内部请求里是否有多个字段直接拼了同一个${__counter()}表达式是否在用户自定义变量里包裹了计数逻辑而变量又被多处引用你的线程数是不是大于 1是不是在看全局计数时按单线程预期去解读有没有在setUp 线程组或tearDown 线程组里也放了一个同名计数器你是用最新稳定版 JMeter 在跑脚本还是旧版本有没有把生成的 uid 用 Debug 采样器打出实际值来确认这八条能覆盖我见过的 90% 以上“计数器按 2、4、6 跳”的案例。剩下 10% 里七成是参数写错三成才是真版本 bug。6.3 我自己最后留下的三点经验说起来这类问题倒不是在“计数”本身而在“预期管理”。很多压测脚本出现异常时第一反应总是“工具坏了”但 JMeter 作为 Apache 老牌工具计数器这种基础组件出 bug 的概率其实很低更多时候是我们自己对它的执行模型理解有偏差。第一点永远不要把所有字段都直接塞进${__counter()}表达式。先算好再引用是避免这个坑的最稳习惯。哪怕多定义一个变量区区一行代码的成本能帮你省一个晚上的排查时间。第二点多线程场景一定要先按线程拆分日志。一段看起来像跳步的序列拆完线程后往往是正常的别急着下 bug 结论。第三点真遇到疑似版本 bug保留最小复现脚本再升级最新补丁对比一次。如果你用的版本特别老与其在旧版上绕来绕去不如把脚本里所有__counter全部换成 JSR223 计数一劳永逸。按 2、4、6 递增这件事本质上是计数器的“被调用频次”和“循环频次”错位了。只要顺着“表达式展开次数”这条主线去排查再多花五分钟做最小复现问题通常很快水落石出。