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

📖 开场:消防演习

想象学校的消防演习 🚒:

不演习(真火灾慌乱)

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⭐!

复制代码
相关推荐
钟离墨笺3 小时前
Go语言-->Goroutine 详细解释
开发语言·后端·golang
Yeats_Liao4 小时前
Go Web 编程快速入门 11 - WebSocket实时通信:实时消息推送和双向通信
前端·后端·websocket·golang
R.lin4 小时前
使用注解将日志存入Elasticsearch
java·大数据·后端·elasticsearch·中间件
用户0806765692534 小时前
蓝桥云课-罗勇军算法精讲课(Python版)视频教程
后端
用户0806765692534 小时前
C#.NET高级班进阶VIP课程
后端
用户401426695854 小时前
Pandas数据分析实战(完结)
后端
用户84298142418104 小时前
js中如何隐藏eval关键字?
前端·javascript·后端
用户9884740373814 小时前
reCAPTCHA v2与v3的核心差异及应对方案
后端
星星落进兜里5 小时前
Spring全家桶面试题, 只补充细节版本
java·后端·spring