一、平台背景
为什么要设计和实现一个云压测平台,而不是使用现有开源压测工具?
-
平台化:企业需要一个平台化的压测工具,每个团队都可以在这个平台上协作,而开源工具大多是 C/S 类型( 客户端 / 服务器体系结构),缺乏平台化支持。
-
标准化: 企业需要一个统一的标准化压测平台,最好能够和公司的其他平台集成,而开源工具在这方面的扩展性一般不强。
-
控制成本: 企业需要控制压测平台的维护成本,对于规模大的公司,自研优于使用开源。虽然开源压测工具由社区维护,但反馈较慢,自己维护的成本又比较高,不如重写一个或者二次开发。
二、压测工具对比
2.1 开源工具
Apache JMeter
Apache JMeter是Apache组织开发的基于Java的压力测试工具。Apache JMeter具备如下特性:
-
支持分布式施压。
-
支持图形化界面,且支持流程编排,同时支持断言、逻辑控制器等高级指令,可满足复杂业务压测需求。
-
扩展性强,开发、测试人员可通过编写自己的插件,满足各种压测需求。
-
技术生态好,有强大的开源社区支持,开发者活跃度高。
-
支持查看资源监控、性能报告,但可查看的监控和报告指标较少。
-
基于并发模型,受限于JVM,单机无法支持超高并发。且只支持并发施压模型,不支持吞吐量施压模型。
-
开源支持的分布式能力无法大规模应用到生产环境,部署成本高。
-
不支持测试用例管理、压测脚本管理等功能
ApacheBench
ApacheBench(ab)是一款针对HTTP协议做性能压测的命令行工具。ApacheBench具备如下特性:
-
具有较好的扩展性。
-
支持协议单一。对HTTP协议支持度较好,不支持主流的HTTPS、WebSocket等协议。
-
支持请求总数、并发数、压测时长控制。
-
作为一款命令行压测工具,上手较为简单。
-
单机压测工具,无分布式施压能力,无可视化界面。
-
不支持链路编排、场景管理等功能,无法做带业务含义的复杂压测。
-
单次压测,只能对单个域名或地址发起流量请求。
-
压测统计指标维度少,缺少压测过程中的统计数据,无法获取系统负载等指标
wrk
wrk是一款针对HTTP协议的基准测试工具。wrk具备如下特性:
-
轻量级性能测试工具,安装简单。
-
学习成本低。
-
基于异步事件驱动框架,单机支持并发高。
-
单机压测工具,无分布式施压能力。
-
只支持HTTP协议。
-
无可视化界面,不支持流程编排、断言等能力,无法满足复杂压测需求。
2.2 能力对比
对比项
|
|
商用PTS
|
Jmeter
|
AB
|
wrk
|
成本
|
学习成本
|
低
|
中
|
低
|
低
|
是否收费
|
是
|
开源免费
|
开源免费
|
开源免费
|
|
分布式能力
|
支持分布式
|
是
|
是,有性能缺陷
|
否
|
否
|
引擎能力
|
支持多协议
|
是
|
是
|
否
|
否
|
施压量级
|
高
|
中
|
低
|
低
|
|
压测场景
|
是否支持流程编排
|
是
|
是
|
否
|
否
|
支持断言/出参提取
|
是
|
是
|
否
|
否
|
|
数据构造
|
支持文件数据源
|
是
|
是
|
否
|
否
|
支持使用函数生成
|
是
|
是
|
否
|
否
|
|
压力控制
|
支持并发模式
|
是
|
是
|
是
|
是
|
支持阶梯递增
|
是
|
是
|
否
|
否
|
|
支持吞吐量模式
|
是
|
否
|
否
|
否
|
|
压测过程手动调速
|
是
|
否
|
否
|
否
|
|
数据可视化
|
支持实时数据监控
|
支持
|
支持,维度有限
|
不支持
|
不支持
|
支持压测报告
|
支持
|
支持,报告简单
|
支持,报告简单
|
支持,报告简单
|
|
支持施压机系统监控数据
|
支持
|
不支持
|
不支持
|
不支持
|
2.3 引擎选型
综上比较,JMeter无论是功能完整度,社区成熟度还是受众方面都明显优于其他开源工具,并且JMeter作为顶级开源项目,其插件丰富,二次开发成本较低,故使用JMeter作为压测平台底层测试引擎。
三、Jmeter概览
3.1 Jmeter介绍
启动方式
Jmeter压测启动方式主要有下面两种:
-
脚本命令执行
# 平台根据前端的操作,自动拼接出一行可执行的命令,然后在指定服务器上执行这段脚本。
# 相当于是手工敲的命令平台帮着拼接和回车执行了。
# 即便是前端来生成测试脚本,也可以先保存成 jmx 文件,再脚本执行。
# 特点是平台和 Jmeter_Home 完全分离,带来的:
# 1. 平台代码可以不用 Java 写了,什么语言写都可以,仅仅是拼装命令。
# 2. 毕竟是脚本执行,Jmeter 随意切换版本。
# 3. 平台和 Jmeter 可以不部署在同一台服务器上,即不是相同的进程内了。
# 4. Jmeter 挂掉不影响平台运行。
jmeter -n -t create.jmx -l test.jtl
- Jmeter的API调用执行,平台代码需要使用Java编写,调用较简单
// 基于jmx文件创建并启动引擎 HashTree tree= SaveService.loadTree(jmxFile); StandardJMeterEngine engine = new StandardJMeterEngine(); engine.configure(tree); engine.run();
平台这两种都支持:
-
对于接口调试或者场景调试这种临时一次性的操作,通过Jmeter的API调用执行。
-
对应启动场景这种长时间压测任务,通过在容器Pod里面启动脚本命令执行。
核心组件
JMeter 有多种组件,我们重点看下这七类: 配置元件、取样器、定时器、前置处理器、后置处理器、断言、监听器。我们来看下它们各自的作用。
-
配置元件:用于初始化变量,以便采样器使用。类似于框架的配置文件,参数化需要的配置都在配置元件中。
-
前置处理器:在进行取样器请求之前执行一些操作,比如生成入参数据。
-
定时器:一般用来指定请求发送的延时策略。在没有定时器的情况下,JMeter 发送请求是不会暂停的。
-
取样器:承担 JMeter 发送请求的核心功能,支持多种请求类型,如 HTTP、FTP、JDBC 等,也可以使用 Java 类型的请求进行自定义编写。
-
后置处理器:在取样器请求完成后执行一些操作,通常用于处理响应数据,从中提取需要的值。
-
断言:主要用于判断取样器请求或对应的响应是否返回了期望的结果。
-
监听器:监听器可以在 JMeter 执行测试的过程中搜集相关的数据,然后将这些数据在 JMeter 界面上以树、图、报告等形式呈现出来。不过图形化的呈现非常消耗客户端性能,在正式性能测试中并不推荐使用。
二次开发
-
自定义Sample
自定义一个JavaSampler需实现下面接口方法即可,其他组件类似实现对应接口方法
public interface JavaSamplerClient {
/**
* 初始化
*/
void setupTest(JavaSamplerContext context);
/**
* 具体逻辑实现
*/
SampleResult runTest(JavaSamplerContext context);
/**
* 收尾工作
*/
void teardownTest(JavaSamplerContext context);
/**
* 在Jmeter中显示的属性
*/
Arguments getDefaultParameters();
}
3.2 Jmeter不足
不足
-
不支持压测场景管理/复杂的压力控制
-
不支持弹性压测和报告实时查看
-
原生分布式集群压测有性能瓶颈
-
分布式执行和单机执行方式的差异较大,需要做很多额外配置,数据文件需要手动上传
-
master 节点通常不参与压测,而是收集 slave 节点的压测信息,这会造成一定程度上的资源浪费
-
slave 节点会将每个请求打点都实时回传给 master 节点,造成大量的带宽消耗和单点瓶颈
扩展
针对上面这些不足,我们可以考虑在平台层来实现这些功能。针对分布式压测我们可以在每台压测机中启动一个压测引擎(基于Jmeter),它能够与压测平台服务器建立通信,这样平台就可以直接对压测机进行调度,相当于我们在平台层重新实现分布式调度功能。
分布式压测
|
Jmeter
|
压测平台实现
|
压测脚本分发
|
master节点进行分发
|
平台服务统一分发
|
数据文件分发
|
用户自行上传到每台压测机
|
通过对象存储统一获取,根据压测机数量切分文件
|
结果回传
|
各slave节点实时回传到master节点
|
异步回传到平台,统一聚合和入库
|
四、平台设计和实现
4.1 平台方案
状态机

