一、 一次"自信"上线的惨痛教训
时间回到去年618大促。

我们团队对自己的系统非常有信心——代码review了三遍,单元测试覆盖率85%,性能测试也做了。上线前一天,技术Leader拍着胸脯说:“没问题,我们压测过了,QPS 2000完全扛得住。”

结果大促当天,流量峰值瞬间冲到QPS 3500。系统在10分钟内经历了:

第一波:应用服务器CPU飙到95%,GC频繁,接口RT从50ms飙升到3秒。

第二波:数据库连接池被耗尽,大量请求超时。

第三波:Redis连接数达到上限,缓存失效,请求直接穿透到数据库。

最终:数据库连接数打满,服务全面瘫痪,持续宕机47分钟。

事后复盘发现:我们做的根本不是全链路压测,只是单接口压测。下单接口单独跑能扛2000 QPS,但真实场景下,下单会调用库存、营销、用户、物流等6个服务,链路一长,各种隐藏问题全暴露了。

那一刻我们明白:不搞真正的全链路压测,下次大促还会再翻车。

本文将完整复盘我们从"盲目自信"到"全链路压测体系"的建设过程,重点分享压测方案设计、JVM调优以及容量规划的实战经验,希望能为大家的大促备战提供参考。

二、 什么是"全链路压测"?和普通压测有什么区别?
先厘清概念:

维度 单接口压测 全链路压测
压测范围 单个API接口 完整业务流程(从网关→微服务→DB→缓存→MQ)
数据构造 固定参数,少量数据 海量真实数据,覆盖多种场景(新用户、老用户、大V、不同商品类目)
流量模型 固定QPS,阶梯式加压 模拟真实峰值曲线(陡增→平稳→回落)
依赖隔离 不关注下游 Mock下游或真实调用
目标 验证单点性能 验证全系统承载能力,发现链路上的每一个瓶颈
核心原则:压测环境越接近生产,结果越可信。

三、 压测工具选型:JMeter vs Gatling vs 自研
我们对比了主流方案:

工具 优点 缺点 结论
JMeter 生态成熟,GUI界面,支持分布式 单机并发有限(约1000-2000线程),脚本维护成本高 ✅ 选为主力(配合分布式)
Gatling 性能更好(基于Akka),脚本灵活 学习曲线陡峭,生态不如JMeter ❌ 暂不采用
自研压测平台 完全可控,支持流量染色、数据隔离 开发成本高(约3人月) ✅ 作为长期规划
最终方案:JMeter 集群 + 自研流量染色插件

[压测控制台]
├─ Master节点:分发压测脚本,收集结果
├─ Slave节点1:模拟 500 并发用户
├─ Slave节点2:模拟 500 并发用户
├─ Slave节点3:模拟 500 并发用户
└─ Slave节点N:模拟 500 并发用户(总并发数 = 500 × N)
关键配置优化(突破JMeter单机瓶颈):

properties

jmeter.properties

1. 禁用图形界面(GUI模式只用于脚本调试,压测用命令行)

jmeter.save.saveservice.output_format=csv

2. 增加JVM堆内存

HEAP=“-Xms4g -Xmx4g -XX:MaxMetaspaceSize=512m”

3. 启用无GC模式(减少压测过程中的GC暂停)

GC_ALGO=“-XX:+UseG1GC -XX:MaxGCPauseMillis=100”

4. 增加网络超时时间

httpclient.timeout=60000
四、 压测数据构造:如何造出"真实"的数据?
压测最容易被忽视的问题是数据。用几条固定数据压测的结果毫无意义。

4.1 数据特征必须覆盖
维度 生产特征 压测数据构造方法
用户分布 80%用户下单少,20%用户(大V)下单多 按二八分布生成用户ID池
商品分布 头部爆款SKU占70%流量 加权随机选择,热点商品权重更高
地域分布 一线城市占比高 按真实比例分配省份/城市
时间分布 白天流量是晚上的3倍 压测脚本中配置时间权重
数据状态 订单状态:待支付40%/已支付40%/已完成20% 混合各类状态数据
4.2 压测数据隔离(防污染生产数据)
这是全链路压测最关键的环节——绝对不能让压测数据污染生产环境!

