🧪 设计一个全链路压测系统:战前的演习!

📖 开场:消防演习

想象学校的消防演习 🚒:

不演习(真火灾慌乱)

markdown 复制代码
真实火灾 🔥
    ↓
学生:不知道怎么逃 😱
老师:不知道怎么组织 😱
    ↓
乱成一团 💀

结果:
- 伤亡惨重 ❌
- 损失巨大 ❌

经常演习(有条不紊)

markdown 复制代码
消防演习 🧯
    ↓
学生:按演习路线疏散 🚶
老师:按预案组织 👨‍🏫
    ↓
有序撤离 ✅

真实火灾时:
    ↓
按演习经验应对
    ↓
安全撤离 ✅

结果:
- 零伤亡 ✅
- 损失最小 ✅

这就是压测:提前发现系统瓶颈!


🤔 为什么需要全链路压测?

问题:生产事故不可预测 💀

markdown 复制代码
双11前夕:
开发:代码没问题 ✅
测试:功能测试通过 ✅
    ↓
双11当天:
流量暴增100倍 🔥
    ↓
服务器崩溃 💀
数据库挂了 💀
订单丢失 💀

结果:
- 损失千万 ❌
- 用户投诉 ❌

有压测

diff 复制代码
双11前1个月:
全链路压测 🧪
    ↓
模拟100倍流量
    ↓
发现问题:
- 数据库连接池不够 ⚠️
- Redis内存不足 ⚠️
- 接口响应慢 ⚠️
    ↓
提前优化 ✅

双11当天:
    ↓
系统稳定运行 ✅

🎯 核心挑战

挑战1:数据隔离 🔒

markdown 复制代码
问题:
压测流量 → 写入真实数据库 💀
    ↓
生产数据被污染 ❌

解决:
影子表/影子库 ✅

挑战2:流量染色 🎨

arduino 复制代码
问题:
如何区分压测流量和真实流量?

解决:
请求头标记:X-Stress-Test: true ✅

挑战3:全链路 🔗

markdown 复制代码
压测必须覆盖:
用户 → 网关 → 订单服务 → 库存服务 → 数据库
       ↓         ↓           ↓
     限流      缓存        消息队列

所有环节都要测!✅

🎯 核心设计

设计1:流量染色 🎨

java 复制代码
@Component
public class StressTestInterceptor implements HandlerInterceptor {
    
    private static final String STRESS_TEST_HEADER = "X-Stress-Test";
    
    /**
     * ⭐ 检查是否是压测流量
     */
    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler) {
        // 1. 检查请求头
        String stressTest = request.getHeader(STRESS_TEST_HEADER);
        
        if ("true".equals(stressTest)) {
            // ⭐ 压测流量,标记到ThreadLocal
            StressTestContext.setStressTest(true);
        }
        
        return true;
    }
    
    @Override
    public void afterCompletion(HttpServletRequest request, 
                               HttpServletResponse response, 
                               Object handler, 
                               Exception ex) {
        // 清理ThreadLocal
        StressTestContext.clear();
    }
}

/**
 * ⭐ 压测上下文(ThreadLocal)
 */
public class StressTestContext {
    
    private static final ThreadLocal<Boolean> STRESS_TEST = new ThreadLocal<>();
    
    public static void setStressTest(boolean isStressTest) {
        STRESS_TEST.set(isStressTest);
    }
    
    public static boolean isStressTest() {
        return Boolean.TRUE.equals(STRESS_TEST.get());
    }
    
    public static void clear() {
        STRESS_TEST.remove();
    }
}

设计2:影子表 👻

数据库路由

java 复制代码
@Component
public class StressTestDataSourceRouter {
    
    /**
     * ⭐ 根据是否压测,选择数据源
     */
    public DataSource getDataSource() {
        if (StressTestContext.isStressTest()) {
            // 压测流量 → 影子库
            return shadowDataSource;
        } else {
            // 正常流量 → 正式库
            return normalDataSource;
        }
    }
}

表名路由

java 复制代码
@Aspect
@Component
public class StressTestTableAspect {
    
