接口串联是性能测试和接口自动化里最容易翻车的一环:请求 A 返回一堆数据,请求 B 要拿着其中的某个字段去跑,而且往往不是一个值,是一批值。我见过太多人第一反应是用正则提取器硬啃 JSON,写出一串"id":"(\d+)"之类的表达式,改一个字段就要重调半个小时。其实 JMeter 自带的 JSON 提取器(JSON Extractor)就能把这件事做得很干净,前提是你得搞清楚它那几个输入框到底怎么协作,尤其是"Match No."和"多字段分隔符"这两个地方,几乎每个人都在这上面交过学费。下面这篇就按我自己做接口链路压测的实际顺序,把提取单个参数的全部值、一次提取多个参数这两件事彻底讲透,顺带把踩过的坑和排查顺序一起交代清楚,新手照着抄配置就能跑,有经验的人可以直接跳到第 3 节和第 6 节。
1. JSON 提取器在一次请求里究竟做了什么
很多人对 JSON 提取器的第一印象是"填个$.data.token就能拿到 token",于是就觉得它简单。真正让人卡住的从来不是第一个值,而是后面衍生出来的两类需求:一个字段对应多个值怎么一次性全捞出来,以及一次请求里同时要拿好几个不同层级的字段。要理解这两件事,得先弄清楚它在 JMeter 的执行链条里站在哪个位置。
1.1 它是后置处理器,位置放错等于没写
JSON 提取器的本质是Post-Processor(后置处理器),它的执行顺序是:前置处理器 → 取样器(发请求)→ 后置处理器(提取)→ 断言 → 监听器。这个顺序意味着两件事:第一,它必须挂在"会产生目标响应的那个取样器"下面,或者挂在这个取样器的父节点上并且作用域能覆盖到它;第二,提取出来的变量在断言里就能直接用了,所以"提取 + 断言"这个组合是完全成立的,你不需要再写额外的脚本去校验。
我个人的习惯是永远把它作为目标 HTTP 请求的直接子节点。原因很实在:如果挂在事务控制器或者线程组下面,它的作用域会覆盖同级的所有取样器,每个取样器执行完都会重新跑一遍提取逻辑,变量被反复覆盖。调试阶段你可能觉得没什么问题,等到并发上来、链路变长,就会出现"明明提取成功了但下一个请求拿到的是上一个接口的值"这种鬼故事。挂在请求下面,作用域干净,一眼就能看出这个变量是从哪个响应里来的。
1.2 六个输入项的职责,一次说清
JMeter 的 JSON 提取器界面上就那么几个框,但每个框的规则都不太一样,尤其是"哪些框支持多个值、用什么符号分隔",这是后面所有问题的根源。先看这张速查表:
| 界面字段 | 是否支持多个 | 分隔符 | 说明 |
|---|---|---|---|
| Names of created variables | 是 | 分号; | 要创建的变量名列表,按顺序与路径一一对应 |
| JSON Path expressions | 是 | 分号; | JSONPath 表达式列表,第 n 个对应第 n 个变量名 |
| Match No. | 否 | - | 单个数字,对本提取器内所有表达式统一生效 |
| Default Values | 是 | 分号; | 与变量名按位置对应,用于匹配失败时兜底 |
| Compute concatenation var | 否 | - | 勾选后额外生成一个变量名_ALL,把所有匹配值拼接起来 |
| 作用域(挂在哪个节点下) | - | - | 决定对哪些取样器的响应生效 |
这里要先敲一个重点:JSON 提取器用的是分号,不是逗号。正则提取器的多个变量名用逗号分隔,很多人从这个习惯带过来,在 JSON 提取器里写token,userId,结果就是 JMeter 把token,userId当成一个完整的变量名,你后面写${token}永远取不到值,而且它不会报错,只是静默地失败。这是我在新人提交的脚本里见到频率最高的问题,没有之一。
另一个容易忽略的点是Match No. 是全局的一个数,不是每个表达式各配一个。这意味着你没法在同一次提取里让 A 字段只取第一个、B 字段取全部。这个限制直接决定了多字段场景的写法,第 3 节会专门讲怎么绕过去。
1.3 变量是线程私有的,别指望跨线程直接用
JSON 提取器创建的变量存在JMeterVariables里,而JMeterVariables是跟着线程走的。也就是说,20 个并发线程各自执行一遍提取,每个线程手里都有一份自己的${id_1},互不干扰。这是好事,它天然支持"每个用户拿自己的数据"这种场景。
但反过来,如果你想把某个线程提取到的值给别的线程用,直接引用是绝对拿不到的,只会原样输出${token}这个字符串。跨线程共享得走 JMeter 的全局属性(props),写法是${__setProperty(...)}和${__P(...)},代价是全剧只有一份值、后写的会覆盖先写的。这块在第 5 节展开。
2. 单个参数取多个值:Match No 填 -1 之后发生了什么
"提取一个参数的所有值"这个需求,答案就在 Match No. 填-1。但只知道填 -1 是不够的,你得知道 JMeter 在这之后悄悄生成了哪些变量,否则你根本没法把它们取出来用。
2.1 Match No. 的取值语义与派生变量
Match No. 是个整数,语义如下:
| 取值 | 含义 | 产生的变量 |
|---|---|---|
| 1 | 取第 1 个匹配(默认) | var(单值) |
| 2 / 3 / n | 取第 n 个匹配 | var(单值) |
| 0 | 随机取一个匹配 | var(单值) |
| -1 | 取全部匹配 | var_1…var_N、var_matchNr,勾选拼接后还有var_ALL |
举个例子,假设登录接口返回了这样一个响应:
{ "code": 0, "data": { "token": "eyJhbGciOiJIUzI1NiJ9.demo.payload", "owner": { "uid": 9001, "nick": "tester" }, "list": [ { "id": 1001, "name": "alpha", "status": "ON", "price": 12.5 }, { "id": 1002, "name": "beta", "status": "OFF", "price": 88.0 }, { "id": 1003, "name": "gamma", "status": "ON", "price": 150.0 } ] } }变量名填id,路径填$.data.list[*].id,Match No. 填-1,勾上"Compute concatenation var"。跑完之后你在 Debug Sampler 里会看到:
id_1=1001 id_2=1002 id_3=1003 id_matchNr=3 id_ALL=1001,1002,1003id_matchNr是这次匹配到的总数,这个变量非常关键——它就是你后面写循环次数的依据,也是做"至少拿到一条"断言的基础。id_ALL是把所有匹配值用逗号拼起来的一个字符串,适合做日志输出或者快速确认,不适合直接拆开当参数用,因为它本质上还是一个字符串。
这里有个很实用的小技巧:当 Match No. 填 -1 而路径只匹配到一个节点时,JMeter 依然会生成var_1和var_matchNr=1。所以如果你把 Match No. 统一设成 -1,那么单值字段你也得用${token_1}这种带下标的方式引用,不能用${token}。这个行为一开始会让人很困惑,理解了之后就变成一种"统一风格"的便利:所有提取变量都带下标,引用规则一致,不会再出现"有的变量带下标有的不带"的混乱。
2.2 三种把批量值消费掉的方式
拿到id_1 … id_N之后,怎么把它们一个个送到下一个接口去,有三种做法,适用场景不同。
第一种是ForEach 控制器,最直观,适合"每个值都要发一次请求"的场景。配置里三个关键项:Input variable prefix 填id(就是变量名去掉下划线和数字的部分),Start index 填1,Output variable name 填currentId。控制器内部用${currentId}引用当前这一轮的值。要注意两个细节:变量名前缀里不要带下划线,因为 ForEach 是按"前缀 + 下划线 + 序号"去拼变量名的,前缀自己带下划线会被截断;另外 ForEach 是靠"变量不存在就停"来判断循环结束的,所以你的变量名空间要干净,别在同一个线程里搞出id_4、id_5这种野变量。
第二种是循环控制器 +__counter+__V,灵活度更高。次数直接写${id_matchNr},循环体里用一个计数器当索引:
${__V(id_${__counter(FALSE,)})}__V的作用是"先算变量名,再取值",把id_和计数器的值拼成id_1、id_2再去取。__counter(FALSE,)是每个线程独立计数,TRUE则是整个测试计划全局计数。这个写法适合你需要在循环里同时用到多个变量(比如既用${id_${n}}又用${name_${n}}),因为__V可以套用在任意变量名上,而 ForEach 一次只能吐出一个变量。
第三种是JSR223 脚本里直接读 vars,适合逻辑复杂、需要拼装或者过滤的场景:
int n = Integer.parseInt(vars.get("id_matchNr")) def sb = new StringBuilder() for (int i = 1; i <= n; i++) { String v = vars.get("id_" + i) if (v != null && v.length() > 0) { sb.append(v).append(",") } } vars.put("idJoined", sb.toString())这段脚本的价值在于:它把"取值 + 过滤空值 + 拼装"一次做完,比在 JMeter 元素里叠三层控制器清爽得多。我压测时经常用这一招把一批 ID 拼成逗号串,直接塞进批量查询接口的请求体里。
2.3 "取数组本身"和"取数组里的字段"不是一回事
这是另一个高频误解。路径写$.data.list、Match No. 填 -1,你拿到的是${list_1}、${list_2}、${list_3},每个值是一个对象的字符串形式(形如{id=1001, name=alpha, ...}或 JSON 字符串,取决于内部实现),你还得再解析一次。而写$.data.list[*].id,拿到的是扁平的数值列表,可以直接用。
所以原则很简单:永远把路径下钻到你真正需要的那一层标量字段,不要停在数组或对象上。同理,嵌套数组要注意扁平化:$.data.list[*].tags[*]会把所有元素的 tags 数组拉平成一个大列表,如果你原本想按元素分组,这个结果就不对了,得用$.data.list[0].tags[*]这种带具体下标的路径逐个处理。
3. 一次提取多个参数:三行配置的对齐规则
多参数提取的核心不是"多写几行",而是理解"位置对齐"这个机制。理解了这个,你就明白为什么错一个分号会导致整组变量错位。
3.1 三行配置必须"位对位"
变量名、JSON 路径、默认值这三行都是分号分隔的列表,它们的对应关系是按下标对齐,而不是按名字对齐。也就是说:
| 第几个 | 变量名 | JSON 路径 | 默认值 |
|---|---|---|---|
| 1 | token | $.data.token | EMPTY |
| 2 | firstId | $.data.list[0].id | -1 |
| 3 | ownerNick | $.data.owner.nick | unknown |
配置文本长这样:
Names: token;firstId;ownerNick Paths: $.data.token;$.data.list[0].id;$.data.owner.nick Defaults: EMPTY;-1;unknown跑完之后${token}、${firstId}、${ownerNick}都能直接用。注意这里的 Match No. 是1,所以变量不带下标。如果你把 Match No. 改成-1,那就得用${token_1}、${firstId_1}、${ownerNick_1}。
一旦某个地方多打或者少打一个分号,整组对应关系就整体串位,比如路径少一个、默认值多一个,结果就是ownerNick拿到了本该给firstId的值。这种错误不会报错,只会让你在断言失败的时候一脸茫然。
提示:变量名里不要带首尾空格。
token; firstId里的firstId会带上一个前导空格,变量名实际变成了" firstId",你写${firstId}就取不到。JMeter 不会帮你 trim,这个坑很隐蔽,尤其在从 Excel 粘贴配置的时候。
3.2 混合场景:既要 token 单值,又要 id 全量
前面说过 Match No. 只能填一个数,所以"token 只要第一个、id 要全部"没法在同一个提取器里用不同的匹配策略实现。可行的做法有这么几种,我按推荐程度排:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 统一用 -1 | 所有路径都填 Match No. = -1,单值字段改用${token_1}引用 | 最省事,推荐默认这么做 |
| 拆成两个提取器 | 一个 Match No.=1 只拿单值,一个 Match No.=-1 拿列表 | 需要保持${token}这种无下标引用风格 |
| 提取后用脚本归一 | 提取后写一行vars.put("token", vars.get("token_1")) | 想保持后续配置干净、不想拆提取器 |
我一般选第一种。原因很功利:多一个提取器就多一处需要维护的配置,改接口的时候容易漏改。而${token_1}这种写法虽然看着别扭,但规则统一之后反而不会错。
顺带说一个数组字段配多值时的效果。变量名id;price,路径$.data.list[*].id;$.data.list[*].price,Match No. 填 -1,你会得到id_1..id_3、id_matchNr=3、price_1..price_3、price_matchNr=3。两组列表长度一致,可以在循环里用同一个下标同时引用${__V(id_${n})}和${__V(price_${n})},做"ID 和价格配对提交"这种场景非常顺手。
3.3 默认值不填,请求会莫名其妙变红
这是最容易被忽略的一条。当某个路径没有匹配到任何节点,而对应的默认值又没填(也就是那一格是空的)时,JMeter 会把这次取样器标记为失败。注意,响应码可能还是 200、业务也是成功的,但取样器状态已经红了,聚合报告里的错误率直接上去,你会以为是服务端出了问题,实际上是提取器在报错。
验证方法很简单:故意把某条路径写成一个不存在的字段,然后跑一次,看查看结果树里这条请求的状态。你会看到失败信息里明确提到提取器没找到匹配。
所以我的默认值填写原则是:
- 对后续流程必须存在的字段(比如 token),默认值留空,让失败暴露出来,我要靠它来发现问题;
- 对可选字段(比如列表里可能没有的某个标记位),填一个明确的哨兵值,比如
NONE或者-1,避免整个请求被判定为失败; - 默认值的数量要么和变量名数量完全一致,要么直接留空整行不填,千万别填一半。
4. JSONPath 语法:真正需要掌握的只有一小部分
JSONPath 规范很长,但实际做接口测试用到的就那么十来种写法。下面这张表基本覆盖了 95% 的场景,建议直接记下来。
4.1 定位类语法速查
| 写法 | 含义 | 示例 |
|---|---|---|
$.a.b | 逐级取子节点 | $.data.token |
$['a-b'] | 键名含特殊字符时用方括号 | $['user-name'] |
$..key | 深度扫描,任意层级找 key | $..id |
$[0] | 数组下标 | $.data.list[0].id |
$[*] | 数组全部元素 | $.data.list[*].id |
$[0:2] | 数组切片(左闭右开) | $.data.list[0:2].id |
$[?(@.k)] | 存在 k 字段的元素 | $.data.list[?(@.price)].id |
$[?(@.k=='v')] | 条件过滤 | $.data.list[?(@.status=='ON')].id |
$[?(@.n > 100)] | 数值比较 | $.data.list[?(@.price > 100)].id |
针对第 1 节那个示例响应,几个实用路径的结果分别是:$.data.list[?(@.status=='ON')].id得到1001, 1003(两元素,Match No. 用 -1);$.data.list[0:2].id得到1001, 1002;$..id得到1001, 1002, 1003, 9001(注意深度扫描会把 owner 里的 uid 以外的其它 id 也捞出来,如果结构里有多个同名字段,结果往往超出预期)。
4.2 过滤、切片和长度函数
过滤表达式里字符串一律用单引号,这是 JSONPath 的习惯写法,也避免了在某些配置文件里双引号被转义的问题。多个条件用&&和||连接,比如"状态为 ON 且价格大于 100"写成$.data.list[?(@.status=='ON' && @.price > 100)].id。
切片[start:end]是左闭右开的,[0:2]表示取下标 0 和 1 两个元素。想做"取前 10 条"这种限制,切片比在脚本里截断优雅得多。
想直接拿到数组长度,可以用$.data.list.length(),这是个函数调用而不是字段访问,返回一个整数。它能正常工作,但它属于 JSONPath 实现提供的扩展函数,不同实现(Jayway、JsonPath-Plus 等)支持程度不完全一致。我一般还是更愿意用[*]配 Match No. = -1,然后用${id_matchNr}拿数量,因为这条路在所有版本上都稳。
4.3 三个和数据类型相关的坑
第一,长整型精度问题。有些业务的订单号、流水号是 19 位数字,作为 JSON 数字返回时,如果超过了 64 位整数的表示范围,底层的 JSON 解析库可能把它转成浮点数,提取出来就变成了1.2345678901234568E18这种科学计数法,值已经不是原来的值了。这不是 JMeter 的锅,是 JSON 数字解析的通用问题。处理办法有两个:一是推动服务端把这类 ID 用字符串返回(最彻底);二是在 JSR223 里用 Jackson 显式按文本节点取值,不走数字转换路径。
第二,过滤器里做数值比较,字段本身必须是数字类型。如果服务端把价格返回成字符串"150.0",你写@.price > 100是匹配不到的,得改成字符串比较或者先把响应处理一遍。这个在联调环境经常出现,因为不同语言的序列化策略不一样。
第三,响应编码。如果响应头里的字符集和实际内容不一致,中文会变成乱码,提取出来的字段自然也带乱码。碰到这种情况先在 HTTP 请求里显式设置一下内容编码,再看提取结果,不要一上来就怀疑路径写错了。
5. 提取出来的值怎么喂给下一个请求
提取只是上半场,把值正确地送到下一个接口才是完整链路。这部分按"线程内、循环内、跨线程"三个层次讲。
5.1 同一线程内:直接引用就行
同一个线程里,提取到的变量在任何地方都能用:URL 查询串里的${id_1}、请求体里的${token}、HTTP 信息头管理器里的Authorization: Bearer ${token},写法完全一样。JMeter 是在发送请求之前把整个请求(包括 header、body、URL)里的${}整体替换一遍,所以顺序上你不用担心"header 在提取之前就生成了"这种问题。
需要注意的一点是:HTTP 信息头管理器如果是线程组级别的,它对组内所有请求都生效,包括登录请求本身。登录请求也带着一个空的Authorization头,有些服务端会因此直接拒绝。解决办法是把信息头管理器挂在需要它的请求下面,而不是线程组下面。
5.2 循环里引用:ForEach 与__V的实际差别
假设你要拿 3 个 ID 分别去查详情,用 ForEach 控制器的配置是:
Input variable prefix: id Start index: 1 Output variable name: currentId控制器内部放一个 HTTP 请求,URL 写/api/detail/${currentId}。ForEach 会自动把这轮循环的索引写到${currentId}里,并且额外提供一个${__jm__ForEach__idx}形式的索引变量(不同版本命名略有差异,一般用不到)。
用循环控制器 +__V的等价写法是:
循环次数: ${id_matchNr} 循环体内引用: ${__V(id_${__counter(FALSE,)})}两种写法的实测差异在于:ForEach 更"声明式",配置直观,但一次只能暴露一个变量;__V更"命令式",但可以在循环体里同时拼出多个变量名,做配对提交时更灵活。另外 ForEach 要求变量名严格遵循前缀_序号格式,而__V没有这个限制,变量名空间更自由。
如果你还要在循环里判断"这是第几条",用${__counter(FALSE,)}记录次数就行;但要小心它是每线程独立的,多线程跑的时候每个线程的输出是不一样的。
5.3 跨线程共享:走属性,但要想清楚代价
前面说过变量是线程私有的。如果你有这样的需求——只登录一次拿 token,然后 100 个并发线程都用它来压业务接口——那就得走全局属性:
// 在 setUp 线程组或者仅一次控制器里执行 ${__setProperty(glbToken,${token},)}业务线程里这样取:
${__P(glbToken)}__setProperty把值写进props,这是 JVM 级别的全局存储,所有线程都能读。__P是读取属性,如果属性不存在会原样返回属性名,所以调试时如果看到输出是glbToken这四个字,就说明属性根本没写成功。
代价要讲清楚:属性是全局唯一的。如果 100 个线程各自登录拿到不同的 token,然后都往同一个属性里写,最后留下来的只有最后一个写入的值,其余线程读到的都是别人的 token。所以属性的适用场景只有"所有线程共用同一个值"这一种,比如全局的固定测试账号。想让每个线程有独立数据,就别用属性,老老实实每个线程自己登录、自己提取。
还有一种更工程化的做法:把 token 的生成放到 setUp Thread Group 里,通过属性和一个"等待"机制协调,业务线程组读取属性。但这是我的个人偏好,如果你的测试计划对时间没这么敏感,直接在业务线程组里每线程登录一次完全够用,而且更接近真实用户行为。
5.4 顺便把它当断言用
提取出来的${id_matchNr}天然就是一个校验点。在响应断言里判断它大于 0,或者用 JEXL3 写一个更细的条件:
${__jexl3(${id_matchNr} > 0,)}在"断言结果"里如果看到失败,说明这次响应里根本没有返回 ID 列表,这时候去查服务端比查脚本更有意义。这比单纯断言 HTTP 200 有价值得多——200 只说明网络通了,业务可能早就返回了code: 500。
顺便区分一下:JMeter 自带的"JSON 断言"(JSON Assertion)和 JSON 提取器是两回事,前者只告诉你"某个路径存不存在",不产出变量;后者产出变量但不做断言。做链路测试时两个经常配合使用,提取器负责传值,断言负责守门。
6. 提取不到值?按这个顺序排查最省时间
这一节是我踩坑最多的部分,也是我认为整篇最有价值的地方。提取失败的原因就那么几类,但表现各不相同,盲目乱试会浪费大量时间。下面按"从外到内"的顺序列出来,照着走基本能在几分钟内定位。
6.1 第一步永远是看真实响应,不要盯着配置发呆
加一个 Debug Sampler(调试取样器),配合查看结果树,先确认三件事:
- 目标请求的响应体到底是不是你以为的那个 JSON。有时候开发改了字段名,或者返回了一个包装层,你的路径从第一级就不对了。
- 变量到底创建了没有、叫什么名字。Debug Sampler 会把当前线程所有变量列出来,包括
id_1、id_matchNr这种派生变量。看到id_matchNr=0就说明路径没匹配上;看到变量压根不存在,说明提取器没执行或者没生效。 - 查看结果树里有一个"JSON Path Tester"面板,可以直接把响应粘进去、把路径贴上去实时试。这个功能比反复改脚本跑测试快十倍,建议养成习惯,路径先在 Tester 里试通,再写进提取器。
6.2 常见现象与真实原因的对照
下面这张表是我自己整理的问题清单,几乎覆盖了日常遇到的所有情况:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 变量存在但值是空字符串 | 路径没匹配到,且默认值为空 | 用 Tester 验证路径;确认字段名大小写 |
| 取样器变红,但响应码是 200 | 提取无匹配且未填默认值 | 补默认值,或修正路径 |
${id}原样输出在请求里 | 变量名对不上,或多变量用了逗号分隔 | 检查分隔符是不是分号;确认是否需要带下标 |
| 只拿到第一个值,后面的没有 | Match No. 没改成 -1 | 改成 -1,确认派生变量 |
拿到的是{...}字符串而不是字段 | 路径停在对象/数组层级 | 路径继续下钻到标量字段 |
| 循环里每次都取到同一个值 | 提取器挂的层级不对,或变量被 CSV 覆盖 | 检查作用域;检查变量是否与 CSV 列名重名 |
| 数字变成了科学计数法 | 长整型超出精度范围 | 让服务端返回字符串,或用脚本处理 |
6.3 三个"静默失败",最容易被忽略
第一个是分隔符。分号写成逗号,或者从别处复制粘贴带进了全角分号(;),JMeter 两个都不认。全角符号这个问题在中文环境特别常见,肉眼几乎分辨不出来,建议配置里如果莫名取不到值,先把分号删掉重新敲一遍。
第二个是作用域。提取器挂在事务控制器下面,而事务控制器里有 3 个请求,每个请求执行完都会跑一遍提取,变量被覆盖。表面上看是"提取成功了但值不对",实际是提取了太多次。
第三个是变量名冲突。这一条我要重点说,因为它跟 JSON 提取器本身没关系,但被坑的人特别多:如果你的测试计划里用了 CSV Data Set Config,CSV 的列名(Variable Names 那栏)和提取出来的变量名重名,那么 CSV 会在每次迭代开始时把变量重置成文件里的值,你的提取结果直接被覆盖。举个真实的例子,CSV 里有一列叫token,你又用 JSON 提取器提取了一个token,那循环第二轮开始,token的值就变成 CSV 里那一行的值了,接口全 401。解决办法很简单:给提取的变量加个前缀,比如apiToken,跟 CSV 的列名彻底分开。
6.4 一个真实案例的完整排查链路
去年做一次链路压测,登录接口拿 token,后面 5 个业务接口都要用。现象是:单线程跑通过,20 并发跑的时候开始零星 401,比例大概 5%。
我的排查顺序是这样的:
- 先确认 token 有没有提取到。打开 Debug Sampler,看到每个线程都有自己的
${token},值不为空,格式也对。说明提取本身没问题。 - 再确认 token 有没有被送出去。把请求的 header 打出来看,发现 Authorization 头是有的,值也是那个 token。说明传参也没问题。
- 然后怀疑服务端。找开发确认 token 的有效期,发现是 30 分钟,压测跑了 10 分钟,不应该过期。
- 最后才想到变量覆盖。翻配置发现 HTTP 信息头管理器是线程组级别的,而这个线程组里除了登录请求,还有一个"刷新用户信息"的请求也会返回一个新的 token 字段——但那个请求我没有挂提取器。问题在于,我把另一个 JSON 提取器挂在了事务控制器上,作用域覆盖了整组请求,导致刷新请求返回的 token 把登录的 token 覆盖掉了。刷新接口返回的 token 在某些并发条件下会返回空,于是覆盖成了空值,401 就出现了。
定位到之后,把两个提取器的位置都改成挂在各自的请求下面,401 立刻降到 0。这个案例的教训是:提取器的位置不是形式主义,它会直接改变变量的生命周期。我现在的习惯是,任何提取器都必须在目标请求的正下方,绝不放在事务控制器或线程组上,哪怕它只影响一个请求。
7. 性能与维护:别让提取器成为压测的瓶颈
最后一个话题,说一下提取方式的选择和维护性的问题。这部分在功能测试阶段感受不明显,但压测的时候差别就出来了。
7.1 三种取数方式的取舍
| 方式 | 上手成本 | 性能 | 适合场景 |
|---|---|---|---|
| JSON 提取器(JSONPath) | 低 | 中,路径越简单越快 | 常规字段提取,90% 的场景 |
| JSON JMESPath 提取器 | 中 | 中 | 表达式复杂、需要函数运算,如sort_by、max_by |
| JSR223 + Jackson/JsonSlurper | 高 | 可控,取决于脚本写法 | 超大响应、复杂条件过滤、需要类型转换 |
JSON 提取器内部用的是 Jayway 的 JSONPath 实现,它对同一个响应做了 JSON 解析缓存,所以你在一个响应上挂多个提取器,不会重复解析整棵树。但每条路径的匹配过程本身还是实打实的开销,尤其是深度扫描$..key,在大响应上会遍历所有节点。
JMESPath 是 JMeter 5.2 之后引入的另一套语法,优势在于字符串处理和多条件运算的表达能力更强,但语法和 JSONPath 不通用,团队里如果只有你一个人用,后期维护会有点麻烦。我的建议是:能用 JSONPath 就别换工具,只有在 JSONPath 表达不出来的时候(比如需要排序取最大)再上 JMESPath 或者脚本。
7.2 让路径尽可能"窄"
性能优化的第一原则是不要用深度扫描。$..id看着爽,实际上它会遍历整棵 JSON 树,一个 500KB 的响应跑一遍深度扫描的开销比你发一次请求还大。而$.data.list[*].id这种明确层级的路径,Journal 实现可以做定向匹配,快得多。压测前花十分钟把脚本里的$..全部替换成具体路径,收益非常明显。
第二个原则是控制数组规模。如果响应里可能返回几千条列表,而你又用[*]全量提取,那变量数量会有几千个,JMeterVariables的内存占用和 Debug Sampler 的输出都会爆炸。这种场景要么在服务端加限制,要么用切片[0:100]限制条数,要么干脆别提取全部、改成在脚本里消费。
第三个原则是合并提取器。同一份响应里的多个字段,写在一个提取器里用分号分隔,比挂五个提取器好维护得多。变量的命名也建议加上业务前缀,比如loginToken、orderListIds,一眼能看出它来自哪个接口,团队协作时这一点比省几个字符重要得多。
7.3 把提取配置沉淀下来
压测脚本往往要复用,接口字段一改就要全量改路径。我现在的做法是把常用的 JSONPath 表达式集中写在一个 CSV 文件里,用 CSV Data Set Config 读进来,提取器里写${__V(pathToken)}这种形式引用——这样做的前提是 Match No. 要处理好,因为动态路径提取出来的变量名不好预测,所以我一般只在"路径固定、只是换环境"的场景用这种方式。
更实用的做法是用 Test Fragment 加模块控制器,把"登录 + 提取 token"封装成一个片段,所有需要登录的脚本直接引用。这样 token 字段一旦改名,只改一个地方。配套的还有一个习惯:在提取器上写注释(JMeter 的 Comment 字段),标明这段路径对应接口文档的哪个字段、响应示例长什么样。三个月后回头看脚本,有注释和没注释完全是两种体验。
说到底,JSON 提取器本身不复杂,难的是把它放进一个多接口、多线程、多轮迭代的链路里还能稳定工作。我自己最看重的两条经验是:提取器永远挂在目标请求下面,以及看到取不到值先加 Debug Sampler 看变量列表,别猜。这两条帮我省下的时间,比读十篇语法教程都多。至于 Match No. 和分号这两件事,写成肌肉记忆之后就再也不会犯错了。