我们的方案:流量染色(Shadow Traffic)

java
public class RequestContext {
private static final ThreadLocal PRESS_CONTEXT = new ThreadLocal<>();

// 在网关层识别压测请求,打标
public static boolean isPressRequest() {
    Boolean isPress = PRESS_CONTEXT.get();
    return isPress != null && isPress;
}

public static void setPress(boolean press) {
    PRESS_CONTEXT.set(press);
}

}
各层的数据隔离策略:

层 隔离策略 实现方式
数据库 压测数据写入影子表(shadow_table) 通过MyBatis拦截器,根据isPress动态替换表名
Redis 压测Key增加前缀 “press_” + originalKey
MQ 压测消息走影子Topic RocketMQ配置PRESS_TOPIC,消费者消费时丢弃或写入影子表
日志 压测日志打标,归档到独立索引 ELK中按isPress=true过滤
MyBatis拦截器实现影子表切换:

java
@Intercepts({
@Signature(type = StatementHandler.class, method = “prepare”, args = {Connection.class, Integer.class})
})
public class ShadowTableInterceptor implements Interceptor {

@Override
public Object intercept(Invocation invocation) throws Throwable {
    if (!RequestContext.isPressRequest()) {
        return invocation.proceed();
    }
    
    // 获取原始SQL
    StatementHandler handler = (StatementHandler) invocation.getTarget();
    MetaObject metaObject = SystemMetaObject.forObject(handler);
    String originalSql = (String) metaObject.getValue("delegate.boundSql.sql");
    
    // 替换表名:t_order → t_order_shadow
    String shadowSql = originalSql.replaceAll("t_(\\w+)", "t_$1_shadow");
    metaObject.setValue("delegate.boundSql.sql", shadowSql);
    
    return invocation.proceed();
}

}
五、 压测执行:发现瓶颈的"六个阶段"
我们按照阶梯式加压的方式执行压测,每个阶段都有明确的观察目标:

第一阶段:单链路基准压测(探底)
目标:获取单个核心链路的基准TPS和RT。

压力:从低并发开始(如50并发),逐步增加到200。

观察指标:成功率、平均RT、TP99、吞吐量。

第二阶段:核心链路组合压测
目标:模拟真实用户行为,多接口混合调用。

场景:40%用户浏览商品→30%加购→20%下单→10%支付。

压力:按比例混合,总并发从100逐步增加到1000。

第三阶段:全链路压测(首次暴露问题)
目标:完整的端到端业务流程(登录→浏览→加购→下单→支付→查看物流)。

压力:按预估峰值(如QPS 3000)的80%施压。

重点关注:首次暴露的瓶颈点(JVM、GC、DB连接池、Redis、MQ堆积)。

第四阶段:极限压测(摸高)
目标:找到系统的崩溃点。

压力:持续加压,直到系统出现不可用(错误率>5%或RT>3秒)。

价值:明确系统的上限容量,为限流阈值提供依据。

第五阶段:长稳压测(耐力测试)
目标:验证系统在持续高压下的稳定性(内存泄漏、连接泄漏)。

压力:以峰值QPS的70%持续运行 4-8小时。

观察重点:内存占用是否持续增长(内存泄漏)、GC频率是否稳定、连接数是否异常。

第六阶段:破坏性压测(故障注入)
目标:验证系统在异常情况下的容错能力。

场景:模拟Redis宕机、DB主从切换、下游服务超时。

价值:验证熔断降级、重试、超时策略是否生效。

六、 JVM调优:从GC频繁到STW几乎为零
压测过程中,我们发现了严重的JVM性能问题:

问题现象:

年轻代GC频率:每秒3-4次

Full GC频率:每5分钟1次(每次STW约2-3秒)

大促期间如果发生Full GC,所有请求都会卡顿2-3秒,这是不可接受的。

6.1 首先定位问题:内存快照分析
bash

导出堆内存快照

jmap -dump:live,format=b,file=heap.hprof

使用MAT或JProfiler分析,发现:

1. 订单对象占用堆内存35%(大量订单对象在请求结束后未及时回收)