    /**
     * ⭐ 拦截Mapper方法,修改表名
     */
    @Around("@annotation(org.apache.ibatis.annotations.Select) || " +
            "@annotation(org.apache.ibatis.annotations.Insert) || " +
            "@annotation(org.apache.ibatis.annotations.Update) || " +
            "@annotation(org.apache.ibatis.annotations.Delete)")
    public Object aroundMapper(ProceedingJoinPoint joinPoint) throws Throwable {
        if (StressTestContext.isStressTest()) {
            // ⭐ 压测流量,修改SQL中的表名
            // t_order → t_order_shadow
            Object[] args = joinPoint.getArgs();
            for (int i = 0; i < args.length; i++) {
                if (args[i] instanceof String) {
                    String sql = (String) args[i];
                    args[i] = replaceShadowTable(sql);
                }
            }
        }
        
        return joinPoint.proceed(args);
    }
    
    /**
     * 替换为影子表
     */
    private String replaceShadowTable(String sql) {
        // t_order → t_order_shadow
        return sql.replaceAll("t_order(?!_shadow)", "t_order_shadow")
                 .replaceAll("t_user(?!_shadow)", "t_user_shadow");
    }
}

MyBatis拦截器(推荐)⭐⭐⭐

java 复制代码
@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare", 
               args = {Connection.class, Integer.class})
})
@Component
public class StressTestSqlInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        if (StressTestContext.isStressTest()) {
            // ⭐ 压测流量,修改SQL
            StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
            
            // 获取BoundSql
            BoundSql boundSql = statementHandler.getBoundSql();
            String sql = boundSql.getSql();
            
            // 替换表名
            String newSql = replaceShadowTable(sql);
            
            // 反射修改SQL
            Field sqlField = BoundSql.class.getDeclaredField("sql");
            sqlField.setAccessible(true);
            sqlField.set(boundSql, newSql);
        }
        
        return invocation.proceed();
    }
    
    private String replaceShadowTable(String sql) {
        // 正则替换表名
        return sql.replaceAll("\bt_order\b", "t_order_shadow")
                 .replaceAll("\bt_user\b", "t_user_shadow");
    }
}

设计3:消息队列隔离 📨

java 复制代码
@Service
public class OrderMQService {
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    /**
     * ⭐ 发送订单消息(压测流量发送到影子Topic)
     */
    public void sendOrderMessage(Order order) {
        String topic;
        
        if (StressTestContext.isStressTest()) {
            // 压测流量 → 影子Topic
            topic = "order-topic-shadow";
        } else {
            // 正常流量 → 正式Topic
            topic = "order-topic";
        }
        
        rocketMQTemplate.syncSend(topic, order);
    }
}

/**
 * ⭐ 消费影子Topic
 */
@Component
@RocketMQMessageListener(
    topic = "order-topic-shadow",
    consumerGroup = "order-consumer-shadow"
)
public class ShadowOrderConsumer implements RocketMQListener<Order> {
    
    @Override
    public void onMessage(Order order) {
        // 标记为压测流量
        StressTestContext.setStressTest(true);
        
        try {
            // 处理订单(会写入影子表)
            orderService.process(order);
        } finally {
            StressTestContext.clear();
        }
    }
}

设计4:Redis隔离 💾

java 复制代码
@Service
public class StressTestRedisService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * ⭐ 获取Redis Key(压测流量添加后缀)
     */
    private String getKey(String key) {
        if (StressTestContext.isStressTest()) {
            return key + ":shadow";  // user:123 → user:123:shadow
        }
        return key;
    }
    
    public void set(String key, String value) {
        redisTemplate.opsForValue().set(getKey(key), value);
    }
    
    public String get(String key) {
        return redisTemplate.opsForValue().get(getKey(key));
    }
}

设计5:压测平台 🖥️

java 复制代码
@RestController
@RequestMapping("/stress-test")
public class StressTestController {
    
    @Autowired
    private StressTestService stressTestService;
    
    /**
     * ⭐ 创建压测任务
     */
    @PostMapping("/task")
    public Result<Long> createTask(@RequestBody StressTestTask task) {
        // 1. 校验参数
        if (task.getTargetQPS() <= 0) {
            return Result.fail("目标QPS必须大于0");
        }
        
        // 2. 创建压测任务
        Long taskId = stressTestService.createTask(task);
        
        return Result.success(taskId);
    }
    
