外观
大促全链路压测:影子库搭建与流量回放避坑实录
问题背景:为什么单机压测无法回答大促容量
大促前最常见的问题不是“接口 QPS 能到多少”,而是“整条链路在真实业务模型下还能不能扛住”。订单、库存、优惠券、支付、消息队列、缓存和数据库彼此影响,单机接口压测往往只能发现 CPU 或慢 SQL,却无法暴露连接池耗尽、缓存击穿、下游限流、消息堆积和跨服务资源竞争。
全链路压测的目标,是在接近生产的部署形态下,用可识别、可隔离、可回滚的方式模拟大促流量,完成容量评估、瓶颈定位和限流预案验证。核心难点有两个:一是压测数据不能污染生产库表、缓存和消息;二是压测流量要尽量还原真实用户路径,而不是只打一个热点接口。
方案对比:独立环境、数据标记与影子库怎么选
常见方案有三类。
第一类是完全独立的压测环境。优点是隔离彻底,缺点也明显:服务规格、数据库参数、网络拓扑、缓存命中率和生产差异很大,压出来的容量难以换算;每次同步版本和基础数据成本高。
第二类是生产环境加流量标记,复用生产表,通过字段或用户标识区分压测数据。实现简单,但侵入业务代码,清理数据容易遗漏唯一索引、库存扣减、审计日志和异步消息,风险较高。
第三类是生产环境全链路压测:入口请求携带压测标识,微服务、缓存 Key、消息、定时任务和数据库连接均识别该标识,SQL 自动路由到影子库。它最贴近真实容量,也最适合大促容量评估,但要求中间件和数据访问层具备统一隔离能力。
建议选型原则是:核心交易链路使用影子库;只读查询或报表类接口可以使用影子表或压测账号;强依赖外部第三方的支付、短信、物流接口使用 Mock 或沙箱,不能向外部发送真实请求。
实操步骤一:搭建影子库与动态数据源
影子库不是简单复制一份数据库即可。它的表结构、索引、参数、字符集、时区和连接串模板必须与生产一致,数据量也要按大促模型预热。可以使用逻辑备份或快照创建影子库,但用户手机号、收货地址、支付令牌等敏感字段需要脱敏。
下面示例基于 Spring Boot、MyBatis 和动态数据源实现。请求头携带 X-Load-Test: 1 时,当前线程切换到影子数据源。
java
// 压测上下文:只保存是否为压测流量,避免把业务参数塞入 ThreadLocal
public final class LoadTestContext {
private static final ThreadLocal<Boolean> FLAG =
ThreadLocal.withInitial(() -> false);
public static void mark() { FLAG.set(true); }
public static boolean isLoadTest() { return FLAG.get(); }
public static void clear() { FLAG.remove(); }
}
// 动态数据源:根据上下文选择生产库或影子库
public class ShadowRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return LoadTestContext.isLoadTest() ? "shadow" : "master";
}
}数据源配置如下:
yaml
spring:
datasource:
master:
jdbc-url: jdbc:mysql://prod-db:3306/trade?useSSL=true&serverTimezone=Asia/Shanghai
username: trade_app
password: ${MASTER_DB_PASSWORD}
maximum-pool-size: 50
shadow:
jdbc-url: jdbc:mysql://shadow-db:3306/trade_shadow?useSSL=true&serverTimezone=Asia/Shanghai
username: trade_shadow
password: ${SHADOW_DB_PASSWORD}
maximum-pool-size: 50注册动态数据源:
java
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean
@ConfigurationProperties("spring.datasource.shadow")
public DataSource shadowDataSource() {
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean
public DataSource dataSource() {
var router = new ShadowRoutingDataSource();
var targets = Map.of(
"master", masterDataSource(),
"shadow", shadowDataSource());
router.setTargetDataSources(new HashMap<>(targets));
router.setDefaultTargetDataSource(masterDataSource());
return router;
}
}过滤器负责识别压测标识,并在请求结束时清理 ThreadLocal:
java
@Component
public class LoadTestFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
try {
if ("1".equals(request.getHeader("X-Load-Test"))) {
LoadTestContext.mark();
}
chain.doFilter(request, response);
} finally {
// 必须清理,避免 Tomcat 工作线程复用导致串流量
LoadTestContext.clear();
}
}
}实操步骤二:跨服务、缓存和消息隔离
微服务调用时要把压测标识放入 RPC Header 或 Trace 上下文,禁止只靠 URL 参数传递。OpenFeign 可以统一增加请求拦截器:
java
@Bean
public RequestInterceptor loadTestRequestInterceptor() {
return template -> {
if (LoadTestContext.isLoadTest()) {
template.header("X-Load-Test", "1");
}
};
}Redis 建议统一封装 KeyBuilder,压测流量自动增加前缀,例如 shadow:stock:sku_1001。不要在业务代码里手写 Redis Key,否则很容易漏掉某个写路径。消息队列则将 Topic 或 Exchange 路由到影子 Topic,消费者继续透传标识;无法新建 Topic 时,可在消息 Header 中打标,并由消费者判断后写入影子库,但要防止没有升级的老消费者消费压测消息。
实操步骤三:流量录制、回放与容量评估
流量回放优先选择生产高峰时段的只读或脱敏后请求,保留接口比例、用户思考时间、参数分布和依赖调用顺序。不能只拿均匀流量循环回放,否则缓存命中率、锁竞争和热点分布都会失真。
一个可运行的简单回放器如下,生产中可替换为 JMeter、Gatling 或公司内部平台:
java
public class ReplayTask implements Runnable {
private final WebClient client = WebClient.builder()
.baseUrl("https://api.example.com")
.build();
private final List<String> traceSamples;
public ReplayTask(List<String> traceSamples) {
this.traceSamples = traceSamples;
}
@Override
public void run() {
for (String path : traceSamples) {
client.get().uri(path)
.header("X-Load-Test", "1")
.retrieve()
.toBodilessEntity()
.doOnSuccess(resp -> Metrics.recordSuccess(path))
.doOnError(ex -> Metrics.recordError(path, ex))
.block(Duration.ofSeconds(3));
// 保留样本中的间隔,而不是所有线程无限猛打
sleepLikeRealUser(path);
}
}
private void sleepLikeRealUser(String path) {
long pauseMs = path.contains("detail") ? 800 : 120;
try { Thread.sleep(pauseMs); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}容量评估至少分三轮:基线轮验证功能正确性和隔离性;阶梯轮每 5 到 10 分钟增加 20% 流量,观察 P95、P99、错误率、CPU、连接池、慢 SQL、MQ 延迟;极限轮验证限流、熔断、降级和扩容策略。容量水位不要只按 CPU 80% 估算,还要给出数据库连接池、磁盘 IO、网络带宽、下游依赖和消息堆积的安全阈值。
避坑总结:影子库和回放最容易翻车的位置
异步线程丢标识,压测数据写入生产库。 现象是压测订单在影子库能查到,但库存流水或通知记录出现在生产表。根因通常是
@Async、线程池、CompletableFuture 没有传递 ThreadLocal。修正方式是使用可传播上下文的线程池包装器,或在提交任务时显式复制压测标识,并在任务结束时清理。影子表结构与生产不一致,压测通过但上线失败。 常见表现是压测 SQL 走了旧索引,或生产新增字段后影子库没有同步。应把影子库结构校验接入发布流程,压测前自动比对表、字段、索引、触发器和数据库参数,不一致直接阻断压测。
只隔离数据库,遗漏缓存、MQ 和第三方调用。 例如压测请求把生产商品缓存删掉,导致真实用户请求回源;压测消息被老消费者消费,触发真实短信或支付回调。必须统一封装 Redis Key、MQ 客户端和外部网关,默认拒绝未识别压测流量访问第三方。
回放数据过于理想,容量评估偏大。 所有请求使用同一个用户、同一批 SKU、零等待时间,会制造虚假热点,也无法模拟登录、购物车、凑单和支付成功率。应从真实访问日志提取接口比例与参数分布,脱敏后按时间节奏回放,并单独构造热点商品和突发流量场景。
没有设置熔断与停止条件。 压测不是把服务打挂为止。应提前约定错误率超过 1%、P99 超过预算、生产业务核心指标异常时自动断流;压测账号、影子库权限和回调地址也要二次确认。
全链路压测的价值不在于得到一个漂亮的 QPS 数字,而在于用可重复的实验回答容量风险在哪里、瓶颈先出现在哪个资源、预案是否真的生效。影子库和流量回放只是手段,严格的标识透传、资源隔离、数据校验和停止机制,才是大促容量评估可靠落地的基础。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。