2. 本地缓存(Caffeine)占用堆内存20%(设置过大)

3. 日志缓冲区占用10%(打印了过多的DEBUG日志)

6.2 JVM参数调优(重点)
调优前(问题配置):

bash
-Xms4g -Xmx4g -XX:NewRatio=2
-XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70
调优后(最优配置):

bash
-Xms8g -Xmx8g # 堆内存从4G提升到8G
-XX:NewRatio=1 # 年轻代:老年代 = 1:1(4G+4G)
-XX:+UseG1GC # 使用G1垃圾回收器(替代CMS)
-XX:MaxGCPauseMillis=100 # 目标最大GC暂停时间100ms
-XX:G1HeapRegionSize=16m # 每个Region大小16MB
-XX:G1ReservePercent=10 # 预留10%空间用于"垃圾"(防止Full GC)
-XX:InitiatingHeapOccupancyPercent=45 # 堆占用45%时启动并发标记(更早触发)
-XX:+PrintGCDetails # 打印GC详情
-XX:+PrintGCDateStamps
-Xloggc:/logs/gc.log # GC日志输出
6.3 代码层面的优化配合
JVM参数调优只能解决"表面"问题,根本优化还是要回到代码:

优化前(内存浪费):

java
// 每次请求都创建新的对象,且未及时释放
public OrderDetail getOrderDetail(Long orderId) {
Order order = orderService.getById(orderId);
List items = itemService.getByOrderId(orderId);
// 组装成超大对象返回
return OrderDetail.builder()
.order(order)
.items(items)
.user(userService.getById(order.getUserId())) // 额外的RPC调用
.coupon(couponService.getByOrderId(orderId)) // 额外的RPC调用
.logistics(logisticsService.getByOrderId(orderId)) // 额外的RPC调用
.build();
}
优化后(懒加载+分批查询):

java
public OrderDetail getOrderDetail(Long orderId) {
// 1. 只查订单核心信息
Order order = orderService.getById(orderId);

// 2. 使用懒加载代理,按需获取关联数据
return OrderDetailProxy.builder()
    .order(order)
    .itemsLoader(() -> itemService.getByOrderId(orderId))   // 用到时才查
    .userLoader(() -> userService.getById(order.getUserId())) 
    .couponLoader(() -> couponService.getByOrderId(orderId))
    .build();

}
6.4 调优效果对比
指标 调优前 调优后 优化幅度
年轻代GC频率 3-4次/秒 0.5次/秒 降低 85%
Full GC频率 每5分钟1次(STW 2-3秒) 几乎为0(每4-6小时1次,STW<50ms) 降低 99%
堆内存占用 峰值6.5G(经常触发Full GC) 稳定在5G左右 稳定
接口RT(TP99) 850ms 120ms 降低 86%
七、 容量规划:压测完了,怎么定配置?
压测数据出来之后,需要制定生产环境的容量规划:

7.1 容量计算公式
单实例QPS = 压测单实例QPS × (1 - 安全冗余系数)
实例数量 = 预估峰值QPS ÷ 单实例QPS
实际计算示例:

参数 数值 说明
单实例压测QPS 800 压测环境下单台服务器能扛的QPS
安全冗余系数 30% 预留30%应对流量抖动
单实例安全QPS 800 × 0.7 = 560 实际建议不超过560
预估峰值QPS 8000 大促预估流量
所需实例数 8000 ÷ 560 = 14.3 → 15台 向上取整,再加1台备用 = 16台
7.2 各层容量规划
组件 压测结果 容量规划 说明
应用服务器 单机 QPS 800 16台 8C16G 预留扩容空间
MySQL 单库 TPS 1200 主从+读写分离 读库3台,写库主从
Redis 单节点 QPS 15000 集群 6节点(3主3从) 支撑峰值+哨兵模式
RocketMQ 单Broker TPS 5000 4主4从集群 高峰预留2倍
Nginx 单机 QPS 20000 2台做负载均衡 足够了
7.3 限流阈值设置
根据容量规划,设置多级限流:

yaml

Sentinel限流配置