状态机是分布式压测的核心,控制中心和各个压测引擎的行为都受状态机变化的影响。
-
创建任务(JobExecution)并开始执行以后,各个任务分片(JobSliceExecution)首先会进入 preparing 状态,各个压测引擎会从云存储下载各自对应的脚本数据,下载完成后作为压测引擎的数据输入。如果下载失败则会重试,即 Prepare。
-
如果所有压测引擎都成功下载了脚本数据,则相继进入 prepared 状态,等全部进入 prepared 状态后,JobExecution 也会进入 prepared 状态,并向各个压测引擎发起执行指令,各个 JobSliceExecution 进入 running 状态,等所有压测引擎执行完成且各个 JobSliceExecution 变成 completed 状态之后,JobExecution 也会进入 completed 状态,此时压测任务执行完成并生成压测报告。
-
如果各个任务分片在 preparing、prepared 或 running 过程中有任何一个出错,则出错的分片会进入 failed 状态并通知控制中心,控制中心则控制其他分片中止正在执行的任务并进入 Stopping 状态,等这些分片中止成功并都变成 stopped 状态后,JobExecution 会被置成 failed 状态。
-
支持手动停止压测任务,这时候 JobSliceExecution 和 JobExecution 都会被置成 stopping->stopped 状态。
脚本生成
我们可以知道需要构建的 jmx 结构,最外层是 TestPlan,TestPlan 是 HashTree 结构,包含 ThreadGroup(线程组)、HTTPSamplerProxy、ResultCollector(结果收集)等节点。
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.5">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="测试计划" enabled="true">
</TestPlan>
<hashTree>
<ResultCollector guiclass="ViewResultsFullVisualizer" testclass="ResultCollector" testname="查看结果树" enabled="true">
</ResultCollector>
<hashTree/>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="线程组" enabled="true">
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="HTTP请求" enabled="true">
</HTTPSamplerProxy>
<hashTree/>
</hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="线程组" enabled="true">
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="HTTP请求" enabled="true">
</HTTPSamplerProxy>
<hashTree/>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
Jmeter的API
通过Jmeter的API将页面多个协议转换为JMX脚本
//创建执行计划
TestPlan testPlan = new TestPlan("创建 JMeter 测试脚本");
testPlan.setProperty(TestElement.TEST_CLASS, TestPlan.class.getName());
testPlan.setProperty(TestElement.GUI_CLASS, TestPlanGui.class.getName());
testPlan.setUserDefinedVariables((Arguments) new ArgumentsPanel().createTestElement());
//创建 ThreadGroup
ThreadGroup threadGroup = new ThreadGroup();
threadGroup.setName("Example Thread Group");
threadGroup.setNumThreads(1);
threadGroup.setRampUp(1);
threadGroup.setProperty(TestElement.TEST_CLASS, ThreadGroup.class.getName());
threadGroup.setProperty(TestElement.GUI_CLASS, ThreadGroupGui.class.getName());
//创建 Sampler
HTTPSamplerProxy httpSamplerProxy = new HTTPSamplerProxy();
httpSamplerProxy.setDomain("127.0.0.1:8080/index");
httpSamplerProxy.setPort(80);
httpSamplerProxy.setPath("/");
httpSamplerProxy.setMethod("GET");
httpSamplerProxy.setName("Open ip");
httpSamplerProxy.setProperty(TestElement.TEST_CLASS, HTTPSamplerProxy.class.getName());
httpSamplerProxy.setProperty(TestElement.GUI_CLASS, HttpTestSampleGui.class.getName());
//创建结果收集器
ResultCollector resultCollector = new ResultCollector();
resultCollector.setName(ResultCollector.class.getName());
//构建 HashTree
HashTree subTree = new HashTree();
subTree.add(httpSamplerProxy);
subTree.add(loopController);
subTree.add(threadGroup);
subTree.add(resultCollector);
HashTree tree = new HashTree();
tree.add(testPlan,subTree);
XML工具包
通过dom4j递归扫描Jmx脚本元素进行处理
SAXReader reader = getSAXReader();
// 将jmx输入流转换为Document
Document document = reader.read(source);
Element jmeterTestPlan = document.getRootElement();
List<Element> childNodes = jmeterTestPlan.elements();
for (Element ele : childNodes) {
parseHashTree(ele);
}
启动压测
弹性压测
用户进行压测时需根据压测参数动态部署压测节点,在压测完成后自动回收压测节点资源。
以每个虚机4C8G设置固定并发(需根据实际测试换算)来换算虚机数量,每个虚机里运行压测引擎作为一个节点。这样有两个好处:
-
节点易管理:节点统一资源规格,节点调度分配计算实现较简单。
-
节点易扩展:可通过虚机数量控制并发数,从而提升压测上限。
压力控制
JMeter 提供了吞吐量控制器的插件,我们可以通过设定吞吐量上限来限制 QPS/TPS,能够确保将吞吐量控制在一个固定值上,但这样还远远不够,实际工作中我们希望在每次压测执行时能够随时动态调节吞吐量,这样的功能该如何实现呢?
BeanShell 解释器有一个非常有用的特性——它可以充当服务器,支持可以通过 telnet 或 http 访问。
使用时在 jmeter.properties 中定义以下内容:
beanshell.server.port=9000
beanshell.server.file=../extras/startup.bsh
基于吞吐量控制器,将吞吐量限制值设为占位符(如 ${__P(throughput, 99999999)},throughput 就是占位符),利用 JMeter 的 BeanShell 功能,通过执行外部命令的方式,在运行时注入具体值,达到动态调节吞吐量的目的。编写 update.bsh BeanShell 脚本为:
import org.apache.jmeter.util.JMeterUtils;
getprop(p){ // get a JMeter property
return JMeterUtils.getPropDefault(p,"");
}
setprop(p,v){ // set a JMeter property
print("Setting property '"+p+"' to '"+v+"'.");
JMeterUtils.getJMeterProperties().setProperty(p, v);
}
setprop("throughput", args[0]);
通过运行命令动态调整TPS
sudo java -jar /<jmeter_home>/lib/bshclient.jar localhost 9000 throughput.bsh <参数>
结果收集
BackendListener
BackendListener是一种异步监听并获取到测试结果的监听器,也就是说测试结果(如HTTP请求的响应结果)都会被封装在SampleResult对象中并被其实现类监听接收。
public interface BackendListenerClient {
//开启多线程执行压测之前,传入线程上下文,进行前置处理
void setupTest(BackendListenerContext context) throws Exception; // NOSONAR
//多线程压测过程中获取到测试结果集,传入当前方法进行处理
void handleSampleResults(List<SampleResult> sampleResults, BackendListenerContext context);
//多线程压测结束之后进行一个后置处理
void teardownTest(BackendListenerContext context) throws Exception;
}
通过实现上面 BackendListenerClient 接口来将异步获取到的测试结果SampleResult进行相应处理,然后将元数据上报至kakfa,最后压测聚合模块通过消费kafka Topic异步来收集测试结果。
报告聚合
由于压测平台自己实现了分布式压测模式,因此在拿到每台压测机的JTL结果文件后,需要自行对这些JTL文件的内容进行合并和解析,并持久化记录下来。
一个JTL文件的片段
timeStamp,elapsed,label,responseCode,responseMessage,threadName,dataType,success,failureMessage,bytes,sentBytes,grpThreads,allThreads,URL,Latency,IdleTime,Connect
1685412971451,15,1111,200,OK,测试场景1-0 1-1,text,true,,301,868,1,1,http://localhost:40050/grpc-gw-push/pushSync,15,0,0
1685412971821,14,1111,200,OK,测试场景1-0 1-1,text,true,,301,868,1,1,http://localhost:40050/grpc-gw-push/pushSync,14,0,0
1685412972017,16,1111,200,OK,测试场景1-0 1-1,text,true,,301,868,1,1,http://localhost:40050/grpc-gw-push/pushSync,15,0,0
JTL 文件对应每一行的单条结果数据的大小很小(大约只有 100 多个字节),但总量很大,如果我们只是简单的将所有数据存储起来,将会占用大量的存储空间,因此结果数据需要做预聚合再存入。
通常以label(JTL 中的 label)作大分类,维度(errorMsg、errorCode 等)作小分类,以时间作为聚合标准,聚合时间随压测时长调整,从而保证存储大小不会过大,又可以汇总比较丰富的数据。