【案例实战】业务稳定性运行之全链路混合压测

发布时间:2024年01月09日

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,至此全链路压测就已经完成啦,大家可以根据公司自己的业务去实施。记得给博主三连哦!

在这里插入图片描述

文章来源:https://blog.csdn.net/weixin_47533244/article/details/135466594
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。