resources:

  • name: “下单接口”
    threshold: 5000 # 单机限流5000 QPS
    strategy: “链路限流”

  • name: “支付回调接口”
    threshold: 3000 # 防止回调流量打崩系统

  • name: “全局限流(网关层)”
    threshold: 10000 # 全局总限流10000 QPS(保护整体系统)
    八、 踩坑实录:压测中遇到的"三个意想不到"
    坑1:日志把磁盘写爆了
    压测过程中,我们发现应用服务器磁盘使用率突然飙升到100%。排查发现:日志打印了海量DEBUG级别日志,单台机器每小时产生80GB日志文件。

解决方案:

生产环境日志级别强制设为WARN(通过配置中心动态调整)。

关键业务日志采用采样打印(每100条打印1条)。

日志滚动策略:按天归档,单文件最大1GB,保留7天。

坑2:数据库连接池被"借光"了
压测发现,数据库连接数达到上限后,新请求全部等待,导致RT飙升。

问题代码:

java
// 错误:每次查询都新建连接,用完不释放
public List queryOrders() {
Connection conn = DriverManager.getConnection(url, user, password);
Statement stmt = conn.createStatement();
// …查询逻辑
// 没有 close() !!!
return orders;
}
解决方案:

使用HikariCP连接池,设置合理参数:

properties

HikariCP最佳实践配置

spring.datasource.hikari.maximum-pool-size=100 # 最大连接数
spring.datasource.hikari.minimum-idle=20 # 最小空闲连接
spring.datasource.hikari.idle-timeout=300000 # 空闲超时(5分钟)
spring.datasource.hikari.connection-timeout=30000 # 获取连接超时(30秒)
spring.datasource.hikari.leak-detection-threshold=60000 # 连接泄漏检测(60秒)
关键:所有Connection、Statement、ResultSet必须使用try-with-resources自动释放。

坑3:Redis连接数被耗尽
现象:压测进行30分钟后,Redis连接数达到1万上限,所有新请求全部报错ERR max number of clients reached。

原因:每次请求都创建新的Redis连接,用完不归还连接池。

解决方案:使用Lettuce连接池(支持异步、自动重连):

java
@Configuration
public class RedisConfig {

@Bean
public LettuceConnectionFactory redisConnectionFactory() {
    RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
    config.setHostName("redis-host");
    config.setPort(6379);
    
    LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
    // 设置连接池最大连接数
    factory.setPoolConfig(new GenericObjectPoolConfig() {{
        setMaxTotal(200);           // 最大连接数
        setMaxIdle(50);             // 最大空闲连接
        setMinIdle(10);             // 最小空闲连接
        setMaxWait(Duration.ofSeconds(30));
    }});
    factory.afterPropertiesSet();
    return factory;
}

}
九、 最终成果与压测常态化
经过两轮完整的全链路压测和调优,最终成果:

指标 压测前 压测后 优化幅度
核心链路峰值QPS 2000(单接口) 8000+(全链路) 提升 4倍
下单接口RT(TP99) 850ms 80ms 降低 90%
错误率 2.3% 0.01% 降低 99%
Full GC暂停 2-3秒/次 <50ms/次 降低 98%
数据库连接数 经常打满(200) 稳定在 120 可控
Redis连接数 经常耗尽(10000+) 稳定在 200 可控
压测常态化机制:

每次发版前:核心接口必须通过基准压测(QPS不低于上一次的80%)。

每周四:凌晨自动执行小规模全链路压测(5%流量模拟)。

大促前1个月:执行完整全链路压测,输出容量规划报告。

监控大屏:实时展示各服务的压测指标、限流阈值、健康状态。

十、 总结:全链路压测的"五个关键词"
最后用五个关键词总结全链路压测的核心:
在这里插入图片描述

文末思考:
全链路压测的本质不是"压测"本身,而是提前暴露问题。大促那天的流量不会等你准备好了再来。平时多流汗,战时少流血。

如果你还没有建立全链路压测体系,建议从核心链路(如"登录→下单→支付")开始,先跑起来,再逐步完善。哪怕初期只能覆盖50%的链路,也比完全不压测强10倍。

欢迎在评论区分享:你们的大促备战踩过哪些坑?全链路压测做了几轮?有没有JVM调优的经验可以交流?

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