全链路压测实战——流量染色、影子库、生产环境压测方案
全链路压测实战——流量染色、影子库、生产环境压测方案
适读人群:高级后端工程师、架构师、大促备战负责人 | 阅读时长:约15分钟 | 核心价值:掌握全链路压测的核心技术:流量染色方案、影子库数据隔离、生产环境安全压测
第一次在生产环境压测的后果
2019年,我主导了一次"在生产上做真实压测"的尝试。理由是:测试环境数据量少、配置低,压测结果不可信。
于是在凌晨2点,流量低谷期,我开始往生产接口发压测流量。
问题来了:
压测创建的订单全都是真实订单,库存被扣减了,但没有真实用户来付款,这些订单占着库存,导致运营发现库存数据异常。
压测产生的物流单、短信验证码全发出去了,一大堆无效短信,被用户投诉。
压测创建的测试用户触发了风控规则,风控把这些账号封了,后续的正常测试无法使用。
从那以后,我明白了:直接在生产环境发原始流量压测是错误的。 要做生产级别的压测,必须做好数据隔离——这就是影子库和流量染色要解决的问题。
全链路压测的核心挑战
全链路压测在生产环境进行,面对三个核心挑战:
挑战1:数据污染 压测产生的订单、用户、库存变更,不能污染真实的业务数据。
挑战2:资源隔离 压测流量和真实流量共享数据库连接池、线程池,压测不能把真实用户的资源抢走。
挑战3:行为一致性 压测要触发和真实流量完全相同的代码路径,不能走特殊的测试捷径,否则压测数据没有意义。
方案一:流量染色
原理
在压测流量的HTTP Header里加入特殊标记:
X-Canary-Test: true
X-Trace-Type: shadow
X-Load-Test: 1全链路所有服务识别这个标记,把压测请求路由到影子资源(影子数据库、影子MQ Topic等),不影响真实资源。
实现:Spring Boot 染色识别
// 染色标记传递工具类
public class LoadTestContext {
private static final ThreadLocal<Boolean> IS_LOAD_TEST = new ThreadLocal<>();
private static final String LOAD_TEST_HEADER = "X-Load-Test";
public static void setLoadTest(boolean isLoadTest) {
IS_LOAD_TEST.set(isLoadTest);
}
public static boolean isLoadTest() {
Boolean val = IS_LOAD_TEST.get();
return val != null && val;
}
public static void clear() {
IS_LOAD_TEST.remove();
}
}
// 拦截器:从HTTP Header读取染色标记
@Component
public class LoadTestInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String loadTestHeader = request.getHeader("X-Load-Test");
LoadTestContext.setLoadTest("1".equals(loadTestHeader));
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
LoadTestContext.clear(); // 清理ThreadLocal,防止泄漏
}
}跨服务传递染色标记
染色标记必须在微服务调用链中自动传递,不能靠手动:
// Feign拦截器:自动在所有微服务调用中传递染色标记
@Component
public class LoadTestFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
if (LoadTestContext.isLoadTest()) {
template.header("X-Load-Test", "1");
}
}
}
// 如果用的是Spring Cloud Gateway,在过滤器里传递
@Component
public class LoadTestGatewayFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String loadTestHeader = exchange.getRequest().getHeaders().getFirst("X-Load-Test");
if ("1".equals(loadTestHeader)) {
// 往下游传递染色标记
ServerHttpRequest request = exchange.getRequest().mutate()
.header("X-Load-Test", "1")
.build();
return chain.filter(exchange.mutate().request(request).build());
}
return chain.filter(exchange);
}
}方案二:影子库(Shadow Database)
原理
为每张业务表创建一个同结构的影子表,压测数据写入影子表,真实数据写入原表。
-- 原表
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint,
product_id bigint,
amount decimal(10,2),
status tinyint,
create_time datetime
);
-- 影子表(完全相同结构)
CREATE TABLE shadow_orders (
id bigint PRIMARY KEY,
user_id bigint,
product_id bigint,
amount decimal(10,2),
status tinyint,
create_time datetime
);实现:MyBatis 影子库路由
// 数据源路由器
@Component
public class DynamicDataSourceRouter extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// 压测流量路由到影子数据源
if (LoadTestContext.isLoadTest()) {
return "shadow";
}
return "primary";
}
}
// 数据源配置
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.primary")
public DataSource primaryDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.shadow")
public DataSource shadowDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@Primary
public DataSource dynamicDataSource(@Qualifier("primaryDataSource") DataSource primary,
@Qualifier("shadowDataSource") DataSource shadow) {
Map<Object, Object> dataSources = new HashMap<>();
dataSources.put("primary", primary);
dataSources.put("shadow", shadow);
DynamicDataSourceRouter router = new DynamicDataSourceRouter();
router.setTargetDataSources(dataSources);
router.setDefaultTargetDataSource(primary);
return router;
}
}影子MQ
如果服务里有消息队列,压测消息也不能发到真实Topic:
@Component
public class LoadTestAwareMQProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void sendOrderCreatedMessage(OrderCreatedEvent event) {
String topic = LoadTestContext.isLoadTest()
? "shadow_order_created" // 影子Topic
: "order_created"; // 真实Topic
rocketMQTemplate.convertAndSend(topic, event);
}
}对应的消费者也要起一个影子消费者,消费影子Topic里的消息,执行压测逻辑的后续处理(但不触发真实的短信、通知等外部操作)。
影子库方案的局限性与改进
局限性:影子库数据为空导致测试失真
影子库通常是空表,没有历史数据。某些查询在空表上很快,在有数据的表上很慢。
解决方案:影子库数据预填充
# 从生产数据库导出一部分脱敏数据到影子库
mysqldump --no-data production orders | mysql shadow_db # 先建表结构
mysqldump --where="id % 100 = 0" production orders | mysql shadow_db # 导入1%的脱敏数据或者用压测前的热身阶段(Warm Up),让影子库积累一些数据后再开始正式统计。
使用 JMeter 的完整全链路压测配置
<!-- 全链路压测关键配置:在HTTP Header Manager里加染色标记 -->
<HeaderManager testname="全链路压测标记" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">X-Load-Test</stringProp>
<stringProp name="Header.value">1</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">X-Test-ScenarioId</stringProp>
<stringProp name="Header.value">doubleleven_2024_fullchain</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>踩坑实录
坑1:ThreadLocal没清理导致染色标记泄漏
现象: 正常用户的请求偶发被路由到了影子库,查不到订单数据。用户投诉"刚下的单不见了"。
原因: LoadTestContext的ThreadLocal在某些异常路径下没有执行clear(),线程池里的线程被归还后仍然持有压测标记。下一个真实用户的请求恰好分配到这个线程,被误认为是压测请求路由到了影子库。
解法: 必须在finally块或afterCompletion里清理ThreadLocal:
// 使用try-finally确保清理
try {
LoadTestContext.setLoadTest(true);
// 业务逻辑
} finally {
LoadTestContext.clear();
}坑2:影子MQ消息最终触发了真实通知
现象: 全链路压测期间,真实用户收到了大量"您的订单已取消"短信,但他们根本没有下单。
原因: 影子Topic的消费者里有一处代码分支,条件判断失误,在某些情况下调用了真实的短信服务。
解法: 影子消费者里的所有外部调用(短信、Push、邮件、第三方接口)都必须全部屏蔽或Mock,不管业务逻辑走到哪条分支。可以在外部调用的统一封装层加判断:
public void sendSMS(String phone, String content) {
if (LoadTestContext.isLoadTest()) {
log.info("[LoadTest] Mock SMS: phone={}, content={}", phone, content);
return; // 压测流量,不发真实短信
}
// 发真实短信
smsProvider.send(phone, content);
}坑3:影子库写入成功,但读取时走了真实库
现象: 全链路压测时创建订单成功,但立即查询订单时返回"订单不存在"。
原因: 创建订单时(POST请求)有X-Load-Test标记,写入影子库。但查询订单时(GET请求),代码里有一处逻辑从request parameter读取参数,但因为是GET请求,Header染色标记在框架处理时丢失了,路由到了真实库,查不到数据。
解法: GET请求的染色标记不能只靠Header,可以用Cookie或者URL参数:GET /api/order?orderId=xxx&_lt=1,让服务从多个地方读取染色标记。
总结
全链路压测的核心是数据安全:压测流量走影子资源,真实流量走真实资源,两者不混淆。
技术实现的关键点:
- 流量染色Header的全链路传递(Feign拦截器/网关过滤器)
- ThreadLocal染色标记的正确清理(避免泄漏)
- 影子库路由(MyBatis + AbstractRoutingDataSource)
- 影子MQ + 所有外部调用的Mock
做好这四点,生产级别的全链路压测才是安全的。
