1.全链路压测开展步骤
(1)什么是全链路压测
全链路压测是指基于真实业务场景,通过模拟海量的用户请求,对整个后台服务进行压力测试,从而评估整个系统的性能水平。
- 对应用程序的整个技术栈进行完整的压力和性能测试,覆盖了整个应用程序的各个部分,包括前端、后端、服务、数据库等。
- 在模拟真实用户交互和**系统环境(生产环境)**的情况下,评估整个应用在高负载情况下的性能表现和持续调优的过程,包括响应时间、吞吐量、资源利用率以及系统的稳定性和可扩展性等。
- 全链路压测可以帮助发现系统的性能瓶颈和故障点,并对整个系统的性能进行全面评估和优化。
(2)全链路压测的实施步骤
- 确定压测场景
- 根据实际使用情况和业务需求,确定要测试的压测场景,包括用户访问路径、操作流程和数据交互等。
- 每个场景包括一系列用户行为和操作流程,涉及到多个应用程序的不同组件。
- 区分核心业务和非核心业务,主要精力和压测都是对核心业务进行。
- 流量数据模型准备
- 根据压测场景,构建模拟用户行为的测试脚本。
- 为模拟用户行为提供必要的测试数据,可以使用真实或合成的数据进行测试,以确保测试的真实性和覆盖性。
- 做好数据隔离:不能污染正常的业务数据,整理好每个环节的数据流转。
- 选择工具配置并发
- 选择合适的流量录制和回放工具,根据实际情况和性能需求,配置并发用户数、请求频率和负载模型等。
- 可以逐步增加并发负载,以模拟系统在峰值和极限负载下的性能表现。
- 开始运行压测
- 运行测试脚本,模拟并发用户对系统进行访问和交互。
- 观察和记录系统在不同负载情况下的性能指标,例如响应时间、吞吐量、CPU和内存利用率等。
- 数据分析和优化
- 分析压测结果,并识别可能的性能问题和瓶颈点。
- 可以根据分析结果进行调整和优化,例如调整配置参数、进行代码优化或增加系统资源等。
- 结果评估和报告
- 根据压测结果进行结果评估,并生成详细的测试报告,报告应包括系统的性能指标、发现的问题和建议的优化方案。
- 压测现场还原
- 对相关压测数据进行清理,比如缓存,队列,数据库等,避免长期占用相关资源,定时维护好相关测试数据。
(3)全链路压测实施关键技术难点
- 流量数据模型:压测数据不真实,达不到生产环境的流量模型,最终导致压测不准确,比如只是拍脑袋想的用户访问路径。
- 业务探针性能:全链路压测涉及到各个方法调用链的监听,会有数据采集上报,离不开APM调用链路追逐工具,需要降低本身的损耗。
- 流量染色:流量请求和访问链路长,涉及业务多,需要多个系统代码改造,减少压测代码侵入性。
- 数据隔离:避免影响线上正式用户,比如数据库,MQ,缓存,外部三方服务等。
- 日志隔离:业务日志输出到文件或者到队列,避免压测数据采集后加入到正式的报表里面,导致运营和产品分析数据不准确。
- 压测时间:一般在业务访问低峰进行,比如国外的话就是半夜2~6点,国外业务就根据时差进行调整。
2.数据模型准备之流量回放
(1)什么是流量回放
流量回放就是通过记录线上流量,在开发或者测试环境回放,来发现系统是否能够正常运行,降低代码变动整体系统带来的风险。
(2)流量回放工具GoReplay
-
官网:https://goreplay.org/
-
github:https://github.com/buger/goreplay
-
GoReplay是GO语言编写的http流量复制工具,使用流程简单,支持多个系统,mac、linux、win。
-
GoReplay 不是代理,而是在后台侦听网络接口上的流量,无需更改生产基础架构,只需在与服务相同的机器上运行 GoReplay 守护程序。
- 流量录制重放特点
- 捕获网络指定端口流量,输出到控制台
- 捕获网络指定端口流量,将原始流量实时重放到其他环境中
- 捕获网络指定端口流量,并保存到文件中
- 捕获网络指定端口流量,请求过滤指定路径流量,并保存到文件中
(3)Linux服务器安装Go环境和GoReplay
- Go语言包下载地址:https://studygolang.com/dl
# 下载之后解压
tar -C /usr/local -zxvf go1.5.3.linux-amd64.tar.gz
- 配置go语言环境变量
# 打开文件
vim /etc/profile
# 添加环境变量
export GOROOT=/usr/local/go
export PATH=$PATH:$GOROOT/bin
# 编译生效
source /etc/profile
# 测试
go version
# go version go1.21.5 linux/386
- GoReplay下载二进制包地址:https://github.com/buger/gor/releases
- 下载1.3.1版本,下载之后解压
tar xvzf gor_1.3.1_x64.tar.gz
- 解压完压缩包后,可以从当前目录进行Gor,也可以将Gor文件复制到的PATH文件下
- ./gor 为执行文件
(4)使用方式
- 参数介绍
输入
--input-raw : 用于捕获 HTTP 流量时,应指定 IP 地址或界面以及应用程序端口
--input-file :接收以前使用过的文件记录
--input-tcp :如果决定将多个转发器Gor实例转发流量到它,Gor聚合实例使用
输出
--output-http :重播HTTP流量到给定的端点
--output-file :记录传入到文件的流量
--output-tcp :将传入的数据转发到另一个Gor实例
--output-stdout :用于调试,输出所有数据。
- 监听本地服务端口 8082 流量保存到本地的文件
./gor --input-raw :8082 --output-file=requests.gor
- 将流量从文件回放到其他服务
./gor --input-file requests.gor --output-http="http://ip:8082"
- 将 http 的请求打印到终端
./gor --input-raw :8082 --output-stdout
- 将 http 的请求实时转发到其他服务
./gor --input-raw :8082 --output-http="http://ip:8082"
- 将流量放大或减少转发给其他服务端口
./gor --input-file "requests.gor|200%" --output-http="http://ip:8082"
./gor --input-file "requests.gor|20%" --output-http="http://ip:8082"
- 过滤指定请求
./gor --input-raw :80 --http-allow-method GET --output-http http://target_server:8080
./gor --input-raw :8080 --output-http staging.com --http-allow-url /api
- 只收集请求头中符合 api-version 为 1.0x 的请求
./gor --input-raw :8080 --output-http staging.com --http-allow-headerapi-version:^1\.0\d
- 对指定的header或url请求进行限流或者加速
./gor --input-tcp :28020 --output-http"http://staging.com|10"# (每秒请求数限制10个以内)
./gor --input-raw :80 --output-tcp"replay.local:28020|10%" # (每秒请求数限制10%以内)
./gor --input-raw :80 --output-tcp"replay.local:28020|10%" --http-header-limiter "X-API-KEY:10%"
./gor --input-raw :80 --output-tcp"replay.local:28020|10%" --http-param-limiter "api_key:10%"
- 将流量转发到多个站点
./gor --input-raw :80 --output-http "http://target_server:8080"--output-http "http://target_server2:8080"
- 将相同的流量发送到多个站点,并且平分所有流量
./gor --input-raw :80 --output-http "http://staging.com" --output-http "http://dev.com"--split-output true
3.全链路压测之流量染色
(1)什么是流量染色
流量染色就是让压测流量可以被程序代码识别,方便做好数据隔离。对压测的请求增加特色的流量标识,比如请求里面增加url参数或header增加请求头。区分压测流量和真实流量,正常用户不会访问到压测数据,压测数据不会影响正式业务。染色后的压测流量,产生的数据可以再压测结束后直接清理。
(2)流量链路改造
-
数据库隔离
-
压测产生的数据需要和真实数据库的进行隔离,一般采用数据库的影子库、影子表进行隔离。
-
具体来说,影子库是生产环境数据库的一份完整拷贝,包含与生产环境相同的表结构和数据。
-
影子表是在影子库中创建的与生产环境表相对应的测试表,压测的数据进入影子表。
-
生产和压测环境的隔离,通过在压测环境中使用影子库和影子表,可以避免对生产环境数据的直接修改和干扰。
-
完整的数据环境,通过生成影子库和影子表的完整拷贝,全链路压测的时候可以在准确、真实的数据环境中进行工作。
-
线上问题还原,当线上出现问题时,可以使用影子库和影子表进行问题还原和分析,在相同数据环境中重现问题。
-
-
消息队列隔离
-
业务产生消息到MQ后,消费者会进行消费,压测过程产生的数据不能直接投递到MQ中。
-
一般是采用队列隔离或者消息隔离,隔离策略也是基于消息的生产者封装方法进行投递。
-
队列隔离:创建不同的消息队列,压测的队列和正式的队列采用不同的前缀进行区分。
-
消息隔离:消息里面增加参数,标记消息是否是压测还是正式的数据。
-
-
缓存隔离
-
缓存里面的数据隔离,对key进行区分,根据流量标识是否是压测流量,增加相关的key前缀标识。
-
不直接操作redis,而是封装redis工具类,在工具类里面判断是否是压测流量,里面对key的读写进行操作。
-
(3)流量标识透传
流量标识透传是一种将请求上下文信息从发起端(如客户端)传递到目标端(如后端服务)的方案。可以在测试过程中追踪和识别请求的来源,并对不同的请求进行分类和分析。压测流量全部带标识,结合拦截器,存储在ThreadLocal里面进行不同服务直接传递。
(4)流量标识透传方案
- HTTP Header:将请求上下文信息添加到HTTP请求的Header中。常用的Header字段包括:
X-Request-ID
:请求标识ID,用于唯一标识每个请求。X-Trace-ID
:链路追踪ID,用于追踪请求在分布式系统中的路径。X-Forwarded-For
:客户端真实IP地址,用于透传客户端IP。- 其他自定义的Header字段用于传递其他请求上下文信息。
(5)跨服务器之间流量传递
- 微服务里面采用了Fegin进行RPC调用,在发送请求前可以从ThreadLocal里面的进行获取相关标识符进行
- Dubbo/GRPC都类似,将请求上下文信息绑定到线程上下文,以实现在不同服务间的透传
4.全链路混合压测环境搭建
(1)阿里云docker部署mysql
docker run -d -p 3306:3306 --name mysql --privileged=true -v /data/mysql/data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=mysql8test. mysql:8.0.23
- 新建数据库,一个正式库,一个影子库(shadow)。
- 订单库 order、shadow_order
CREATE TABLE `product_order` (
`id` bigint NOT NULL AUTO_INCREMENT,
`product_id` bigint DEFAULT NULL,
`product_title` varchar(255) DEFAULT NULL,
`amount` int DEFAULT NULL,
`gmt_create` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
- 商品库 product、shadow_product
CREATE TABLE `product` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(255) DEFAULT NULL,
`stock` int DEFAULT NULL,
`amount` int DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 分别在两个库中加入数据
INSERT INTO `product` (`id`, `title`, `stock`, `amount`) VALUES (1, '名称', 200, 10);
(2)阿里云docker部署RabbitMQ
docker run -d --name rabbitmq -e RABBITMQ_DEFAULT_USER=admin -e RABBITMQ_DEFAULT_PASS=password -p 15672:15672 -p 5672:5672 rabbitmq:3.8.9-management
(3)阿里云docker部署Redis
docker run -itd --name redis -p 6379:6379 -v /mydata/redis/data:/data redis:7.0.8 --requirepass 123456
(4)阿里云docker部署Nacos
docker run -d -e MODE=standalone -e JVM_XMS=128m -e JVM_XMX=128m -e JVM_XMN=128m -p 8848:8848 -p 9848:9848 --restart=always --privileged=true --name nacos nacos/nacos-server:v2.2.3
5.全链路混合压测项目搭建
(1)需求模块划分
- 微服务业务划分:商品服务、订单服务
(2)业务逻辑
- 我们就以蓝色的用户行为链路进行测试。
(3)流量染色和RPC透传
- 压测流量全部带标识,结合拦截器,存储在ThreadLocal里面进行不同服务直接传递。
/**
* @author lixiang
* @date 2024/1/6 15:50
*/
@Slf4j
public class RequestInterceptor implements HandlerInterceptor {
public static TransmittableThreadLocal<Integer> threadLocal = new TransmittableThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, @NonNull HttpServletResponse response,@NonNull Object handler) throws Exception {
//前端在header中传入test_flag标识来区分是否为正式流量或者压测流量
String testFlag = request.getHeader("test_flag");
if(StringUtils.isNotBlank(testFlag)){
//通过threadLocal传递信息
log.info("压测流量,path = {}",request.getRequestURI());
threadLocal.set(1);
}else{
log.info("正式流量,path = {}",request.getRequestURI());
threadLocal.set(0);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
threadLocal.remove();
}
}
- RPC调用feign透传参数
/**
* @author lixiang
* @date 2024/1/6 15:50
*/
@Configuration
public class FeignConfig implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
assert attributes != null;
HttpServletRequest request = attributes.getRequest();
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames != null) {
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
String values = request.getHeader(name);
requestTemplate.header(name, values);
}
}
}
}
(4)缓存隔离
- 缓存里面的数据隔离,对key进行区分,根据流量标识是否是压测流量,增加相关的key前缀标识。
/**
* 构建 key,用于区分压测和正式流量
* @param key
* @return
*/
private String buildFinalKey(String key){
Integer testFlag = RequestInterceptor.threadLocal.get();
String finalKey = "";
//压测流量
if(testFlag !=null && testFlag ==1){
finalKey = "shadow:"+key;
}else {
finalKey = key;
}
return finalKey;
}
(5)消息队列隔离
- 采用队列隔离或者消息隔离,隔离策略也是基于消息的生产者封装方法进行投递
/**
* 构建 routingKey的时候,用于区分压测和正式流量
* @param routingKey
* @return
*/
private String buildFinalRoutingKey(String routingKey){
Integer testFlag = RequestInterceptor.threadLocal.get();
String finalKey = "";
//压测流量
if(testFlag !=null && testFlag ==1){
finalKey = "SHADOW."+routingKey;
}else {
finalKey = routingKey;
}
return finalKey;
}
- 监听器这块用于区分不同的队列
@Slf4j
@Component
public class OrderMQListener {
@RabbitListener(queuesToDeclare = { @Queue("ORDER_QUEUE") })
public void orderQueue(ProductOrderDO productOrderDO, Message message, Channel channel) throws IOException {
log.info("监听到正式消息:{}",message);
long msgTag = message.getMessageProperties().getDeliveryTag();
handleOrderMsg(productOrderDO);
channel.basicAck(msgTag,false);
}
@RabbitListener(queuesToDeclare = { @Queue("SHADOW_ORDER_QUEUE") })
public void shadowOrderQueue( ProductOrderDO productOrderDO,Message message, Channel channel) throws IOException {
log.info("监听到影子消息:{}",message);
long msgTag = message.getMessageProperties().getDeliveryTag();
handleOrderMsg(productOrderDO);
channel.basicAck(msgTag,false);
}
private void handleOrderMsg(ProductOrderDO productOrderDO){
String type = "1".equals(productOrderDO.getType())?"正式逻辑":"压测逻辑";
log.info("{}-处理订单消息",type);
}
}
(6)数据源隔离
- 双数据源配置类
/**
* @author lixiang
* @date 2024/1/6 15:50
*/
@Configuration
public class DynamicDataSourceConfig {
public static final String MASTER = "MASTER";
public static final String SHADOW = "SHADOW";
@Bean("masterDataSourceProperties")
@ConfigurationProperties("spring.datasource.master")
public DataSourceProperties masterDataSourceProperties() {
return new DataSourceProperties();
}
@Bean("masterDataSource")
@ConfigurationProperties(prefix = "spring.datasource.master.hikari")
public HikariDataSource primaryDataSource() {
return masterDataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@Bean("shadowDataSourceProperties")
@ConfigurationProperties("spring.datasource.shadow")
public DataSourceProperties shadowDataSourceProperties() {
return new DataSourceProperties();
}
@Bean("shadowDataSource")
@ConfigurationProperties(prefix = "spring.datasource.shadow.hikari")
public HikariDataSource secondaryDataSource() {
return shadowDataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
}
- 数据源路由配置
/**
* @author lixiang
* @date 2024/1/6 15:50
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
private static final TransmittableThreadLocal<String> dataSourceContextHolder = new TransmittableThreadLocal<>();
/**
* 配置DataSource, defaultDataSource为主数据库
*/
public DynamicDataSource(DataSource defaultDataSource, Map<Object,Object> targetDataSourceMap) {
super.setDefaultTargetDataSource(defaultDataSource);
super.setTargetDataSources(targetDataSourceMap);
super.afterPropertiesSet();
}
@Override
protected Object determineCurrentLookupKey() {
return getDataSource();
}
public static void setDataSource(String dataSource) {
dataSourceContextHolder.set(dataSource);
}
public static String getDataSource() {
return dataSourceContextHolder.get();
}
public static void clearDataSource() {
dataSourceContextHolder.remove();
}
}
- 数据源切面区分正式库和测试库
@Slf4j
@Aspect
@Component
public class DataSourceAspect {
@Pointcut("execution(public * com.lixiang.controller..*.*(..))")
public void controllerPointcut() {
}
@Before(value = "controllerPointcut()")
public void methodBefore(JoinPoint joinPoint) {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
assert requestAttributes != null;
HttpServletRequest request = requestAttributes.getRequest();
// 获取请求头
String testFlag = request.getHeader("test_flag");
// 通过 testFlag 判断
if (StringUtils.isNotBlank(testFlag)) {
System.out.println("压测流量,影子库,path = "+request.getRequestURI());
DynamicDataSource.setDataSource(DynamicDataSourceConfig.SHADOW);
} else {
System.out.println("正式流量,正式库,path = "+request.getRequestURI());
DynamicDataSource.setDataSource(DynamicDataSourceConfig.MASTER);
}
}
}
- 启动主类配置数据源路由
/**
* 创建多个数据源对象
* @param masterDataSource
* @param shadowDataSource
* @return
*/
@Bean
@Primary
public DynamicDataSource dataSource(DataSource masterDataSource, DataSource shadowDataSource) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("SHADOW", shadowDataSource);
targetDataSources.put("MASTER", masterDataSource);
return new DynamicDataSource(masterDataSource, targetDataSources);
}
6.Jmeter全链路混合压测
项目的代码我会传到资源文件中哦,大家记得去找【案例实战】业务稳定性运行之全链路混合压测
这个标题的资源文件。
(1)启动项目,查看服务情况
(2)接口准备
ip:8082/api/product/v1/findById 查看商品详情
ip:8082/api/product/v1/list 查看商品列表
ip:8082/api/product/v1/lock 扣件商品库存
ip:8081/api/order/v1/add 下单
- 我们以这个链路进行测试。
(3)配置jmeter
- 配置100个用户,持续60s
- 配置请求头,添加压测标识
- 配置用户变量
- 添加压测接口
- 查看报告
OK,至此全链路压测就已经完成啦,大家可以根据公司自己的业务去实施。记得给博主三连哦!