1. 性能测试到底在测什么:先别急着点“开始”
很多朋友拿到JMeter的第一反应是:把接口地址填进去,线程数改成100,点一下绿色启动按钮,然后盯着聚合报告看数字。这事儿我干过,说实话,要是测的是个纯静态页面或者一个只读的查询接口,这套流程也许能给你一点“我在做性能测试”的错觉。但一旦你面对的是真实业务系统——有登录态、有验证码、有分布式锁、有数据库连接池、有第三方依赖——上面那套操作只会得出一个毫无价值的结论:系统在你造出来的假流量下表现还不错,仅此而已。
先聊聊一个很容易被忽略的大背景。2021年发布的GB/T 39788-2021《系统与软件工程 性能测试方法》,把性能测试的定义、流程、方法和结果分析都做了标准化梳理。这个标准不是让你背条文,而是给了你一套做事的框架:从性能需求分析、测试设计、测试执行到结果评估,每一步都有对应的输入和输出。实际项目中,我见过太多团队把性能测试等同于“压一下看QPS”,完全不关心测试目标是什么、业务模型是什么、结果怎么跟需求对应上。举个例子,你测一个秒杀接口,目标是“单机支撑2000 QPS,99%响应时间不超过500ms”,但你的压测脚本里压根没模拟用户领取优惠券的前置动作,也没有准备不同档位的商品库存,那你跑出来的2000 QPS放在生产环境里可能直接就把库存服务打挂了。GB/T 39788里强调的“基于业务模型的测试设计”,说的就是这层意思。
本文要讲的JMeter使用示例,不是把官方文档给你翻译一遍,而是以我实际做过的几个压测项目为蓝本,把从一个空白的JMeter开始,到跑出一个能指导上线决策的压测报告,这中间涉及的安装、脚本设计、参数化、断言、文件上传、HTTPS、证书、数据库压测、小程序压测等场景,逐个拆开讲清楚。内容偏实操,适合三类人看:一是刚接触性能测试、想建立正确认知的测试工程师;二是被临时抓去压测的开发同学,你不需要成为全栈性能专家,但需要知道脚本怎么写才能不被开发怼“你压的数据不对”;三是想系统梳理JMeter使用细节、想把踩过的坑记录下来备查的同行。
先说一个贯穿全文的原则:JMeter是工具,不是目的。你的价值在于设计出一个足够贴近真实业务场景的压测方案,并让JMeter忠实地执行它。所以后面所有的示例,我都会把重点放在“为什么这么设计”上,而不是给你一串可以直接复制的配置截图。
2. 安装之前,你需要知道的几件事
2.1 JDK版本不是越新越好
JMeter是Java写的,所以电脑上必须先有JDK。这里有个特别常见的坑:很多教程告诉你“安装JDK 8”,但也没解释为什么。我的经验是,这个选择跟JMeter的版本有直接关系。目前主流的JMeter 5.x版本,官方要求Java 8以上,但实际上用Java 8跑是最稳的,原因有两点:一是JMeter本身基于Java 8特性编译,兼容性最好;二是很多企业级插件和扩展库(比如自定义Java请求、某些数据库驱动)对高版本JDK的支持反而不如JDK 8完善。
我实测过在JDK 11和JDK 17下跑JMeter 5.6.3,基础功能没问题,但一旦用到Beanshell脚本或者某些旧版插件,就可能报一些诡异的ClassNotFound或者模块访问错误。所以如果你不需要用最新的语法特性,老老实实装JDK 8,能省掉一大半环境问题。
有个细节值得注意:Windows下安装JDK后,一定要检查JAVA_HOME环境变量是否指向了正确的目录。很多人装了JDK但环境变量没配好,启动JMeter时报“Unable to detect Java”或者双击bat文件一闪而过。配置方式是:右键“我的电脑”->“属性”->“高级系统设置”->“环境变量”,新增JAVA_HOME指向JDK安装目录(比如C:\Program Files\Java\jdk1.8.0_202),然后在Path里追加%JAVA_HOME%\bin。改完后重新打开命令行执行java -version,能正常输出版本号才算完事。
2.2 下载安装与目录结构
JMeter的官网地址是jmeter.apache.org,下载页面提供两个包:apache-jmeter-xxx.zip(Windows、macOS、Linux通用)和对应的tgz包。Windows用户下载zip解压即可,macOS用户也一样。Linux用户除了手动解压,还可以用包管理器直接装:Ubuntu/Debian执行sudo apt install jmeter,CentOS/RHEL系列的话,默认源里不一定有,建议直接用官方zip包解压到/opt目录,然后做个软链接到/usr/bin/jmeter。
解压后的目录结构里,你只需要重点关注四个地方:
bin/:启动脚本所在目录。Windows双击jmeter.bat,macOS/Linux执行./jmeter。同目录下的jmeter.properties是核心配置文件,后面讲中文乱码的问题要改这里。lib/:扩展JAR包目录。你下载的JDBC驱动、Kafka客户端、自定义插件都要扔到这里,重启JMeter才能生效。ext/:位于lib/ext下,专门放JMeter插件(比如后面会用到的jmeter-plugins系列)。logs/:运行日志目录。脚本跑挂了,先来这里看jmeter.log,里面往往会写清楚报错原因。
启动时有个小经验:如果你的脚本要跑很久,建议用命令行模式而不是GUI模式。GUI模式消耗资源大,而且官方明确说了GUI仅用于调试脚本,压测时要加-n参数走命令行。比如在Linux服务器上执行:
jmeter -n -t /path/to/script.jmx -l /path/to/result.jtl -e -o /path/to/html-report这条命令的含义是:非GUI模式运行脚本script.jmx,把原始结果写到result.jtl,并在最后生成一份HTML格式的可视化报告到html-report目录。跑压测用这种方式,完事直接打开HTML报告看聚合指标,比在GUI里盯表格高效得多。
3. 第一个压测脚本是怎么搭起来的
3.1 线程组、Sampler、监听器的分工
JMeter脚本的骨架是测试计划,计划下面挂着线程组、配置元件、Sampler、监听器这些零件。第一次接触的人很容易被这些术语搞晕,我用大白话解释一下:
- 线程组(Thread Group):模拟一批并发用户。你设置的线程数就是“同时有多少人在操作”,Ramp-Up Period是“这批人是在多长时间内陆续到达”,循环次数是“每个人连续操作几轮”。
- Sampler:具体发什么请求。HTTP请求就是访问一个URL,JDBC请求就是执行一条SQL,后面要讲的文件上传也是Sampler的活儿。
- 配置元件(Config Element):给Sampler提供公共参数。比如HTTP请求默认值(统一协议、域名、端口)、用户定义的变量、CSV数据文件。
- 监听器(Listener):收集和展示结果。查看结果树、聚合报告、图形结果这些都用它,但压测时常开监听器会拖慢性能,正式压测时建议只保存结果数据,跑完了再离线分析。
- 断言(Assertion):检查返回结果是否符合预期。响应断言、JSON断言、BeanShell断言都属于这类。
一个最简单也最完整的压测脚本,至少包含:线程组 + HTTP请求 Sampler + 查看结果树(调试阶段)/ 简单数据写入器(压测阶段)。
3.2 线程组参数怎么设:不是越大越好
线程组的参数设置,直接决定你压出来的数据有没有意义。我见过有人把线程数设成1万、Loop Count设成1万,结果JMeter自己先崩了,这测的不是系统,是JMeter的极限。
正确的做法是,线程数要从业务模型推导,而不是拍脑袋。假设你的系统日活是10万人,晚高峰8点到9点这一个小时里有80%的请求集中到达,平均每秒大约有220个请求(10万×80%÷3600秒),考虑峰值因子再乘2到3倍,那你的压测目标大概就是每秒500到700的并发请求量。这种情况下,如果单请求平均响应时间是200ms,那么线程组里“在线并发用户数”大约是500乘以0.2秒等于100个并发线程。注意,“并发线程数”不是“每秒请求数”,两者相差一个响应时间的倍数,这是很多人经常搞混的点。
Ramp-Up Period的设置也有讲究。默认给1秒,意思是所有线程瞬间全部启动,这会产生一个突发尖峰,一般设为线程数除以每秒启动数。比如100个线程、希望每秒启动20个,那Ramp-Up就填5秒,让流量平滑爬坡,这样既能观察系统在逐步加压下的表现,也不会因为瞬间启动太多线程导致JMeter自身资源耗尽。
3.3 参数化:让每个用户的数据都不一样
真实场景下,每个用户请求的参数往往是不同的。如果你100个线程全都发同样的请求体,那测出来的结果只能代表“同一份数据被并发读取”的场景,不具备代表性。JMeter里做参数化最常用的有两种方式:一是用CSV Data Set Config从文件里读取数据,二是用__Random函数生成随机值。
CSV文件的用法是:创建一个文本文件,每行放一组参数,第一列对应一个变量名,第二列对应另一个变量名,以此类推。然后在线程组下添加“CSV Data Set Config”,填上文件路径、变量名称(用英文逗号分隔)、是否允许重复数据等字段。配置好之后,在HTTP请求的“参数”表单里直接写${username}、${password},运行时JMeter会自动从文件里读一行数据填充进去。
有一点要特别注意:CSV文件的编码。Windows下用记事本保存的CSV默认是GBK编码,如果文件里有中文参数,JMeter读取后会出现乱码。解决办法是用VS Code或Notepad++打开文件,另存为UTF-8 with BOM编码。但是“UTF-8 with BOM”在某些旧版JMeter中会导致第一行列名被读成乱码,所以更稳妥的做法是保存为纯UTF-8无BOM,并在CSV Data Set Config里手动指定file_encoding="UTF-8"。
使用CSV参数化有一个好处是可以配合线程数×循环次数精确控制数据的使用方式。比如你准备了1000行用户数据,线程组设置100线程、每个线程循环10次,那正好用完1000行数据,每个请求用的数据都不重复,这在压测登录接口时就特别有用——避免了用同一账号反复登录被限流的尴尬。
4. 断言与Beanshell:不只是看响应代码
4.1 响应断言和JSON断言的区别
性能测试的核心指标虽然是响应时间和吞吐量,但前提是这些响应都是“正确的成功响应”。如果你的脚本把服务器返回的500错误也当作成功的200来统计,那聚合报告再好看也是自欺欺人。所以断言是每个压测脚本都不能少的。
最简单的断言是“响应断言”,通过匹配响应文本中的关键词来判断请求是否成功。做法是:在Sampler下添加“响应断言”,在“测试模式”里勾选“包括”,然后填写你要检查的字符串。比如登录接口成功后一定会返回"success":true,就把这个字符串写进断言里。如果响应里包含这个字符串,断言通过,否则这个请求就会被标记为失败。
接口返回的是JSON格式时,用JSON断言更精准。它用JSONPath表达式定位到具体字段,然后校验字段值。比如:
$.code == 0这表示JSON路径code字段的值等于0才通过。相比响应断言全文匹配,JSON断言可以精确到字段,不会因为响应里某个无关字符串恰好包含关键字而产生误判。
4.2 BeanShell断言:当标准断言不够用的时候
标准的断言元件只能做简单的匹配和条件判断,遇到复杂逻辑就捉襟见肘了。举个例子:接口返回一个加密的token,你要校验token的格式是否符合某些规则(比如长度固定、特定字符开头),或者你要把响应里的某个值取出来,跟数据库里的值做对比,这时候就要写脚本。
Beanshell是一种轻量级的Java脚本语言,JMeter内置了对它的支持。你可以通过“BeanShell断言”编写自定义逻辑,用prev对象获取上一个Sampler的响应结果。常用的写法是:
import org.apache.jmeter.assertions.AssertionResult; String response = prev.getResponseDataAsString(); if (response == null || !response.contains("expected_token")) { Failure = true; FailureMessage = "响应中没有找到预期的 token 字段"; }这里的prev是JMeter内置变量,代表前一次采样结果。Failure和FailureMessage是两个特殊变量,设置后就能让断言失败并输出自定义的失败消息。实际项目中我用BeanShell断言做过的事情包括:校验响应JSON中的金额字段与请求参数中的金额是否一致、检查响应耗时是否超过某个阈值并自动标记失败、从响应中提取A接口的token作为B接口的请求头。
需要注意,Beanshell脚本是解释执行的,性能开销比较大。压测线程数一高,BeanShell会消耗大量CPU。所以一般只在功能验证阶段用BeanShell断言,正式压测阶段尽量换成JMeter的“JSR223断言”配合Groovy脚本,Groovy的执行性能远优于Beanshell。
4.3 怎么把响应数据提取出来给下一个接口用
压测一个完整的业务流程,往往需要从前一个接口的响应中取数据,拼到下一个接口的请求里。比如先登录拿到token,再带着token去查询订单。JMeter里做这件事靠的是“后置处理器”里的“正则表达式提取器”或者“JSON Extractor”。
JSON Extractor的使用更简单直观。假设登录接口返回:
{"data": {"token": "abc123xyz", "userId": 9527}}在登录请求下添加“JSON Extractor”,填写:
- 变量名:
loginToken - JSONPath表达式:
$.data.token - 默认值:
NOT_FOUND
运行后,${loginToken}这个变量就会被替换成响应里的abc123xyz。在后续的HTTP请求里,通过“HTTP Header Manager”添加请求头,值填${loginToken}即可。这个串联式的写法,是JMeter模拟真实业务流程的基础,也是性能测试从“压单接口”走向“压全链路”的关键一步。
5. 常见场景实战:上传、鉴权、HTTPS、数据库
5.1 文件上传和中文文件名乱码
用JMeter做文件上传接口压测,核心点在“Multipart/form-data”的设置和文件名的编码处理。HTTP请求的“Method”选POST,“参数”页签里勾选“使用multipart/form-data”,然后在“文件上传”区域添加文件路径和参数名。
这里有一个我踩过的深坑:当上传的文件名包含中文时,服务端收到的文件名往往显示成乱码。之前排查过这个问题,最终定位到是JMeter在构造multipart请求时,文件名默认使用ISO-8859-1编码。解决办法是在jmeter.properties里加一行配置:
sampleresult.default.encoding=UTF-8同时把JMeter的HTTP Request设置里的“Content encoding”也指定为UTF-8。如果仍然不行,就要修改启动脚本里的JVM参数,在jmeter.bat(或者jmeter脚本)的JVM_ARGS中加入-Dfile.encoding=UTF-8。这三处都改完之后,重启JMeter再试,文件名通常就正常了。
5.2 登录鉴权:从Cookie到Token
很多业务系统的接口需要带登录态才能访问。常见的方案有两种:Cookie模式(Session)和Token模式(通常是JWT)。JMeter处理这两类鉴权的方式不太一样。
Cookie模式最简单:先写一个“登录”HTTP请求,请求成功后会在“HTTP Cookie管理器”(线程组下添加)里自动保存服务端返回的Cookie,后续同线程组的请求会自动携带这个Cookie。注意,HTTP Cookie管理器要放在线程组级别,并且它的“Cookie策略”默认选择standard即可覆盖大多数场景。
Token模式需要手动提取。前面提到的JSON Extractors提取token之后,要通过HTTP Header Manager在每个后续请求里加上Authorization: Bearer ${token}。这里有个细节:不同用户的token要隔离,所以线程组里每个线程都要执行一次登录接口获取自己的token,不能把第一个线程的token共享给所有线程。
5.3 录制HTTPS脚本与安全证书
JMeter录制脚本功能适合快速生成一个基本流程,但涉及HTTPS时会遇到证书校验的问题。官方推荐的做法是把JMeter的证书导入系统信任库,这样JMeter作为代理才能解密HTTPS流量。具体步骤是:
- 在JMeter的
bin目录下找到ApacheJMeterTemporaryRootCA.crt(旧版本是JMeterProxyCA之类的名字)。 - 双击证书文件,选择“安装证书”,在向导中选择“本地计算机” -> “将所有证书放入下列存储” -> “浏览”选择“受信任的根证书颁发机构”。
- 配置JMeter的HTTP代理服务器(在“测试计划”上右键添加“非测试元件”->“HTTP代理服务器”),端口设一个比如8888,浏览器代理指向
localhost:8888,然后访问目标页面,JMeter就能录制到HTTPS请求了。
录制完成后要做的第一件事是“清理”。录制出来的脚本通常包含静态资源的请求(图片、CSS、JS),这些在压测时一般要过滤掉,否则会严重消耗JMeter自身的资源,影响压测数据的准确性。过滤方式是在HTTP代理服务器的“排除”列表里添加.*\.(js|css|png|jpg|gif|ico)(\?.*)?$这样的正则表达式。
5.4 微信小程序性能测试的常见思路
小程序压测和普通Web压测最大的区别在于:小程序的前端逻辑跑在微信客户端里,很多接口有独立的调用链和参数签名规则。做小程序性能测试前,首先要获取小程序的真实接口数据。常见做法是使用微信开发者工具或Charles之类的抓包工具,把小程序发起的请求抓下来,提取出接口地址、请求头和请求体模板。
拿到接口后,仍然套用JMeter的普通HTTP请求来压。但要注意两个小程序的特殊点:第一,小程序接口通常有sign签名参数,签名生成逻辑一般在前端代码里,你需要用BeanShell或者Groovy脚本重新计算签名,否则压测请求会被服务端拒绝;第二,很多小程序接口依赖微信的wx.login流程拿code换sessionKey,这个code是动态的,压测时要提前准备好一批有效session,或者和服务端约定压测环境关闭签名校验。
5.5 数据库压测脚本:JDBC请求的配置
对数据库直接压测的场景,比如排查慢SQL、评估某个存储节点的瓶颈,用JMeter的“JDBC Connection Configuration”和“JDBC Request”组合可以非常高效地完成。
JDBC Connection Configuration里要填的关键配置:
- JDBC Driver Class:MySQL填
com.mysql.cj.jdbc.Driver(新驱动类名,旧版是com.mysql.jdbc.Driver),Oracle填oracle.jdbc.driver.OracleDriver。 - Database URL:MySQL格式是
jdbc:mysql://localhost:3306/testdb?useSSL=false&serverTimezone=Asia/Shanghai。 - Username / Password:数据库账号密码。
- 其他参数:连接池的最大连接数
Max Number of Connections要小于数据库实例的最大连接数,否则压测时会先出现连接池等待超时。
JDBC Request的SQL语句可以直接写原生SQL。如果想做参数化,SQL里用?占位符,然后在“Parameter types”里填写参数值,比如VARCHAR、INT,并在“Parameter values”里用${变量名}引用。这里有个容易踩的坑:参数类型和值顺序必须和SQL里的占位符一一对应,否则会报“Data truncation”或者“Parameter index out of range”。
数据库压测的结果需要通过“Response Time Graph”或“Throughput”监听器来看,但更重要的是关注数据库实例的CPU、IO、连接数监控,不能只看JMeter这边的时间曲线。因为数据库性能下降时,JMeter的响应时间会变长,但具体瓶颈是CPU还是磁盘,得回到数据库侧排查。
6. 录制脚本和上传文件之外的那些“小毛病”排查
6.1 报错“could not delete existing file”是怎么回事
有段时间我经常在Windows上遇到JMeter报错:Could not delete existing file C:\Windows\System32\...。这个错误多半不是系统文件的问题,而是JMeter尝试把结果写入某个被占用的路径导致的。最常见的原因是运行日志目录和结果文件路径设置重复了。
解决办法是:检查测试计划里“结果文件”的名称路径,把result.jtl这类结果文件放到独立目录,别放在JMeter的bin或System32下。另外,如果之前有一个JMeter实例还在运行,新的实例试图写同一个jtl文件也会报这个错,关掉旧进程重试即可。
6.2 响应全是乱码:编码问题的三层处理
在国内的项目里,中文乱码是JMeter使用中最高频的报错之一。它出现在两个环节:一是请求参数的乱码,二是响应数据的乱码。
请求参数乱码,参考前面CSV文件的解决办法,保证数据文件是UTF-8编码,并且在“HTTP Request”的“Content encoding”填UTF-8。如果是POST请求,注意Body Data和Parameters两种传参方式的编码行为有差异,建议请求体统一用Body Data传JSON,并在Content encoding里指定UTF-8。
响应乱码多在“查看结果树”里显示为���之类的奇怪字符。原因是JMeter默认按ISO-8859-1解析响应字节流,改法是:在jmeter.properties里,把sampleresult.default.encoding从默认的空值改成UTF-8,重启JMeter。如果接口返回的是GBK编码,就改成GBK。改完后再看响应,中文就正常了。
6.3 压测结果波动太大:先排查自己,再排查系统
如果你跑出来的聚合报告里响应时间忽高忽低、吞吐量不稳定,第一步要排查的不是系统,而是JMeter所在的压测机。常见的问题包括:
- 线程数开太高,JMeter自身GC频繁,导致采样结果不准。
- 压测机上还跑着其他程序,占用了CPU和内存。
- 监听器开得太多,比如同时开了“查看结果树”和“图形结果”,这两个都是重量级监听器,极耗资源。
正确的做法是:压测时用命令行模式运行,不带任何监听器,只通过Simple Data Writer保存原始结果到jtl文件;压测结束后,再用监听器打开jtl文件分析。另外把JMeter的堆内存调大一些,编辑jmeter脚本(或jmeter.bat),找到HEAP="-Xms1g -Xmx1g -XX:MaxMetaspaceSize=256m"这类配置,把-Xmx调到2G或4G。这个调整对压测机的性能稳定性非常有效。
7. 一个完整的压测流程串起来看
为了让你对以上内容有个整体的感知,我描述一个实际做过的压测流程。目标系统是电商平台的订单查询接口,要求压测环境支撑500 TPS、99分位响应时间不超过300ms。
第一步,分析业务模型和接口逻辑。订单查询需要登录态,需要从登录接口获取token,订单列表接口分页参数每页20条,查询条件有订单状态选项。我准备了一个CSV文件,包含500个测试账号和对应的userId。
第二步,搭建脚本。线程组设为5个线程、Ramp-Up 30秒、循环次数根据目标TPS动态调整。测试计划里加HTTP Cookie管理器(处理登录后的session)、用户自定义变量。登录请求用CSV参数化,后接JSON Extractor提取token,订单查询请求的Headers里带上Authorization: Bearer ${token},并在响应断言里校验$.code == 0和$.data.orderList[0].orderId非空。
第三步,先小规模调试。用GUI模式、线程数设为1,查看结果树确认请求都返回200且断言通过。再逐步加大到10并发,观察有没有参数取值冲突、token提取失败的情况。这一步发现问题要及时修,否则后面大规模跑的时候排查起来特别痛苦。
第四步,命令行压测。把脚本传到压测机(8核16G内存),执行:
jmeter -n -t order_query.jmx -l result.jtl -e -o report压测时长设定为15分钟,前面5分钟作为预热期,数据单独看后10分钟的稳定段。跑完后用聚合报告插件打开result.jtl,看TPS、平均响应时间、99%响应时间、错误率。如果TPS达不到500,就要逐步调大线程数,观察是响应时间变长导致TPS上不去,还是错误率开始上升,进而判断瓶颈在Web层还是DB层。
第五步,结果分析。最终跑出来的数据如果满足“500 TPS、99%响应时间300ms”,测试结论就是合格的;如果不满足,就定位瓶颈,调优后重新压测。这里有一个必须强调的实践:每次压测前后都要记录压测机的系统资源,因为压测机本身资源不足会让结果完全失真。比如8核的压测机开500线程,光JMeter自己就能吃掉300%的CPU,这时候你测出来的数据只能说明压测机扛不住了。
最后再分享一个我自己的习惯
每做完一次压测,我都会把脚本导出的jmx文件和对应的CSV数据文件、压测记录一起归档进项目的test/performance目录,命名为v1.0_20240115_订单查询_500TPS这种格式。别小看这个习惯,半年后这个接口的业务模型变了,你再做回归压测时,直接从归档里翻出旧脚本,对照参数把新数据换进去就能跑,省去重新搭脚本的大把时间。而且归档里顺带写上当时的压测结论和系统配置(版本、JVM参数、压测机规格),后续排查生产性能问题时,这些历史数据能帮你快速判断哪些指标是“和之前一样”的,哪些是“这次新出现的”。工具会一直变,但这个习惯,是让我每次做性能测试都能越来越快的真正原因。