JMeter 进阶实战——CSV 数据驱动、关联提取、参数化、分布式压测
JMeter 进阶实战——CSV 数据驱动、关联提取、参数化、分布式压测
适读人群:有JMeter基础的测试工程师、后端开发 | 阅读时长:约15分钟 | 核心价值:掌握JMeter进阶技巧,实现真正意义上的场景模拟和大规模分布式压测
线上事故推着我学会了分布式压测
2020年底,公司要求对整个支付链路做一次容量评估,目标是验证系统能否支撑10000 TPS的支付请求。
我信心满满地配置了1000线程的JMeter脚本,在一台16核压测机上跑起来。结果才跑了两分钟,压测机CPU就打满了,JMeter进程开始GC,采样数据开始丢失,TPS数据一直在抖动。
最可笑的是,那台被压测的支付服务,CPU只有30%,完全没跑满。瓶颈在压测机自己身上。
如果就这样汇报"TPS达到2000,系统存在性能瓶颈",那完全是错的结论。
后来我学会了分布式压测:用一台Controller控制10台Agent,每台跑100线程,合计1000线程。压测流量均匀分布,Controller只负责汇总数据,压测机自身不成为瓶颈。
那次最终测出了支付链路的真实上限:8600 TPS时P99是380ms,9000 TPS时P99开始发散到1200ms,找到了明确的拐点。
CSV 数据驱动深入
CSV Data Set Config是JMeter参数化的核心,但有几个细节不搞清楚容易出问题。
线程共享模式
这是最容易踩坑的地方:
All threads(所有线程共享) 所有线程按顺序从CSV中取数据,不重复。适合"每次请求用不同数据"的场景,比如批量注册用户。问题是如果CSV行数 < 请求总数,后面的请求会从头循环,可能造成重复数据。
Current thread group(每组独立) 同一个线程组内所有线程共享一个CSV读取指针,不同线程组各自独立。适合多线程组的场景,比如一个线程组压登录,另一个压下单,两者用不同CSV。
Current thread(每个线程独立) 每个线程有自己的CSV读取指针,从第一行开始读,循环读取。适合"每个虚拟用户有固定身份"的场景,比如模拟100个固定用户循环操作。
生成大量测试数据
手动准备10万条测试数据太麻烦,用Python脚本批量生成:
import csv
import random
import string
import hashlib
def generate_test_users(count=100000, output_file='users.csv'):
"""生成测试用户数据CSV"""
with open(output_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['user_id', 'username', 'password', 'token'])
for i in range(1, count + 1):
user_id = 100000 + i
username = f'testuser_{i:06d}'
password = 'Test@1234'
# 模拟token(实际压测时需要先调登录接口获取真实token)
token = hashlib.md5(f'{user_id}_salt_2024'.encode()).hexdigest()
writer.writerow([user_id, username, password, token])
print(f'Generated {count} test users to {output_file}')
def generate_product_data(count=10000, output_file='products.csv'):
"""生成商品数据CSV"""
categories = ['electronics', 'clothing', 'food', 'books', 'sports']
with open(output_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['product_id', 'category', 'price'])
for i in range(1, count + 1):
product_id = 200000 + i
category = random.choice(categories)
price = round(random.uniform(9.9, 999.9), 2)
writer.writerow([product_id, category, price])
print(f'Generated {count} products to {output_file}')
if __name__ == '__main__':
generate_test_users(100000)
generate_product_data(10000)运行后得到100万行的users.csv,保证压测期间每个请求用不同用户ID,不会产生缓存热点。
关联提取——多接口场景的核心
实际业务场景不是单接口,而是一串有状态的操作。以电商下单流程为例:
1. 登录 → 返回 accessToken
2. 查询商品详情 → 返回 productId, skuId
3. 加入购物车 → 返回 cartItemId
4. 创建订单 → 携带 cartItemId, 返回 orderId
5. 支付订单 → 携带 orderId每一步的输出是下一步的输入,这就是"关联"。
实现登录-下单关联场景
Step 1: 登录接口
URL: POST /api/auth/login
Body: {"username":"${username}","password":"${password}"}
Response:
{
"code": 200,
"data": {
"accessToken": "eyJhbGci...",
"userId": 10001,
"expireAt": 1735200000
}
}
↓ JSON Extractor提取token
变量名: accessToken
JSONPath: $.data.accessToken
Step 2: 创建订单接口(使用上一步提取的token)
URL: POST /api/order/create
Header: Authorization: Bearer ${accessToken}
Body: {"userId":"${userId}","productId":"${__Random(1001,5000,)}"}关键:JSON Extractor的作用域
JSON Extractor要放在登录Sampler的子节点下,不能放在同级。这样它只对登录的响应生效,提取出的变量${accessToken}在当前线程的后续请求中都可以使用。
正则表达式提取复杂响应
当响应不是标准JSON,或者需要提取嵌套的动态数据:
// 响应是HTML页面,从中提取隐藏的csrf token
<input type="hidden" name="_csrf" value="a8f9b2c3-4d5e-6f7a-8b9c"/>
Regular Expression Extractor:
变量名: csrfToken
正则: name="_csrf" value="([^"]+)"
模板: $1$
匹配数字: 1
默认值: CSRF_NOT_FOUNDJMeter Functions 内置函数
JMeter提供了丰富的内置函数,在参数化中非常有用:
${__Random(1,1000,)} - 生成1到1000的随机整数
${__RandomString(8,abcdefg,)} - 生成8位随机字符串
${__UUID()} - 生成UUID,适合requestId
${__time()} - 当前时间戳(毫秒)
${__dateTimeConvert(${__time()},,,yyyyMMddHHmmss,)} - 格式化时间
${__counter(TRUE,)} - 递增计数器
${__property(threads,100)} - 读取属性值(命令行可覆盖)
${__eval(${someVar})} - 对变量再次求值(二次解析)一个实际用法:生成不重复的订单号
订单号: ORDER_${__time()}_${__counter(TRUE,)}_${__threadNum()}__time()毫秒级时间戳 + __counter()线程内递增 + __threadNum()线程ID,三者组合基本不会重复。
BeanShell / JSR223 Sampler
当内置功能不够用时,用BeanShell(或更推荐的Groovy)写自定义逻辑:
// JSR223 PreProcessor - Groovy脚本
// 生成HMAC-SHA256签名(某些接口需要请求签名)
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64
def appSecret = "your-app-secret"
def timestamp = System.currentTimeMillis().toString()
def nonce = UUID.randomUUID().toString().replace("-", "")
def userId = vars.get("userId")
// 待签名字符串
def signStr = "appId=testApp&nonce=${nonce}×tamp=${timestamp}&userId=${userId}"
// 计算HMAC-SHA256
Mac mac = Mac.getInstance("HmacSHA256")
SecretKeySpec secretKey = new SecretKeySpec(appSecret.bytes, "HmacSHA256")
mac.init(secretKey)
byte[] signBytes = mac.doFinal(signStr.bytes)
String sign = Base64.getEncoder().encodeToString(signBytes)
// 写入变量供后续请求使用
vars.put("timestamp", timestamp)
vars.put("nonce", nonce)
vars.put("sign", sign)
log.info("Generated sign: " + sign)然后在HTTP Header里用${timestamp}, ${nonce}, ${sign}引用。
分布式压测完整方案
当单机无法产生足够压力时,需要分布式压测。
架构说明
[Controller(1台)] ——————→ [Agent-1(压测机)]
——————→ [Agent-2(压测机)]
——————→ [Agent-3(压测机)]
↓↓↓
[被压测的目标服务]Controller:接收用户指令,分发测试计划,汇总结果,不直接发压测流量 Agent(remote server):真正发送压测请求,汇报结果给Controller
配置步骤
在所有Agent机器上:
# 1. 安装JMeter(版本必须与Controller一致!这点很关键)
# 2. 修改 jmeter.properties
server.rmi.ssl.disable=true # 简化SSL配置(内网环境)
server.rmi.localport=4000 # Agent RMI端口
client.rmi.localport=0 # 0表示随机端口
# 3. 启动JMeter Server
./jmeter-server -Djava.rmi.server.hostname=<agent机器IP>在Controller机器上:
# 修改 jmeter.properties
remote_hosts=10.0.1.101:1099,10.0.1.102:1099,10.0.1.103:1099
# 运行分布式压测
jmeter -n \
-t /scripts/order_test.jmx \
-r \ # -r 表示使用所有remote hosts
-l /results/result.jtl \
-e -o /report/
# 只用部分Agent
jmeter -n -t order_test.jmx \
-R 10.0.1.101:1099,10.0.1.102:1099 \
-l result.jtl分布式压测注意事项
数据文件同步
CSV数据文件必须在每台Agent上都存放相同路径。用rsync或Ansible批量同步:
# 把users.csv同步到所有Agent
for agent in 10.0.1.101 10.0.1.102 10.0.1.103; do
rsync -avz /data/users.csv root@${agent}:/data/users.csv
done而且要注意:如果用All threads共享模式,每台Agent机器从CSV的开头读,3台Agent会读同样的数据。应该给每台Agent分配不同的数据文件,或者用Current thread模式让每台独立读。
汇总计算
3台Agent各100线程,总并发是300,TPS是三台之和。Controller的Aggregate Report已经做了汇总,直接看Controller上的报告即可。
踩坑实录
坑1:版本不一致导致分布式压测失败
现象: Controller能连上Agent,但发测试计划后Agent返回IncompatibleClassChangeError,压测无法启动。
原因: Controller用的JMeter 5.5,Agent用的JMeter 5.3。两个版本的序列化协议有变化,导致无法反序列化测试计划。
解法: 所有机器必须用完全相同的JMeter版本,包括插件也要一致。建议用脚本统一部署:
JMETER_VERSION=5.6.3
wget https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz
tar -xzf apache-jmeter-${JMETER_VERSION}.tgz -C /opt/坑2:CSV数据不够用导致后期测试数据重复
现象: 压测跑到第20分钟时,DB监控发现某几个user_id的操作次数远超其他,已经触发了重复下单的业务校验,错误率从0.1%爬到了3%。
原因: CSV只准备了5000条数据,200个线程跑20分钟每线程循环1200次,总请求240000次,但CSV才5000行,每行被重复用了48次。
解法: 按公式提前计算需要的数据量:所需数据量 = 线程数 × 每线程每分钟请求数 × 测试时长(分钟) × 安全倍数(1.5)。做30分钟200线程测试,每线程每分钟约200次,需要200 × 200 × 30 × 1.5 = 180万条数据。宁多勿少。
坑3:分布式压测时各Agent压力不均匀
现象: 三台Agent压测,Agent1的CPU是85%,Agent2是30%,Agent3是25%。总TPS比预期低很多。
原因: Controller是按Agent启动顺序分配请求的,某些情况下前面的Agent分到的任务更多。另一个原因是各Agent与目标服务的网络延迟不同。
解法: 确保所有Agent到目标服务的网络延迟相近(都在同一个机房或VPC里)。同时检查Agent的JVM参数是否一致,避免某台Agent因为内存不足而GC频繁。
总结
JMeter进阶的核心是三个方向:数据驱动(CSV + 足量随机数据)、关联提取(JSON Extractor + 变量传递)、分布式压测(Controller + Agent架构)。
掌握这三点,你就能模拟出接近真实的生产场景,压测数据才有参考价值。
下一篇聊JMeter与CI/CD集成,实现自动化性能回归,让每次发版前都自动跑一遍压测。
