作者简介:
魏占飞,来自货拉拉/技术中心/质量保障部,负责货拉拉性能测试领域的质量保障和效能建设工作。
李鹏飞,来自货拉拉/技术中心/质量保障部,负责货拉拉货运性能压测保障和效能建设工作。
一、背景与挑战
在过去的几年里,货拉拉的用户和货运订单数量都实现了快速增长,系统稳定性的保障愈发重要,性能测试及全链路压测的测试频率加快,主流性能测试工具均为本地单机软件,存在性能测试过程的效率、协作、数据保存等问题,需要一个高性能的性能测试平台来提供相关能力。
对于平台实现,需要满足以下主要的要求。
分布式压测机: 需要避免压测机的Master/Slave模式,以便保持实现压测机平等以实现快速动态扩容及减少通信压力。
提高压测效率: 提供包含了脚本维护,办公协作,场景化压测相关流程,通过资源、脚本、场景的权限内共享,实现及时、高效的全链路压测。
保障平台性能: 压测所产生的大量数据,需要及时收集、分析并展示到性能平台上,因此,需要保障压测性能相关性能,保障系统能实时高效地展现性能数据。
二、方案与目标
在构思初始方案时,我们在行业内寻找并参考了相关方案设计,在结合对比开源性、工具资料、上手难度后,选择了基于开源的Jmeter的做为压测引擎进行二次开发,结合性能测试的相关实践操作,整体设计框架如下图如示。
三、能力建设
压测平台整体能力建设内容较多,我们将以重点功能组件为例,以时间顺利列出相关的思考及相关改进思路。
功能模块规划图
代码模块结构图
csharp
├── Dockerfile
├── README.md
├── pom.xml
├── qapt-agent //压测机服务
├── qapt-agent-base //压测机基础类
├── qapt-agent-plugin //压测机&jmeter插件
├── qapt-api //管理平台api
├── qapt-collector //数据收集器
├── qapt-hsmart //脚本处理
├── qapt-monitor //数据监控
└── springboot-module //基础模块
├── pom.xml
├── springboot-base-boot //项目基础&公用类
├── springboot-base-msg //消息管理
├── springboot-mongodb-auth //权限管理
├── springboot-mongodb-file //文件管理
└── springboot-mongodb-quartz //定时任务
3.1 公共模块
基础公共模块是提取比较公共的功能来形成的独立模块,其功能职责比较独立且公用比较多,主要分为基础&公共类及消息管理、权限管理、文件管理及定时任务管理几个主要模块,以基础模块为例需要对Spring的项目进行统一返回处理、统一日志、统一异常处理等操作。
3.1.1 Jmeter配置元件
Jmeter为本地软件,如果想放在Spring容器中启动,需要配置相关配置文件放到指定路径公位置并配置Jmeter本地配置文件的路径配置。
Java
@Configuration
@Slf4j
public class JmeterFileConfig {
static {
String jmeterHome = FilesUtil.getJarPath(SpringbootMongodbFileApplication.class);
log.info("================Jmeter Info====================,JmeterHome:{}", jmeterHome);
//主要解决编译调试时与部署时的路径和目录结构不一致的问题
if (jmeterHome.endsWith("lib")) {
jmeterHome = FilesUtil.getParentPath(jmeterHome);
} else if (jmeterHome.endsWith("qapt-api")) {
jmeterHome = jmeterHome + "/src/main/resources";
}
String jmeterProperties = jmeterHome + File.separator + "bin" + File.separator + "jmeter.properties";
log.info("================Jmeter Info====================,JmeterHome:{}", jmeterHome);
JMeterUtils.setJMeterHome(jmeterHome);
JMeterUtils.getProperties(jmeterProperties);
//设置本地脚本路径
String localScriptHome = jmeterHome + File.separator + "scripts";
JMeterUtils.setProperty("LocalScriptHome", localScriptHome);
//配置Jmeter变量
JMeterUtils.setProperty("search_paths", jmeterHome + File.separator + "lib");
JMeterUtils.setProperty("jmeter.home", jmeterHome);
FilesUtil.addPath(JMeterUtils.getProperty("search_paths"));
//日志
log.info("================Jmeter Bean Started=====================");
}
}
3.1.2 统一日志处理
对日志进行统一过滤、加追踪信息、公共打印、监听分析错误日志等功能,以下为监听Jmeter运行日志、并把相关错误信息回传到相关报告的日志队列实现示例代码。
Java
/*
日志监听队列,注册日志监听指定文件,并发送日志消息。
*/
@Component
public class LoggerDisruptorQueue {
private static final Pattern LOG_PATTERN = Pattern.compile("^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+([\w\.]+):\s+(.*)$");
private static RingBuffer<LoggerEvent> ringBuffer;
@Autowired
LoggerDisruptorQueue(LoggerEventHandler eventHandler) {
ThreadFactory threadFactory = Executors.defaultThreadFactory();
LoggerEventFactory factory = new LoggerEventFactory();
int bufferSize = 2 * 1024;
Disruptor<LoggerEvent> disruptor = new Disruptor<>(factory, bufferSize, threadFactory);
disruptor.handleEventsWith(eventHandler);
ringBuffer = disruptor.getRingBuffer();
disruptor.start();
}
public static void publishEvent(LoggerMessage log) {
long sequence = ringBuffer.next();
try {
LoggerEvent event = ringBuffer.get(sequence); // Get the entry in the Disruptor
event.setLog(log);
} finally {
ringBuffer.publish(sequence);
}
}
public static void publishEvent(String reportId, String logLine) {
Matcher matcher = LOG_PATTERN.matcher(logLine);
if (matcher.matches()) {
String timestamp = matcher.group(1);
String level = matcher.group(2);
String className = matcher.group(3);
String message = matcher.group(4);
LoggerMessage tempLoggerMessage = new LoggerMessage(message, timestamp, "", className, level, reportId);
long sequence = ringBuffer.next();
try {
LoggerEvent event = ringBuffer.get(sequence);
event.setLog(tempLoggerMessage);
} finally {
ringBuffer.publish(sequence);
}
}
}
}
3.2 压测机管理
压测机管理的最初实现到后期优化,是变动最大的一个模块,从最初的Web程序内运行到后期的命令行运行,从ECS部署到容器实现,都是踩的比较大的坑,分享相关有进行过模块优化的思考及思路。
3.2.1 Jmeter引擎
在最初选择如何实现Jmeter引擎方式时,有Srpingboot容器内启动和命令行启动原生Jmeter程序两种实现的选择,最初的考虑是Web容器内实现可精准控制Jmeter的并发线程,获取数据也可直接在程序内获取,当时选择了Web容器内启动。随着压测活动的增加,发现了两个比较难克服的问题,一是Jmeter在大压力的情况容易引起程序无响应,Web容器内的启动Jmeter引擎容易导致Web容器的通信也会无响应,影响压测停止的成功率; 二是Web依赖的Jar包版本与Jmeter依赖的Jar包冲突问题。后期优化的方案就是服务内启动的Jmeter对象只做脚本预处理为主,压测时用命令行方式来Jmeter。
Java
/*
原生Jmeter的启动方式
*/
@Slf4j
public class OriginalJmeterService {
private static String javaHome = SystemUtil.getJavaRuntimeInfo().getHomeDir();
private static String jmeterHome = FilesUtil.getJarPath(YiapiAgentServerApplication.class);
private static String classpath;
private static String jarPath;
private static ExecutorService executorService = ThreadUtil.newExecutor();
private ScheduledExecutorService exec = Executors.newScheduledThreadPool(1);
private ScheduledFuture<?> scheduledFuture;
private long lastTimeFileSize = 0; //上次文件大小
private static String scriptPath = jmeterHome + File.separator + "scripts";
private static Process process;
static {
//主要解决编译调试时与部署时的路径和目录结构不一致的问题
if (jmeterHome.endsWith("lib")) {
jmeterHome = FilesUtil.getParentPath(jmeterHome);
} else if (jmeterHome.endsWith("classes")) {
jmeterHome = FilesUtil.getParentPath(FilesUtil.getParentPath(jmeterHome)) + "/target/dest";
}
classpath = jmeterHome + "/jmeter/lib/*";
jarPath = jmeterHome + "/jmeter/bin/ApacheJMeter.jar";
}
public String run(String scriptPath, ScriptDTO scriptDTO) throws IOException {
String reportId = scriptDTO.getReportId();
Report report = scriptDTO.getReport();
String logPath = FilesUtil.getParentPath(scriptPath) + File.separator + "jmeter.log";
log.info("FilesUtil.getParentPath:{}", logPath);
String cmd = javaHome + "/bin/java"
+ " -Xms4G -Xmx4G"
+ " -jar " + getSystemProperties(scriptDTO) + jarPath
+ " -n -t " + scriptPath
+ " -j " + logPath;
log.info("run original jmeter and the cmd is :{}", cmd);
AgentInfo.setAgentStatus(Agent.AGENT_BUSYING);
File logFile = FileUtil.newFile(logPath);
executorService.execute(() -> process = RuntimeUtils.exec("output.log", cmd));
if (report.getType() == Report.REPORT_TYPE_DEBUG) {
if (scheduledFuture != null) {
scheduledFuture.cancel(true);
}
scheduledFuture = exec.scheduleWithFixedDelay(() -> {
//创建WatchService实例,并注册要监视的事件类型
final RandomAccessFile randomFile;
try {
randomFile = new RandomAccessFile(logFile, "rw");
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
try {
randomFile.seek(lastTimeFileSize);
String tmp;
while ((tmp = randomFile.readLine()) != null) {
String text = new String(tmp.getBytes(StandardCharsets.UTF_8));
LoggerDisruptorQueue.publishEvent(reportId, text);
}
lastTimeFileSize = randomFile.length();
} catch (IOException e) {
e.printStackTrace();
}
}, 0, 1, TimeUnit.SECONDS);
}
return executorService.toString();
}
}
3.2.2 压测机部署
压测机最初采用 ECS 集群部署,在达到约 200 台时压测机规模时,其硬件成本已经比较高了,后把压测机改造采用 Serverless Container 弹性容器实例,通过 ASK 竞价申请 POD 资源,1 分钟内可完成 500 台压测机的申请与部署,在申请后,及时进行压测任务后自动或手工释放,使压测机的硬件成本得到了大幅降低。
同时,压测机资源还会根据不同项目、不同申请人、公用、私用等维度进行管理,普通用户可使用自已申请的、当前项目拥有的及公用的压测机进行压测,使压测机资源的综合利用率更高,压测启动后会先把压测任务所需要的压测资源分发到对应权限的空闲压测机中。
3.2.3 压测文件处理
平台在执行具体的压测任务后,会拆分压测任务的场景、脚本、数据文件及依赖 JAR 包,并按照一定要求把对应的脚本和数据文件分发到对应的目标压测机,这中间涉及到任务的分发,文件的下载与预处理,同时为了避免压测机在各项目之间的文件冲突、数据冲突或 JAR 包冲突,还需要对相关文件做对应处理后放入纯净的压测空间。
Jmeter脚本预处理,以检测压测环境是否符合环境要求为例
Java
@Slf4j
@Service
public class JmeterService {
@Autowired
private JMeterEngines jMeterEngine;
@Autowired
private LoggerEventHandler loggerEventHandler;
private boolean hasEnvironmentalRisk(HashTree tree, ScriptDTO scriptDTO) {
//检查默认配置里是否有与当前环境相同
SearchByClass<ConfigTestElement> configTestElementListeners = new SearchByClass<>(ConfigTestElement.class);
tree.traverse(configTestElementListeners);
Collection<ConfigTestElement> configTestElements = configTestElementListeners.getSearchResults();
//1.检查配置元件中是否有不符合环境的请求
for (ConfigTestElement configTestElement : configTestElements) {
if (configTestElement.isEnabled()) {
String domain = configTestElement.getPropertyAsString("HTTPSampler.domain");
if (!StringUtils.isEmpty(domain)) {
String env = scriptDTO.getReport().getEnv();
if (!hasDomainContainEnv(domain, env)) {
sendStopReportErrorMsg(scriptDTO, domain, configTestElement.getName(), env);
return true;
}
}
}
}
//检查http组件是否有与当前环境相同
SearchByClass<HTTPSamplerProxy> httpSamplerProxyListeners = new SearchByClass<>(HTTPSamplerProxy.class);
tree.traverse(httpSamplerProxyListeners);
Collection<HTTPSamplerProxy> httpSamplerProxies = httpSamplerProxyListeners.getSearchResults();
for (HTTPSamplerProxy http : httpSamplerProxies) {
String domain = http.getPropertyAsString("HTTPSampler.domain");
String env = scriptDTO.getReport().getEnv();
if (!StringUtils.isEmpty(domain)) {
if (!hasDomainContainEnv(domain, env)) {
sendStopReportErrorMsg(scriptDTO, domain, http.getName(), env);
return true;
}
}
}
return false;
}
}
3.2.4 插件管理
Jmeter的插件管理需要包括了主流的协议如MQTT,gRpc的扩展测试,也有公司内部的自定义协议的支持,包括GUI部分和采样器的支持,需要打好包后下载到本地支持Jmeter的脚本编写,也需要添加到压测机的依赖包进行管理。
3.3 管理后台
压测平台的主要功能和Web界面操作都是和管理后台直接进行通讯和交互,核心功能主要与压测操作业务相关,包括脚本、场景、压测机管理,其中变化较大的是与压测机的通讯方式。除了核心的压测功能,管理后台也支持如监控数据收集、脚本批量操作、自动化配置等一些效率功能。
3.3.1 脚本管理
上传jmeter脚本,在压测平台进行预处理后,可自动生成压测平台的脚本,以脚本维度进行管理与压测,通常单业务的性能测试通常以脚本为单位测试即可。
3.3.2 场景管理
在需要多个脚本配合的测试场景中,可提前组合对应的压测场景,并对压测场景的并发压力进行配置和调整,大规模测试如全链路压测需要以场景维度进行测试。
3.3.3 通信协议
在平台开发初期因主要考虑各环境的网络防火墙的策略,通讯协议采用长链接的WebSocket协议,随着公司网络策略的变化有了专用的测试区,并且随着压测规模的增大,WebSocket协议需要一个专用服务来管理Session链接,管理复杂程度增加,不利于压测平台的水平扩容,所以与压测机的通信方式增加了http通讯方式,原WebSocket只用于压测机的心跳及状态更新及调试日志的上传。
WebSocket服务端点
Java
@ServerEndpoint("/ws/{cid}/route/{rid}")
@Component
@Slf4j
@Data
public class WebSocketServer {
private static Map<String, WebSocketService> webSocketServiceMap;
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("cid") String cid, @PathParam("rid") String rid) {
WsSessionManager.add(cid, session);
log.info("有新client连接,clientId:{},routeId:{}", cid, rid);
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose(@PathParam("cid") String cid, @PathParam("rid") String rid) {
WsSessionManager.removeAndClose(cid);
WsSessionManager.removeAndClose(rid);
AgentServiceImpl agentService = SpringContextUtils.getBean(AgentServiceImpl.class);
if (!cid.startsWith(WsSessionManager.WEB_CLIENT)) {
agentService.setOffOnline(cid);
}
}
/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
*/
@OnMessage
public void onMessage(@PathParam("cid") String cid, String message) {
if (null == webSocketServiceMap) {
webSocketServiceMap = SpringContextUtils.getBeans(WebSocketService.class);
}
log.debug("session:{}, message:{}", cid, message);
WsMsg msg;
//解析消息
try {
msg = JSON.parseObject(message).toJavaObject(WsMsg.class);
String uuid = msg.getUuid();
if (WsSessionManager.futureCache.asMap().containsKey(uuid)) {
SyncFuture syncFuture = WsSessionManager.futureCache.get(uuid);
syncFuture.setResponse(message);
}
} catch (JSONException e) {
log.error(e.toString());
return;
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
//执行消息动作
try {
if (webSocketServiceMap.containsKey(msg.getAction())) {
webSocketServiceMap.get(msg.getAction()).execute(msg, cid);
} else {
log.error("this is no handler for this msg's action:{}", msg.getAction());
}
} catch (Exception e) {
log.error("this handler Exception and the msg's action:{}", msg.getAction());
e.printStackTrace();
}
}
@OnError
public void onError(@PathParam("cid") String cid, @PathParam("rid") String rid, Throwable e) {
WsSessionManager.removeAndClose(cid);
WsSessionManager.removeAndClose(rid);
log.error("webSocket client is error,cid is :{} and rid is :{},the reason is :{}", cid, rid, e.getMessage());
}
public static void sendMessage(String message, String cid) {
log.info("send msg to cid:{},and msg is {}", cid, message);
try {
WsSessionManager.get(cid).getBasicRemote().sendText(message);
} catch (IOException e) {
log.error("send msg to cid:{},but can't find the cid session,the message is:{}", cid, message);
e.printStackTrace();
}
}
public static String sendSyncMsg(WsMsg wsMsg, String cid) throws InterruptedException {
SyncFuture<String> syncFuture = new SyncFuture<>();
// 放入缓存中
String dataId = wsMsg.getUuid();
WsSessionManager.futureCache.put(dataId, syncFuture);
// 封装数据
sendMessage(JSON.toJSONString(wsMsg), cid);
// 发送同步消息
return syncFuture.get(1, TimeUnit.SECONDS);
}
/**
* 指定cid发送消息
*/
public static void sendMessageToCid(String message, String cid) {
sendMessage(message, cid);
}
/**
* 指定cids发送消息
*/
public static void sendMessageToCids(String message, ConcurrentHashSet<String> cids) {
log.debug("push message to cids:" + cids + ",推送内容:" + message);
cids.forEach(cid -> sendMessage(message, cid));
}
public static boolean checkAgentOnline(String cid) {
return WsSessionManager.existSession(cid);
}
//清理失效的CID
public static void cleanLoseContactSocket() {
WsMsg wsMsg = new WsMsg();
WsSessionManager.SESSION_POOL_AGENT.forEachKey(10, cid -> {
try {
sendSyncMsg(wsMsg, cid);
log.info("check agent:{} websocket online ", cid);
} catch (InterruptedException e) {
WsSessionManager.remove(cid);
log.warn("check agent:{} websocket lose and remove the web socket info ", cid);
e.printStackTrace();
}
});
}
}
3.4 收集器
在全链路压测进行过程中,需要实时关注压测相关指标,及时进行收集和汇总,给压测任务提供数据指标及风险参考,各压测机把自身的压测数据通过监听器实时发送到 Kafka,再由收集服务进行统一汇总,在汇总数据后进行展示或结合其它策略进一步利用。
3.4.1 压测机单机数据
如果把每一条压测数据都发送给到收集器,很容易达到到kafka的消息发送40万~100万QPS的性能瓶颈,压测机把自身单机的数据先收集汇总并每秒发送一次,可以极大减少发送消息的QPS,提高压测平台消息汇总的性能。
less
//压测机初步收集汇总本机数据,并发送到kafka
@Slf4j
public class HllBackendListenerClient extends AbstractBackendListenerClient {
@Override
public void setupTest(BackendListenerContext backendListenerContext) throws Exception {
//压测设置项
}
@Override
public void handleSampleResults(List<SampleResult> list, BackendListenerContext backendListenerContext) {
//压测数据处理并发送
}
@Override
public void teardownTest(BackendListenerContext backendListenerContext) throws Exception {
analysis();
schedule.shutdown();
}
private void analysis() {
//压测机初步数据统计
Collector<YiSample, SampleState, SampleState> c = Collector.of(
SampleState::new,
SampleState::accumulator,
SampleState::combiner,
SampleState::finisher
);
Map<String, SampleState> sampleStateMap = temp.stream()
.collect(Collectors.groupingBy(YiSample::getLabel, c));
}
}
3.4.2 汇总整体数据
收集器收集到各压测机的压测数据后,收集服务的收集器采用每 3 秒收集汇总一次,尽可能保证的数据实时性并减少指标图表的曲率的变化频率,以报告ID的维度把相关压测数据收集汇总,并持久化到数据库中。
scss
//收集服务消费KAFKA并统一汇总数据
@Slf4j
public class AnalysisProcessor implements Processor<String, String> {
private ProcessorContext context;
private AtomicInteger processorNumber = new AtomicInteger();
PerformanceRecordRepository performanceRecordRepository = SpringContextUtils.getBean(PerformanceRecordRepository.class);
ReportRepository reportRepository = SpringContextUtils.getBean(ReportRepository.class);
private ConcurrentHashMap<String, List<SampleState>> sampleMap = new ConcurrentHashMap<>();
@Override
public void init(ProcessorContext context) {
//1.初始化
this.context = context;
processorNumber.getAndIncrement();
if (processorNumber.get() <= 1) {
this.context.schedule(Report.REPORT_INTERVAL, PunctuationType.WALL_CLOCK_TIME, (timeStamp) -> analysis());
log.info("init AnalysisProcessor");
}
}
@Override
public void process(String key, String message) {
//2.压测数据处理
SampleState sampleState = JSON.parseObject(message, SampleState.class);
if (!sampleMap.containsKey(key)) {
List<SampleState> list = new ArrayList<>();
sampleMap.put(key, list);
}
sampleMap.get(key).add(sampleState);
}
@Override
public void close() {
}
private void analysis() {
sampleMap.forEach((reportId, sampleStates) -> {
if (sampleStates.size() == 0) {
sampleMap.remove(reportId);
}
long start = System.currentTimeMillis();
Collector<SampleState, PerformanceRecordState, PerformanceRecord> c = Collector.of(
PerformanceRecordState::new,
PerformanceRecordState::accumulator,
PerformanceRecordState::combiner,
PerformanceRecordState::finisher
);
//汇总性能数据
Map<String, PerformanceRecord> reportRecord = sampleStates.stream()
.collect(Collectors.groupingBy(SampleState::getLabel, c));
AtomicInteger i = new AtomicInteger();
reportRecord.forEach((label, record) -> {
PerformanceRecord saveRecord = performanceRecordRepository.save(record);
i.getAndAdd((int) saveRecord.getN());
});
log.info("analysis report:{} once,it has {} record ,and {} samples ,used:{}ms ", reportId, sampleStates.size(), i.get(), (System.currentTimeMillis() - start));
//清空此批数据
if (sampleMap.containsKey(reportId)) {
sampleMap.get(reportId).clear();
}
});
}
}
3.4.3 展示报告数据
对汇总的报告数据通过不同维度进行展示,达到实时观察需要或做为其它功能的前置数据。
报告汇总:
接口数据:
性能曲线:
四、未来展望
最初平台大部分组件及功能均是独立不依赖于公司其它组件,包括权限系统,文件管理系统等,随着公司相关配套组件的发展,压测平台也逐步接入了统一权限、飞书消息、分布式缓存等组件,功能也从最初的设计的单纯压测到后期自动化压测平台(可参阅《全链路压测自动化的探索与实践》)。目前压测平台支撑货拉拉核心业务的性能测试及全链路测试,每月产生压测数据2000份以上,期间也过较多的迭代改造和升级,后续会继续根据业务的发展并结合公司的业务情况进行优化升级。
4.1 文件分发升级
目前平台使用的文件分发系统是通过mongo数据库分布式文件进行存储及分发的,其数据库的数量和吞吐即为文件分发的瓶颈,不能通过简单的扩容管理服务进行效率提升,在启动500台+压测测试时,分发文件会有一定的分发迟滞,在压测规模再进一步增长后计划优化为基于mongo+OSS双负载方式。
4.2 进一步的AI探索
目前压测平台在压测模型建模及压测问题发现方面已经进行AI算法上的一些探索,可提高压测模型的精度及主动发现压测过程的性能问题,下一步会结合AIGC在压测问题定位及压测报告生成上继续探索。