1. 项目概述:为什么我们需要自定义请求参数
做性能测试的朋友,尤其是用过JMeter的,肯定都遇到过这样的场景:脚本里有一个登录接口,你需要模拟100个用户同时登录。如果这100个用户都用同一个用户名密码去请求,服务器一看就知道是“机器人”在刷,要么直接拒绝,要么数据都挤在同一个用户下,完全测不出真实场景下的数据库锁、缓存竞争和业务逻辑。这时候,“自定义不同请求参数”就成了压测脚本从“能跑”到“有用”的关键一跃。
简单来说,这个项目标题“JMETER并发压测-自定义不同请求参数”的核心,就是解决在并发压力测试中,如何让每一个虚拟用户(线程)使用独一无二的数据去发起请求。这不仅仅是替换一个用户名那么简单,它涉及到参数化数据的准备、在脚本中的动态引用、不同数据格式(如JSON、表单)的处理,以及如何模拟真实业务中数据分布的规律(比如90%的用户只浏览,10%的用户会下单)。掌握这套方法,你的压测结果才具有参考价值,才能真实地反映系统在高并发下的瓶颈所在。
2. 核心思路与方案选型:从“写死”到“动态生成”
在深入具体操作前,我们先理清思路。实现“自定义不同请求参数”无外乎几种路径,每种都有其适用场景和优劣。
2.1 方案对比:CSV文件、函数助手与代码生成
最经典、最通用的方案是使用CSV 数据文件设置。它的工作原理就像准备了一张Excel表格(CSV格式),每一列是一个参数(如username, password, productId),每一行是一组数据。JMeter的线程在运行时,会按顺序或随机地从文件中读取一行数据,并将列值赋给对应的变量。这种方法优势在于数据准备直观、管理方便,尤其适合测试数据量固定、且需要反复使用的场景。比如,你有1000个预注册好的测试账号,用CSV文件管理是最合适的。
JMeter内置函数提供了另一种轻量级的动态化手段。比如__Random函数可以生成随机数,__RandomString可以生成随机字符串,__time可以获取时间戳。这些函数非常适合生成一些无需持久化、范围简单的参数,比如随机的商品ID、当前时间戳等。它们的优点是无需外部文件,直接在请求参数中嵌入函数调用即可,非常灵活。但缺点是无法生成符合特定业务规则或需要关联的复杂数据(比如,用户名和邮箱需要保持一致)。
当需求变得复杂,比如需要生成符合特定格式的身份证号、手机号,或者需要从上一个请求的响应中提取数据作为下一个请求的参数(关联)时,前两种方法就力有未逮了。这时,就需要祭出BeanShell 或 JSR223 处理器。通过编写少量的Java、Groovy等脚本代码,你可以实现几乎任何逻辑的数据生成和加工。例如,用Groovy脚本读取一个本地文件作为数据池并随机选取,或者利用Faker库生成高度仿真的测试数据。这是功能最强大、最灵活的方案,但代价是需要一定的编程能力。
注意:在JMeter 5.0之后,官方强烈推荐使用JSR223 处理器配合Groovy语言,来代替旧的BeanShell。因为Groovy在JMeter中运行效率更高,编译性能更好,对现代Java特性的支持也更完善。
2.2 我们的选型策略:混合模式应对多场景
在实际项目中,我很少只采用单一方案。一个成熟的压测脚本,往往是“CSV文件打底,函数生成辅助,脚本处理复杂逻辑”的混合模式。
- 核心业务数据用CSV:像用户账号、核心ID这类需要严格准确、且可能涉及业务校验(如密码错误次数限制)的数据,我会用CSV文件预先准备好。这保证了测试的可重复性和准确性。
- 辅助参数用函数:像一些查询条件里的时间范围、随机的页码大小(pageSize),直接用
__Random和__time函数搞定,让脚本更简洁。 - 动态关联与复杂生成用脚本:对于需要从登录响应中提取token并传递给后续请求的场景,必须用正则表达式提取器或JSON提取器配合变量传递。对于生成批量且符合特定规则的数据(如1000个不同地址),我会在“仅一次控制器”里用Groovy脚本生成一个List,供后续线程使用。
这样的混合策略,既兼顾了稳定性和效率,又保留了应对复杂需求的扩展能力。
3. 核心细节解析与实操要点
确定了方案,我们来拆解实现过程中的核心细节。很多初学者在这里踩坑,不是因为工具不会用,而是对并发下数据如何被消费理解不透。
3.1 CSV数据文件设置的“坑”与“技巧”
添加一个CSV 数据文件设置元件很简单,但下面几个参数配置决定了你的压测是“雨露均沾”还是“扎堆撞车”。
- 文件名:建议使用绝对路径,或者相对于JMeter启动目录的相对路径。如果你计划进行分布式压测,这份CSV文件必须存在于所有压力机(Slave)的相同路径下。
- 变量名称:这是关键!比如你CSV有三列,分别代表用户名、邮箱、年龄,这里可以填写
username,email,age。JMeter会按顺序创建这三个变量。注意分隔符是逗号。 - 遇到文件结束符再次循环?:这个选项至关重要。
- True(默认):当所有线程把数据文件里的行都读完后,会从头开始循环读取。这适用于“用户数 > 数据行数”的情况,但会导致数据被重复使用,可能不符合“每个用户数据唯一”的预期。
- False:读完最后一行后,停止读取。后续线程将获取不到值,变量可能为空。这适用于“用户数 <= 数据行数”,且要求数据绝对唯一的场景。如果设置False,务必确保你的线程数不超过数据行数,否则部分请求会失败。
- 遇到文件结束符停止线程?:如果上面选了False,这个可以设为True。当数据用完时,线程自动停止,是一种优雅的控制方式。
- 共享模式:在分布式压测或线程组嵌套时需要注意。
- 所有线程:默认值,所有线程共享同一个文件指针,按顺序取数据,能保证数据不重复。
- 当前线程组:仅在线程组内共享。
- 当前线程:每个线程独立拥有一份文件副本,都会从第一行开始读。这通常不是你想要的效果,除非你的数据文件内容是完全通用的、可重复的。
实操心得:我习惯为重要的CSV数据文件设置创建一个独立的“配置元件”目录,并在文件名上加上日期和版本标识,如user_data_v1_20240515.csv。同时,在CSV文件的第一行,我会写上注释,说明各列的含义和格式,例如# username, password, user_id。
3.2 参数引用与数据格式处理
拿到变量后,如何在请求中使用它?这里根据请求内容类型(Content-Type)不同,方式也有差异。
对于 HTTP 请求:
查询参数(Parameters):直接在参数列表里,值一栏填写
${username}即可。消息体数据(Body Data):当发送JSON或XML时,这是最常用的方式。你需要构建一个完整的JSON字符串,并将变量嵌入其中。例如:
{ "username": "${username}", "credential": "${password}", "age": ${age} }注意,数字类型的变量(如age)不需要加引号。这是一个常见的错误,加了引号会被服务器当作字符串处理。
消息体数据(Body Data)中的文件上传:如果需要上传文件,且文件名也需要参数化,可以在“文件上传”标签页中,将“文件名称”设置为
${file_name},前提是你已经通过CSV或脚本定义了file_name变量,且该文件存在于压力机的指定路径。
对于其他协议,如JDBC(数据库),你可以在SQL查询中使用${variable}来替换查询条件。
重要提示:在JMeter中,变量引用是区分大小写的。
${Username}和${username}是两个不同的变量。保持命名风格一致(我习惯全小写下划线)能避免很多不必要的麻烦。
3.3 使用JSR223生成更智能的数据
当CSV和函数不够用时,JSR223处理器就是你的瑞士军刀。假设我们需要在注册接口中,生成1000个符合“姓名+随机数字”格式的用户名。
在需要生成数据的请求前,添加一个JSR223 PreProcessor。
语言选择groovy。
在脚本区域编写如下代码:
import java.util.concurrent.ThreadLocalRandom // 定义一个姓氏列表 def lastNames = ["张", "王", "李", "赵", "刘", "陈", "杨", "黄", "周", "吴"] // 定义一个名字列表 def firstNames = ["伟", "芳", "娜", "秀英", "敏", "静", "丽", "强", "磊", "洋"] // 随机选取姓氏和名字 def lastName = lastNames[ThreadLocalRandom.current().nextInt(lastNames.size())] def firstName = firstNames[ThreadLocalRandom.current().nextInt(firstNames.size())] // 生成一个3位随机数 def randomSuffix = ThreadLocalRandom.current().nextInt(100, 1000) // 生成100-999的数 // 组合成用户名 def generatedUsername = lastName + firstName + randomSuffix // 将生成的值存入JMeter变量,供后续请求使用 vars.put("generated_username", generatedUsername) // 同样可以生成邮箱 vars.put("generated_email", generatedUsername + "@test.com") log.info("生成用户名: " + generatedUsername)在后续的HTTP请求中,就可以使用
${generated_username}和${generated_email}作为参数了。
为什么用Groovy和ThreadLocalRandom?因为Groovy性能好,语法简洁。ThreadLocalRandom是Java并发包中高性能的随机数生成器,相比传统的Random类,它在多线程环境下冲突更少、性能更高,非常适合JMeter这种高并发场景。
4. 实操过程:构建一个完整的参数化压测脚本
理论说再多,不如亲手搭一个。我们以一个典型的用户“登录-浏览商品-加入购物车”流程为例,构建一个完整的参数化压测脚本。
4.1 第一步:准备测试数据(CSV文件)
我们创建一个test_data.csv文件,内容如下:
username,password,product_id user001,pass001,1001 user002,pass002,1002 user003,pass003,1003 ... (可以准备成百上千行)保存到你的JMeter脚本目录下,例如D:/jmeter_scripts/data/test_data.csv。
4.2 第二步:搭建JMeter脚本结构
- 创建线程组:右键测试计划 -> 添加 -> 线程(用户) -> 线程组。设置线程数(虚拟用户数)为10,循环次数为5,Ramp-Up时间为2秒。这意味着2秒内启动10个用户,每个用户执行5次整个流程。
- 添加CSV数据文件设置:右键线程组 -> 添加 -> 配置元件 -> CSV 数据文件设置。
- 文件名:
D:/jmeter_scripts/data/test_data.csv(或使用相对路径./data/test_data.csv) - 变量名称:
username,password,product_id - 其他默认(分隔符逗号,遇到文件结束符再次循环? True)。
- 文件名:
- 添加事务控制器(可选但推荐):右键线程组 -> 添加 -> 逻辑控制器 -> 事务控制器。命名为“用户完整流程”。这会把其下的所有请求采样器统计为一个事务,便于分析整体耗时。
4.3 第三步:实现参数化请求
在“事务控制器”下,按顺序添加请求:
登录请求:
- 添加 -> 取样器 -> HTTP请求。
- 协议:
http或https - 服务器名称或IP:填写你的被测系统地址。
- 路径:
/api/login - 方法:
POST - 转到“Body Data”标签页,添加JSON:
{ "username": "${username}", "password": "${password}" } - 添加HTTP信息头管理器,设置
Content-Type: application/json。
处理登录响应(关联):
- 在登录请求下,添加 -> 后置处理器 ->JSON提取器。
- 变量名称:
auth_token(这是我们要提取的变量名) - JSON路径表达式:
$.data.token(假设响应JSON结构为{"code":0, "data":{"token":"xxx"}}) - 匹配数字:
1
浏览商品请求:
- 添加另一个HTTP请求。
- 路径:
/api/products/${product_id}(这里直接使用了CSV中的product_id) - 方法:
GET - 在HTTP信息头管理器中(可以复用之前的,也可以新建),添加一个Header:
Authorization: Bearer ${auth_token}。这样就把登录得到的token传递过来了。
加入购物车请求:
- 再添加一个HTTP请求。
- 路径:
/api/cart/add - 方法:
POST - Body Data:
{ "productId": ${product_id}, "quantity": ${__Random(1,10,)} // 使用函数生成1-10之间的随机购买数量 } - 同样,需要包含带有
Authorization: Bearer ${auth_token}的HTTP信息头管理器。
4.4 第四步:添加监听器查看结果
添加几个常用的监听器来查看结果:
- 查看结果树:用于调试,查看每个请求和响应的详细信息。注意:正式压测时务必禁用或删除它,因为它会消耗大量内存。
- 聚合报告:查看整体的TPS、响应时间、错误率等关键指标。
- 用表格查看结果:以表格形式查看每个样本的结果,方便排序和观察。
现在,运行这个脚本,你将看到10个用户,各自使用test_data.csv中不同的用户名、密码和商品ID,独立地执行登录、浏览、加购操作,并且每个用户的登录token都被正确提取并用于后续授权请求。这就实现了一个基本的、参数化的并发业务流程压测。
5. 高级技巧与复杂场景应对
掌握了基础操作,我们来看看一些更进阶的、能让你脚本更贴近真实、更强大的技巧。
5.1 模拟真实业务分布:加权随机与吞吐量控制器
真实场景中,用户操作不是均匀的。例如,80%的请求是浏览,15%是搜索,只有5%是下单。我们可以用吞吐量控制器来模拟这种分布。
- 在线程组下,创建三个“简单控制器”,分别命名为“浏览操作”、“搜索操作”、“下单操作”。
- 在每个简单控制器下,放置对应的HTTP请求。
- 右键“浏览操作”简单控制器 -> 添加 -> 逻辑控制器 ->吞吐量控制器。
- 百分比:
80 - 勾选“每用户”
- 百分比:
- 同样,为“搜索操作”和“下单操作”的简单控制器添加快捷吞吐量控制器,百分比分别设置为
15和5。 - 将“吞吐量控制器”的执行模式设置为“百分比”。
这样,在压测运行时,JMeter会控制各个部分执行的频率,使其大致符合80:15:5的比例,从而让压力模型更加真实。
5.2 参数唯一性与全局计数器
有时我们需要一个全局唯一的ID,比如订单号。虽然可以用__Random或UUID函数,但高并发下仍有极小概率冲突。更稳妥的方法是使用计数器(Counter)配置元件。
- 在线程组下,添加 -> 配置元件 -> 计数器。
- 设置起始值、递增步长(通常为1)。
- 引用名称:
global_order_counter - 与每用户独立的跟踪计数器?:如果希望每个线程有自己的计数序列,选True;如果希望所有线程共享一个全局递增计数器,选False。
- 在生成订单号的请求中,可以将计数器与其他字符串拼接:
ORDER_${global_order_counter}。
5.3 动态参数依赖:后置处理器与变量传递
我们之前用JSON提取器提取了token,这是一个典型的后置处理。对于更复杂的响应,比如一个商品列表,需要随机选取一个商品ID加入购物车,可以结合使用正则表达式提取器和JSR223处理器。
- 在获取商品列表的请求后,添加正则表达式提取器。
- 引用名称:
product_id_list - 正则表达式:
"id":(\d+)(假设响应是JSON,提取所有id字段的值) - 模板:
$1$ - 匹配数字:
-1(匹配所有,结果会存为product_id_list_1,product_id_list_2, ...,匹配总数存在product_id_list_matchNr)
- 引用名称:
- 在下一个请求(如加入购物车)前,添加JSR223 PreProcessor(Groovy)。
import java.util.concurrent.ThreadLocalRandom // 获取匹配到的商品ID总数 def matchNr = vars.get("product_id_list_matchNr") as Integer if (matchNr > 0) { // 随机选择一个索引 (0 到 matchNr-1) def randomIndex = ThreadLocalRandom.current().nextInt(0, matchNr) // 获取对应的商品ID def selectedProductId = vars.get("product_id_list_${randomIndex + 1}") // 存入新变量供请求使用 vars.put("selected_product_id_for_cart", selectedProductId) log.info("随机选择商品ID: " + selectedProductId) } else { log.warn("未提取到商品ID,使用默认值") vars.put("selected_product_id_for_cart", "1000") } - 在加入购物车的请求中,使用
${selected_product_id_for_cart}作为参数。
6. 常见问题与排查技巧实录
即使按照步骤操作,也难免会遇到问题。这里记录了几个我踩过的坑和解决方法。
问题1:变量值为空(${variable} 没有被替换)
- 可能原因1:变量作用域问题。CSV数据文件设置或生成变量的处理器,必须位于你要使用该变量的请求的上级路径。检查元件树结构。
- 可能原因2:变量名拼写错误或大小写不一致。仔细检查。
- 可能原因3:CSV文件读取失败。检查文件路径是否正确,是否有读写权限。可以在CSV数据文件设置中添加一个调试后置处理器(Debug PostProcessor)来查看读取到的变量值。
- 排查技巧:在出问题的请求前,添加一个调试取样器(Debug Sampler)。运行后,在“查看结果树”中查看这个取样器的响应,它会清晰列出当前作用域下所有JMeter变量的值,是排查变量问题的利器。
问题2:并发下数据重复或错乱
- 可能原因:CSV数据文件设置的“共享模式”或“遇到文件结束符再次循环?”配置不当。如果希望每个线程用独立数据且不重复,应确保“共享模式”为“所有线程”,并且“遇到文件结束符再次循环?”为False,同时保证线程数 <= CSV数据行数。
- 排查技巧:在请求中,不仅输出业务参数,也输出线程编号
${__threadNum}和循环次数${__iterationNum},这样在结果中就能清晰地看到是哪条线程在哪个循环用了哪组数据。
问题3:使用JSR223脚本时性能急剧下降
- 可能原因:在JSR223处理器中,脚本编译模式设置不当。如果脚本内容每次迭代都变化(比如写在参数框中),JMeter每次都会编译,消耗巨大。
- 解决方案:将脚本写在“脚本”区域,而不是“参数”区域。并确保在JSR223元件的底部,“缓存编译的脚本?”选项勾选上(打勾)。这样脚本只会被编译一次并缓存,性能会大幅提升。
问题4:参数化文件上传时,文件找不到
- 可能原因:文件路径是相对于JMeter启动目录的。在分布式压测时,这个文件必须在所有Slave机器的相同路径下都存在。
- 解决方案:使用绝对路径最保险。或者,将需要上传的文件和CSV数据文件一起,打包到测试计划中(JMeter的“文件”菜单),这样在分布式执行时,主控机(Master)会自动将文件发送给Slave。
问题5:JSON请求体中,数字变量被加上了引号
- 现象:在“Body Data”中写
"age": ${age},但实际发出的请求是"age": "25",导致服务端类型错误。 - 原因与解决:这通常是因为
${age}变量在定义时就是字符串格式(比如从CSV读取的)。JMeter不会自动转换类型。解决方法有两种:一是在服务端允许字符串转数字;二是在JSR223预处理器中,使用Integer.parseInt(vars.get("age"))将其转为整数再存回变量,或者在JSON中直接写"age": ${__javaScript(parseInt(vars.get("age")),)}。
最后,一个至关重要的建议:始终在低并发(如1-5个线程)下,使用“查看结果树”监听器,完整地调试一遍你的参数化脚本。确保每个请求的参数都按预期被替换,关联也正确无误。调试成功后,再禁用或移除耗资源的监听器,进行高并发压测。磨刀不误砍柴工,清晰的参数化逻辑是获得有效压测结果的前提。