    /**
     * ⭐ 启动压测
     */
    @PostMapping("/task/{taskId}/start")
    public Result<Void> startTask(@PathVariable Long taskId) {
        stressTestService.startTask(taskId);
        return Result.success();
    }
    
    /**
     * ⭐ 停止压测
     */
    @PostMapping("/task/{taskId}/stop")
    public Result<Void> stopTask(@PathVariable Long taskId) {
        stressTestService.stopTask(taskId);
        return Result.success();
    }
    
    /**
     * 查询压测报告
     */
    @GetMapping("/task/{taskId}/report")
    public Result<StressTestReport> getReport(@PathVariable Long taskId) {
        StressTestReport report = stressTestService.getReport(taskId);
        return Result.success(report);
    }
}

设计6:压测执行器 ⚙️

java 复制代码
@Service
public class StressTestExecutor {
    
    @Autowired
    private RestTemplate restTemplate;
    
    /**
     * ⭐ 执行压测
     */
    public void execute(StressTestTask task) {
        // 目标QPS
        int targetQPS = task.getTargetQPS();
        
        // 计算每秒发送请求数
        int requestsPerSecond = targetQPS;
        
        // 计算请求间隔(毫秒)
        long interval = 1000 / requestsPerSecond;
        
        // 压测持续时间(秒)
        int duration = task.getDuration();
        
        long startTime = System.currentTimeMillis();
        long endTime = startTime + duration * 1000;
        
        // 统计
        AtomicInteger totalCount = new AtomicInteger(0);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);
        
        // ⭐ 多线程发送请求
        ExecutorService executor = Executors.newFixedThreadPool(10);
        
        while (System.currentTimeMillis() < endTime) {
            executor.submit(() -> {
                try {
                    // 构造请求
                    HttpHeaders headers = new HttpHeaders();
                    headers.set("X-Stress-Test", "true");  // ⭐ 压测标记
                    
                    HttpEntity<String> request = new HttpEntity<>(headers);
                    
                    // 发送请求
                    long start = System.currentTimeMillis();
                    ResponseEntity<String> response = restTemplate.exchange(
                        task.getUrl(),
                        HttpMethod.GET,
                        request,
                        String.class
                    );
                    long cost = System.currentTimeMillis() - start;
                    
                    totalCount.incrementAndGet();
                    
                    if (response.getStatusCode().is2xxSuccessful()) {
                        successCount.incrementAndGet();
                    } else {
                        failCount.incrementAndGet();
                    }
                    
                    // 记录响应时间
                    recordResponseTime(task.getId(), cost);
                    
                } catch (Exception e) {
                    totalCount.incrementAndGet();
                    failCount.incrementAndGet();
                    e.printStackTrace();
                }
            });
            
            // 控制QPS
            try {
                Thread.sleep(interval);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
        
        executor.shutdown();
        
        // 生成报告
        generateReport(task.getId(), totalCount.get(), 
                      successCount.get(), failCount.get());
    }
}

🎓 面试题速答

Q1: 什么是全链路压测?

A : 模拟真实流量,覆盖所有环节

markdown 复制代码
全链路:
用户 → 网关 → 订单服务 → 库存服务 → 数据库
       ↓         ↓           ↓
     限流      缓存        消息队列

压测覆盖所有环节 ✅

目的

