年前接到一个活动项目的压测任务,场景很典型:一万名用户同时去请求两个活动接口,而且这两个接口还是串联的——第二个接口必须用到第一个接口的返回结果。这类场景在互联网业务里太常见了,要么是"参与活动领奖→用奖品兑换权益",要么是"下单→支付",接口之间有先后依赖关系,压测时如果只测单个接口,根本没法反映真实业务链路的承载能力。
我做压测这些年,最深的体会是:技术难度往往不在工具操作上,而在场景建模和问题定位上。一万并发看着只是在线程组里填个10000,但真要跑起来,压测机本身、网络连接、服务端资源、接口关联、数据准备,任何一个环节掉链子,结果都没法看。这篇就把这次压测的完整思路、配置步骤、关联实现和排查过程捋一遍,给后续做类似活动接口压测的朋友一个可以直接抄的作业。
1. 压测前期:先把业务逻辑和场景模型理清楚
1.1 一个典型的活动业务串联场景拆解
这次要压的活动是典型的"两步走":用户可以参与活动,系统返回一个参与记录编号和对应的奖品兑换码;紧接着用户拿着这个记录编号和兑换码去进行奖品核销。两个接口分别对应两次HTTP请求,第二个请求的入参完全依赖第一个请求的响应数据。
我一般接到这种有串联关系的压测需求,第一步不是打开JMeter,而是先和开发把接口文档对清楚:
- 第一个接口返回的JSON里,哪个字段是第二个接口需要的?这个字段在响应体的什么路径下?
- 第二个接口需要的是query参数、header还是request body?这个决定了提取后的变量在什么地方引用。
- 两个接口之间是否有超时限制?比如用户必须在几秒内完成兑换,这个会影响压测时两个请求之间要不要加固定延时。
这次的情况是,第一个接口返回类似这样:
{ "code": 0, "msg": "success", "data": { "recordId": "R20240501001", "prizeCode": "PRIZE888" } }第二个接口在请求体里需要传recordId和prizeCode这两个字段。那整个压测链路就非常清晰了:第一个请求发出后,从响应里抠出这两个字段的值,作为第二个请求的入参。这就是JMeter里常说的"接口关联",实现方式不外乎JSON提取器、正则表达式提取器,复杂一点的会用BeanShell或JSR223脚本,但能简单就简单,后面细说。
1.2 一万并发该怎么理解(不是简单填个10000)
很多人一看到"一万名用户同时请求",脑子里的第一反应就是线程数填10000,然后把循环次数设为1,点下启动就完事了。这其实是新手最容易踩的坑。
首先要搞清楚业务上的"同时请求"到底是什么概念。用户不会真的在同一微秒内全部点按钮,而是有一个到达过程。所以压测时我们要模拟的不是瞬间到达,而是某个时间段内的稳态并发——即系统在持续承受一万在线用户不断发起请求的压力。JMeter里控制这个行为的关键参数,一个是线程数,另一个是Ramp-Up Period(启动延迟时间)。
Ramp-Up Period指的是所有线程在多长时间内全部启动完毕。如果设置60秒,那一万线程就是大约每秒启动167个线程(10000÷60)。这样做的好处是给服务端一个"预热"的过程,避免一开机就一万请求砸过去直接把服务打崩,根本测不出真实的瓶颈拐点。但如果业务场景就是要模拟极端瞬间冲击(比如整点秒杀),那Ramp-Up可以缩短到几秒,甚至填0表示立即并发,不过这种情况下压测机自身的调度压力也最大。
我的建议是:初次压测用阶梯加压,不要一上来就拉满一万。先500、1000、3000、5000、8000、10000这样分阶段跑,每个阶段观察响应时间和错误率的变化,找到性能拐点。这比一次性跑完拿到一份"全红"的报告要有意义得多。
1.3 压力模型与数据准备
一万个用户同时跑,每个用户如果都用相同的数据请求,很容易被服务端的缓存或者幂等校验干扰。比如第一万个用户请求参与的接口,结果发现和前一个用户拿到的是同一个活动名额,那接口内部可能直接返回"已被领取",错误率就会虚高。
所以在压测之前,必须把测试数据准备好。这次活动接口,用户维度上最核心的是用户ID和手机号。我让开发和测试环境导出了一万个真实脱敏用户数据,做成了CSV文件,每行一个用户,大概长这样:
uid,phone U10001,13800000001 U10002,13800000002 ...在JMeter里用CSV Data Set Config参数化,让每个线程从文件里按顺序或随机取一条数据。这样做的好处是每个虚拟用户都有独立身份,更贴近真实场景,也能避免因为数据重复造成的业务逻辑误判。
这里有个细节要注意,CSV Data Set Config放在线程组下、HTTP请求之前,默认是每个线程取一行。如果想要随机分配用户,可以把"共享模式"改成"每次取样"或者用随机顺序。但如果是严格模拟"一个用户一次请求",顺序取更合理,因为后面还要统计每个用户的操作成功率。
2. JMeter核心配置:线程组、默认请求与连接参数
2.1 线程组这样配置才不会把压测机打垮
JMeter的每个线程在操作系统层面就是一个真正的线程,一万个线程意味着你要开出一万条并发执行流。这对压测机本身的CPU和内存是有硬性要求的,不是随便一台电脑就能扛住的。
我在压测前第一件事是调大JMeter的JVM堆内存。默认的JMeter启动脚本给的内存很小,跑几百线程还行,跑一万线程分分钟OutOfMemoryError。修改方式很简单,找到JMeter安装目录下的bin/jmeter.bat(Windows)或者jmeter(Linux),编辑文件里的堆内存参数:
# Windows: jmeter.bat set HEAP=-Xms6g -Xmx8g -XX:MaxMetaspaceSize=2g # Linux: 通过环境变量覆盖 export HEAP="-Xms6g -Xmx8g -XX:MaxMetaspaceSize=2g"这里注意,-Xms和-Xmx最好是同一个值,一次性把内存申请到位,避免运行过程中频繁扩容触发GC停顿。如果压测机内存不太够,至少保证4G起步,因为除了线程栈空间,JMeter还需要保存每个请求的响应数据(如果开启了结果树监听的话)。
线程组的配置这次我用的参数是:
- 线程数:10000
- Ramp-Up Period:120秒(每秒约83个线程启动)
- 循环次数:1次
- 调度器:不勾选,压测时长通过实际脚本运行控制
循环次数我设置为1,原因是这次关注的是一万用户的完整操作成功率,而不是单个用户反复刷接口。如果要测长时间稳定性,可以把循环次数设大或者勾选"永远",配合调度器的持续时间使用。
跑一万线程时,压测机在非GUI模式下大约要消耗8G左右内存,CPU也会跑到多核满载。所以强烈建议用命令行模式执行压测,不要在GUI界面里挂着跑大并发,GUI模式本身就要消耗大量资源,还会因为界面刷新影响压测数据的稳定性。我是这样执行的:
jmeter -n -t activity_pressure_test.jmx -l result.jtl -e -o report/-n表示非GUI模式,-t指定脚本,-l输出原始结果文件,-e和-o是生成HTML报告用的,压测完直接就能看到完整的图表分析。
2.2 HTTP请求默认值和超时参数
一万并发下,HTTP请求的配置越统一越好管理。我习惯在测试计划下先添加一个"HTTP请求默认值"(HTTP Request Defaults),把公共信息一次性配好:
- 协议:http
- 服务器名称或IP:被测环境地址
- 端口号:8080
- 内容编码:UTF-8
- 连接超时:3000毫秒
- 响应超时:10000毫秒
超时参数一定要配,而且不能配得太大。我之前见过有同事把响应超时设为0(表示无限等待),结果服务端出现局部故障时线程全部挂在等待上,后续请求越积越多,最后把压测机自己搞崩了。设置合理的超时能让线程快速释放,比如连接超时3秒、响应超时10秒,一旦服务端处理不过来,请求就会以超时错误的形式记录下来,错误率指标才能反映真实情况。
同时,在请求里需要加的公共头也通过"HTTP信息头管理器"统一设置,比如Content-Type: application/json、Authorization等。这里有一个很容易被忽略的点:如果活动接口需要登录态,JMeter怎么处理?一般有两种方案,一种是在压测前先跑一次登录接口,用正则或JSON提取器拿到token,再加到后面的请求头里;另一种是让开发在测试环境统一放开鉴权,或者提供一个万能测试token。我这次用的是第二种,开发给了一个测试环境的固定token,省去了登录步骤,压力更聚焦在活动接口自身。
2.3 阶梯加压:从1000慢慢爬到10000
直接在线程组里写死10000线程,虽然也能跑,但问题是一旦服务端在3000并发时就扛不住了,你得到的结果只有一堆超时,至于瓶颈到底在哪、什么时候开始恶化,完全看不出来。所以我日常压测更喜欢用阶梯加压,这次也不例外。
如果用JMeter自带的线程组,想实现阶梯效果有两个办法:一是做多个线程组,分别设置1000、3000、5000、8000、10000,然后用"启动时间"错开;二是直接装一个jmeter-plugins-manager,通过它安装Custom Thread Groups插件,里面有现成的"Stepping Thread Group"(逐步加压线程组)和"Ultimate Thread Group"(终极线程组)。
Ultimate Thread Group的配置方式很直观,可以一行一行地定义线程池,每一行都包含启动时间、线程数、持续时间、停机时间。我这次的配置大致是:
| 线程数 | 延迟启动 | 持续时间 | 停机时间 |
|---|---|---|---|
| 1000 | 0秒 | 60秒 | 10秒 |
| 3000 | 60秒 | 60秒 | 10秒 |
| 5000 | 120秒 | 60秒 | 10秒 |
| 8000 | 180秒 | 60秒 | 10秒 |
| 10000 | 240秒 | 120秒 | 20秒 |
这样跑下来的结果里,每个阶段的错误率和响应时间都分布在不同的时间窗口,对照聚合报告可以明显看到从5000并发开始,平均响应时间开始拉高,到8000时错误率上升,到10000时出现超时,瓶颈就一目了然了。
3. 接口关联实现:把第一个接口的返回值交给第二个接口
3.1 JSON提取器和正则提取器的选型
这是串联接口压测的核心环节。在JMeter里做接口关联,最常用的两种后置处理器就是"JSON提取器"(JSON Extractor)和"正则表达式提取器"(Regular Expression Extractor)。
到底用哪个,我的选择标准很简单:接口返回的是标准JSON结构,优先用JSON提取器,因为JSONPath的语义清晰、容错性好;如果接口返回的是非标准结构(比如夹杂着各种描述文本的HTML、或者JSON嵌在字符串里),或者响应体太大、结构不固定,那就退回用正则表达式提取器。
这次两个接口返回的都是标准JSON,我直接用的JSON提取器,配置简单,可读性也强。但正则提取器我也一并讲了,因为实际工作中总会碰到一些返回结构比较奇葩的接口,到时候就知道多会一种方案有多省事了。
3.2 具体配置步骤(含表达式写法)
在第一个HTTP请求(参与活动接口)上右键添加后置处理器,选择JSON提取器。这里我给两个字段各建一个JSON提取器,也可以在一个提取器里一次提取多个变量,后者更高效。
单个字段的提取:
- 变量名称:recordId
- JSONPath表达式:$.data.recordId
- 匹配编号:1
- 默认值:NOTFOUND
再添加一个JSON提取器提取prizeCode,配置同上,JSONPath表达式改成$.data.prizeCode。
如果嫌多个提取器麻烦,JMeter也可以在一个JSON提取器里同时提取多个变量。在变量名称和JSONPath表达式两栏里用分号分隔,比如:
变量名称:recordId;prizeCode JSONPath表达式:$.data.recordId;$.data.prizeCode这样一次请求就能同时拿到两个变量。注意两边的数量必须一一对应,写错了提取就会失败。我把"默认值"统一设置成一个容易发现的字符串,比如NOTFOUND,这样一旦提取失败,第二个接口的请求体里会出现明显的NOTFOUND字样,从请求日志或者结果树里一眼就能看到关联有没有生效。
如果是用正则表达式提取器,配置方式是这样的:
- 变量名称:recordId
- 正则表达式:
"recordId":\s*"([^"]+)" - 模板:$1$
- 匹配编号:1
- 默认值:NOTFOUND
这里的正则含义是匹配"recordId":后面紧跟的引号字符串,括号里的部分就是我们要抓取的内容。需要注意正则里的转义,JMeter对双引号和反斜杠的处理有时候会让人头大,测试完多看一眼提取结果总没错。
3.3 关联的调试与验证
配置完提取器,先不要急着跑一万并发。我会加一个"调试取样器"(Debug Sampler)放在第二个请求之前,然后先跑1个线程,打开"查看结果树"观察提取到的变量值。
Debug Sampler会用JSR223脚本的形式把当前线程上下文里的变量全部打印出来,如果你看到这样的输出:
recordId=R20240501001 prizeCode=PRIZE888说明关联成功,这时候再把Debug Sampler禁用掉,正式跑压测。注意Debug Sampler在正式压测时一定要禁用或删除,因为每跑一次线程它都要把所有变量打一遍,大量IO操作会把压测机的性能拖垮,影响结果准确性。
第二个接口的请求体里直接引用变量就行,请求体大概是这样的:
{ "uid": "${uid}", "recordId": "${recordId}", "prizeCode": "${prizeCode}" }引用变量就是${变量名}这种格式,JMeter会在运行时自动替换成实际值。
这里有一个细节很关键:提取器必须挂在第一个HTTP请求下面,作为它的子节点,而且在取样器顺序上要排在第二个HTTP请求之前。JMeter线程组里是顺序执行的,第一个接口跑完,提取器立刻处理响应结果,把变量存到当前线程的变量池里,紧接着第二个接口才能拿到变量。如果你的提取器放错位置,第二个接口拿到的永远是NOTFOUND,因为线程跑到第二个接口的时候,提取器压根还没执行。
4. 参数化、断言与监听器:让压测数据真实可分析
4.1 CSV参数化模拟一万用户身份
前面提到过,这次压测准备了带一万个用户信息的CSV文件。在JMeter里做参数化的标准组件是CSV Data Set Config,具体配置如下:
- 文件名:/path/to/users.csv
- 文件编码:UTF-8
- 变量名称:uid,phone
- 分隔符:,
- 是否允许带引号:False
- 遇到文件结束符:继续循环 或 停止线程
- 线程共享模式:当前线程组
"线程共享模式"这里有个坑值得说下。默认配置下CSV文件由所有线程共享读取,每个线程拿到的行是顺序错开的;如果选择"当前线程",每个线程会从头开始读文件,容易造成数据重复。我的建议是共享模式保持默认,然后在请求里只引用uid,phone用不到就不引用。不过CSV里多列数据并不会影响读取,因为JMeter是按变量名来索引的。
如果测试环境没有那么多真实用户,也可以用JSR223取样器动态生成随机数据来模拟,比如用${__Random(1,10000)}生成随机ID,或者用${__time(yyyyMMddHHmmss)}生成不重复的操作流水号。但要注意,这种方式生成的随机数据如果业务端有唯一性校验,很可能产生大量冲突,所以有真实账号池的时候优先用CSV,实在没有才用函数生成。
4.2 响应断言配置
压测时只看HTTP状态码是远远不够的。如果接口内部抛了业务异常,响应码可能还是200,但响应体里的code字段变成了非0。这时候如果没有断言,这些请求会被当成成功请求统计,错误率被严重低估,报告也就失去了参考价值。
我这次为两个接口分别添加了"响应断言"(Response Assertion),逻辑是:响应文本中包含指定业务成功标识。活动接口的成功标识是"code":0,所以断言配置为:
- 响应字段:响应文本
- 模式匹配规则:包含
- 测试模式:
"code":0
这样,只要接口返回值里包含code为0的字段,请求就算通过;如果业务失败,比如返回"活动已结束"或"库存不足",断言就会失败,错误率立刻体现出来。
还有一点建议,不要在正式压测时把"查看结果树"监听器挂在测试计划里。我在调试阶段会开着查看结果树确认关联和断言配置正确,但正式压测前一定把它禁用。原因很简单,结果树会把每个请求的完整报文写到内存里,一万并发下这个监听器自己就能把堆内存吃满,导致压测提前OOM。
4.3 监听器与指标解读
这次压测用了两种结果输出方式:一是"聚合报告"(Aggregate Report),直接看关键指标的汇总;二是生成HTML报告,方便后续和团队一起复盘。
聚合报告里我最关心的几个指标是:
- Samples:总请求数。如果线程数是10000,每个线程发两个请求,Samples应该是20000左右,如果少太多说明有请求没发出去或者被压测机卡住了。
- Error%:错误率。这里需要看是业务报错还是连接超时,结合断言结果判断。
- Average:平均响应时间。活动接口一般要求P95小于1秒,平均响应时间不能定得太死,因为不同接口逻辑复杂程度不同。
- Throughput:吞吐量,单位是请求数/秒,代表系统每秒能处理多少请求。
不过聚合报告也有个短板:它不直接给TP99这种分位数。如果想知道99%的请求在多少毫秒内完成,需要用"汇总报告"的插件版本比如"p99/p90"列,或者直接在HTML报告中查看响应时间百分位图。这次跑完我导出的HTML报告里能直接看到中位数、90%响应时间、99%响应时间,活动场景用户体感更接近这种分位数指标,而不是平均响应时间。
配套地,我还会在压测过程中同步监控服务端的CPU、内存、数据库连接池和慢SQL情况,因为接口响应变慢往往不是接口本身的问题,而是下游的数据库或者Redis扛不住了。只有把压测端的TPS指标和服务端的系统指标放在一起看,才能锁定真正的瓶颈。
5. 在压测现场:常遇问题与排查实录
5.1 压测机自身瓶颈排查
一万线程不是所有机器都能扛的。我第一次跑的时候,压测机是一台4核8G的云主机,结果跑到6000线程左右的时候,JMeter报了OutOfMemoryError,压测被迫中断。后来我把堆内存调大,再观察压测机的CPU,发现已经打满了,线程上下文切换严重,客户端本身成了瓶颈。
这个问题的解决方案是上JMeter分布式压测,也就是大家常说的agent模式。一台主控机(Controller)负责调度脚本,多台从机(Agent)负责执行实际压力。比如两台从机,每台跑5000线程,总压力还是10000,但每台机器的负担直接减半。
分布式压测的配置不算复杂,从机上启动jmeter-server,主控机的jmeter.properties里配置remote_hosts,把从机IP和端口(默认1099)加进去,然后脚本通过"远程启动"选项分发到各从机执行。但有几个细节必须注意:
- CSV文件里的用户数据,每台从机都要有一份,且路径一致,否则从机读不到文件会直接报错。
- 从机的系统时间尽量同步,否则时序数据会乱。
- 主控机不产生压力,只做调度和结果汇聚,所以主控机的配置可以比从机低一些,但别低太多。
如果实在没有多台机器做分布式,也可以试着用JMeter的线程组配置做单机极限压测,但要做好心理准备,结果可能受限于客户端资源。我的判断标准是:单机压到CPU超过80%,测出来的数据就不太可信了,优先考虑扩展从机。
5.2 服务端性能瓶颈与连接数问题
去掉压测机自身问题后,最常遇到的另一类问题是服务端连接耗尽。有一次压测过程中,服务端的Tomcat直接拒绝连接,错误日志里大把的"Connection refused"。排查了一下,Tomcat默认的maxThreads是200,数据库连接池默认配置也不大,一万并发远超它本身能承受的连接数上限。
这种问题从压测的角度不能算是测试脚本的问题,但对压测执行者来说,必须能判断出来并推动服务端调优。常见的调优方向包括:
- 调整Web容器线程池大小:比如Tomcat的maxThreads调整为2000,并同步调整acceptCount。
- 调整数据库连接池:Druid或HikariCP的最大连接数要匹配实际并发,避免连接池排队。
- 开启连接复用:HTTP请求头里加上Connection: keep-alive,减少TCP握手开销。
- 调整操作系统层面参数:Linux上服务端需要适当调大文件描述符,默认1024肯定不够,至少要设到65535,压测机上也要避免TIME_WAIT端口堆积。
说到TIME_WAIT,压测机上如果大量出现"java.io.ioexception: error writing to server"这种报错,基本上就是TCP连接建立不过去了。排查方法是压测中在压测机上执行netstat -ant | grep TIME_WAIT | wc -l,如果数量达到几万,说明本地临时端口号已经排光了。Linux系统可以这样调整:
# 调整临时端口范围 sysctl -w net.ipv4.ip_local_port_range="1024 65535" # 开启连接复用 sysctl -w net.ipv4.tcp_tw_reuse=1注意这些是压测机上的优化,生产服务器或者被测环境不建议随意开启tw_reuse,有可能引入连接数据混乱的问题。
5.3 关联失效的几类原因
接口关联这块最容易出的问题有三类,这里整理成一个速查表方便排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 第二个接口请求体里全是NOTFOUND | JSON提取器的JSONPath表达式写错 | 先用单线程+查看结果树观察提取结果 |
| 变量没提取到但正则表达式看着没问题 | 响应体过大,正则匹配到第一个就停了 | 检查匹配编号和贪婪匹配写法 |
| 一部分用户成功、一部分失败 | 业务数据差异导致响应结构不同 | 打开结果树对比成功和失败响应体 |
| 断言报错但接口返回正常 | 响应文本里字符串格式不一致,比如code和0之间有空格 | 用"包含"模式而非"等于",放宽模式匹配规则 |
| 分布式从机上关联全部失败 | CSV文件路径或编码不一致 | 确认每台从机的文件实际存在且内容一致 |
还有一个非常隐蔽的问题:如果两个接口在同一个线程组里,但JMeter的循环次数大于1,提取器会反复执行并把变量覆盖。如果第一个接口的返回结果不是每次都是新数据,第二个接口用到的可能是上一轮的旧值。所以串联接口的压测场景,循环次数通常设为1,或者确保每次循环的数据互不干扰。这个踩过一次,印象特别深。
5.4 分布式压测的规划
最后说说这次一万并发在分布式压测下的分配方案。活动接口的单请求处理时间大约在几十毫秒到几百毫秒之间,单机跑一万线程至少需要16G内存和8核CPU。我这次是用3台从机来分担:每台从机分到3333个线程,主控机负责统一的脚本分发和结果汇总。
不过需要提醒的是,在分布式模式下,每个从机都在输出自己的压测结果,最后汇聚到主控端时,主控端的磁盘IO可能成为瓶颈。所以我一般让每台从机把结果写到本地,压测结束后再用脚本汇总成一份完整的聚合报告,而不是全部实时传回主控机。小技巧,但在大规模压测时很好用。
从性能分析的角度,这次压测结束后我拿到了几个关键数据:系统的最大TPS大概是多少、在哪个并发量级开始劣化、错误率是否有突刺、两个接口之间的吞吐量差异有多大。这些数据最终汇成一份压测报告,给团队和运维做容量评估参考,后面加机器扩节点也有数据支撑。
小结之外的几句实操体会
这类串联接口的高并发压测,我做了不止一次,最想提醒后来人的是三件事:第一,关联提取器一定要先小规模调通,不要拿一万线程去赌提取表达式有没有写对;第二,压测机资源要提前评估,内存、端口、文件描述符这些基础环境配置比测试脚本更早暴露问题;第三,任何一次压测都要保留原始结果文件,别压完就删,后面复盘和对比调优全靠它。
最后再分享一个细节:跑完压测后不要只盯着平均值和错误率,多看一眼响应时间的90%、99%分位,活动接口的用户体感往往取决于最差的那一批请求能不能扛住。把P99压下去,比单纯把平均值做低,对业务的意义要大得多。