  • 发现系统瓶颈
  • 验证容量规划
  • 提前优化

Q2: 如何区分压测流量和真实流量?

A : 流量染色

java 复制代码
// 请求头标记
headers.set("X-Stress-Test", "true");

// ThreadLocal存储
StressTestContext.setStressTest(true);

// 后续所有操作都能判断
if (StressTestContext.isStressTest()) {
    // 压测流量处理
}

Q3: 如何保证数据隔离?

A : 影子表/影子库

sql 复制代码
方案1:影子表
t_order → t_order_shadow(同库不同表)

方案2:影子库
order_db → order_db_shadow(不同库)

实现:MyBatis拦截器修改SQL

Q4: 消息队列如何隔离?

A : 影子Topic

java 复制代码
String topic;
if (StressTestContext.isStressTest()) {
    topic = "order-topic-shadow";  // 影子Topic
} else {
    topic = "order-topic";  // 正式Topic
}

rocketMQTemplate.send(topic, message);

Q5: Redis如何隔离?

A : Key添加后缀

java 复制代码
private String getKey(String key) {
    if (StressTestContext.isStressTest()) {
        return key + ":shadow";
    }
    return key;
}

// user:123 → user:123:shadow

Q6: 如何清理压测数据?

A : 定时清理

java 复制代码
@Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨2点
public void cleanShadowData() {
    // 清理影子表
    jdbcTemplate.execute("TRUNCATE TABLE t_order_shadow");
    
    // 清理影子Redis
    Set<String> keys = redisTemplate.keys("*:shadow");
    redisTemplate.delete(keys);
}

🎬 总结

markdown 复制代码
       全链路压测系统核心

┌────────────────────────────────────┐
│ 1. 流量染色 ⭐                      │
│    - 请求头标记                    │
│    - ThreadLocal传递               │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 2. 影子表 👻                        │
│    - MyBatis拦截器                 │
│    - 修改表名/库名                 │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 3. MQ隔离                          │
│    - 影子Topic                     │
│    - 影子Consumer                  │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 4. Redis隔离                       │
│    - Key添加:shadow后缀            │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 5. 压测平台                        │
│    - 创建任务                      │
│    - 启动/停止                     │
│    - 生成报告                      │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了全链路压测系统的设计!🎊

核心要点

  1. 流量染色:请求头标记 + ThreadLocal传递
  2. 影子表:MyBatis拦截器修改SQL
  3. MQ隔离:影子Topic
  4. Redis隔离:Key添加:shadow后缀
  5. 压测平台:任务管理 + 执行器

下次面试,这样回答

"全链路压测系统通过流量染色和数据隔离实现。流量染色在请求头添加X-Stress-Test标记,拦截器检测到后存入ThreadLocal,后续所有操作都能判断是否压测流量。

数据隔离使用影子表实现。通过MyBatis拦截器拦截SQL执行,检测到压测流量时,正则替换表名,如t_order替换为t_order_shadow。这样压测数据写入影子表,不污染生产数据。影子表结构与正式表完全相同,但数据完全隔离。

消息队列隔离使用影子Topic。判断压测流量时,消息发送到order-topic-shadow而非order-topic。创建影子Consumer消费影子Topic,消费时标记ThreadLocal为压测流量,后续写入影子表。

Redis隔离通过Key后缀实现。封装Redis操作,getKey方法判断压测流量时,给key添加':shadow'后缀。如user:123变为user:123:shadow,实现数据隔离。

压测平台提供任务管理。创建压测任务配置目标QPS、持续时间、压测URL。执行器多线程发送请求,请求头添加压测标记,控制发送间隔实现目标QPS。统计成功数、失败数、响应时间生成压测报告。定时任务清理影子数据。"

面试官:👍 "很好!你对全链路压测的设计理解很深刻!"


本文完 🎬

上一篇 : 223-设计一个分布式Session管理方案.md
下一篇 : 225-设计一个灰度发布系统.md

作者注 :写完这篇,我觉得压测太重要了!🧪

如果这篇文章对你有帮助,请给我一个Star⭐!

复制代码
相关推荐
用户298698530142 小时前
.NET 文档自动化:Spire.Doc 设置奇偶页页眉/页脚的最佳实践
后端·c#·.net
序安InToo2 小时前
第6课|注释与代码风格
后端·操作系统·嵌入式
xyy1232 小时前
C#: Newtonsoft.Json 到 System.Text.Json 迁移避坑指南
后端
洋洋技术笔记2 小时前
Spring Boot Web MVC配置详解
spring boot·后端
JxWang052 小时前
VS Code 配置 Markdown 环境
后端
navms2 小时前
搞懂线程池,先把 Worker 机制啃明白
后端
JxWang052 小时前
离线数仓的优化及重构
后端
Nyarlathotep01132 小时前
gin01:初探gin的启动
后端·go
JxWang052 小时前
安卓手机配置通用多屏协同及自动化脚本
后端
JxWang052 小时前
Windows Terminal 配置 oh-my-posh
后端