决战双十一:大促备战性能调优记录

1 前言

一年一度的双十一大促如台风过境般席卷而来又呼啸而去,它是对后端开发者过去一年技术沉淀的集中检阅。它用数以亿计的并发请求,无情地放大我们系统中每一个懒惰的循环、每一次不经意的锁竞争、每一个被忽略的慢查询。在平时,这些可能只是监控图上一个不起眼的毛刺;但在洪峰来临的瞬间,它们会像多米诺骨牌一样连锁倒下,最终演变成一场技术灾难。

本文将带你回顾,我们是如何从一次次的压力测试和线上事故中,抽丝剥茧,对系统进行深度性能调优的。这不仅仅是为了扛住那个特定的峰值,更是为了构建一个在任何时候都更加健壮、高效和可靠的系统。让我们一起,将双十一的“性能炼狱”,变为我们技术体系的“最佳试金石”。
在这里插入图片描述

2 经典案例

1、线程不安全方法

问题描述:在压测过程中发现,发生了消息丢失。在数万次请求中,发生几次、几十次消息丢失,还是挺难被察觉到的,所以针对这种发送MQ消息的接口做压测时,要做针对性检验,具体就是记录当次压测的请求成功次数,记为X,分别记录对应消息队列的压测前入队数和压测后入队数并作差,记为Y,如果Y<X,就意味着发生了消息丢失。
在这里插入图片描述
排查过程:

  1. 首先,消息丢失通常发生在三个阶段:生产消息阶段、存储消息阶段、消费消息阶段,这次的消息丢失现象为消息未入队列,因此只能是第一种情况,也就是Java进程压根就没有向MQ发送消息。
  2. 接下来,梳理接口逻辑,基本流程简略示意如下,接口被调用后,主线程将计算任务提交给线程池,线程池的一个线程执行计算后发送MQ消息。
    在这里插入图片描述
  3. 查看代码发现,线程池配置有点不太合理,一般cpu密集型任务,应将核心线程数设为cpu核心数+1,服务器是4核心的,但线程池核心线程竟然配置了200。
private ThreadPoolExecutor threadPool = new ThreadPoolExecutor(100, 200, Long.MAX_VALUE, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(1024*1024), new ReportThreadFactory("Report"));
  1. 以上配置显然不合理,于是将核心线程和最大线程设置为4、8,使用同样的目标TPS进行多轮压测,没有发生消息丢失。由于排期紧急,先期上线,但根源问题并未得到解决。
  2. 上个步骤说明,问题可能是过多线程并发导致的,但为何没有留下任务异常记录?于是排查调用链上的方法,注意到某个方法try…catch可能存在问题,这个段代码只catch了一个特定的异常,没有兜底逻辑,根据异常捕获机制:在 Java 中,catch 块仅能捕获其声明的异常类型或其子类。如果 catch 指定的异常类型与实际抛出的异常类型不匹配(且无继承关系),则该 catch 块不会执行,异常会继续向外层抛出,例如下面的代码。
try {
    int a = 10 / 0; // 抛出 ArithmeticException
} catch (NullPointerException e) { // 不匹配 ArithmeticException
    System.out.println("不会执行这里!");
}
  1. 高度怀疑是实际异常与声明异常不匹配,导致线程池的线程就那么悄无声息的终止了。进行处理后(增加finally或多个catch且最后一个catch声明Exception,再次压测,终于捕获到了相关异常:一个线程不安全的日期工具类引发的血案。
  2. 代码中使用到了SimpleDateFormat进行时间格式化处理,众所周知,SimpleDateFormat是线程不安全的,在并发情况下容易出现:Exception in thread "Thread-1" java.lang.NumberFormatException: For input string: ""
pubic class Demo{
	private static final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
	private static final SimpleDateFormat utcFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");//XXX为默认时区

	**
     * 将UTC时间转换为标准格式化时间
     */
    public static String utcToStr(String utcDate, String resTag) {
        try {
            logger.info("resTag :{}, date: {}", resTag, utcDate);
            return df.format(utcFormat.parse(utcDate));
        } catch (ParseException parseException) {
            logger.error("格式化错误!!!直接返回{}", utcDate);
            return utcDate;
        }
    }
}

解决办法:定位到具体问题,解决起来就简单了,按照Java8的推荐使用DateTimeFormatter,或者将utcToStr方法使用synchronized关键字修饰,保证其线程安全。

总结:

  1. 使用到线程池时,一定要注意配置的合理性,否则会造成意料不到的问题,不过在本案例中,不合理的配置反而成了问题暴露的导火索。
  2. 异常处理注意要进行兜底处理,防止实际异常未被捕获,对后续流程造成影响。
  3. 并发场景中,要注意切勿使用线程不安全的类和方法。

2、分布式锁的错误使用

问题描述:在一个拼团购场景测试过程中,发生一个概率很低的问题——测试用户支付成功后达成拼成条件(团满员即成团),但依然显示订单未支付,而团状态为完成。
在这里插入图片描述
这种发生概率低而又严重的问题是相当令人头疼的,首先的难题就是复现。考虑到偶现问题通常与多线程并发有关,这里采用了高并发压测方式来尝试复现,再通过增加更多细节日志去排查。

排查过程:

  1. 首先梳理场景流程,简略流程如下图。订单分为普通订单和拼团订单,两种订单判断用户是否真正购买成功的逻辑不同——如果是普通订单,则只判断订单状态是否为【已支付】,是的话则判定用户购买成功;如果是拼团订单,则需要订单状态为【已支付】且拼团状态为【已成团】,才会判定用户购买成功。拼团模块收到订单模块支付成功的消息后,判断已成团,会修改订单的拼团状态为【已成团】。
    在这里插入图片描述
  2. 首先排查异常案例,确认支付流程没有问题:所有订单都正常接收到了三方支付接口支付成功的消息,也就是说与外部支付接口的交互没有问题
  3. 进一步排查发现,订单模块是有正确修改过订单状态的,但最终却诡异地被改回去了,除了订单模块,那只有拼团模块有修改订单的可能了。拼团模块处理订单行的示意代码如下:
// 读取订单信息
OrderDto orderDto = OrderService.getOrderById(orderId);
// 将订单拼团状态置为已成团
orderDto.setGroupStatus("1");
// 更新订单行
orderService.updateOrder(orderDto);
  1. 通过日志观察到,拼团模块修改订单行的订单实体中,订单状态竟然是【待支付】,到此基本破案:在大多数情况下,订单模块修改订单行会早于拼团模块读取订单行,这样拼团模块读到的就是最新数据,但在某些特殊情况下,拼团模块后发先至,在订单模块修改订单之前就读取订单行,这样就读取到了过时数据,而后订单模块来修改订单了,紧接着拼团模块再次修改订单行,用错误的订单状态覆盖了订单行。这也是问题偶发且概率较低的一个原因。
    在这里插入图片描述
  2. 但在这种场景下,不同模块处理同一个订单,必须有分布式锁来限制并发,为什么分布式锁没有生效呢?
// 伪代码:使用Redis分布式锁(Redisson实现)
// 伪代码:订单状态更新
public boolean updateOrderStatus(String orderNo, String newStatus) {
    String lockKey = "order:status:lock:" + orderNo;
    RLock lock = redisson.getLock(lockKey);
    
    try {
        if (lock.tryLock(2, 5, TimeUnit.SECONDS)) {
            Order order = orderService.getByNo(orderNo);
            // 检查当前状态是否允许变更(如不能从"已取消"→"已完成")
            if (!order.canChangeTo(newStatus)) {
                throw new RuntimeException("状态变更非法!");
            }
            return orderService.updateStatus(orderNo, newStatus);
        }
    } finally {
        lock.unlock();
    }
    return false;
}
  1. 走查两个模块的代码发现,其实是一个非常低级的错误:订单模块使用orderId作为分布式锁的key,而拼团模块使用orderNo作为分布式锁的key,这当然导致分布式锁没有起到防并发的作用。

总结:

  1. 团队中关于一些通用规则要对齐和贯彻,例如分布式锁key的选取、数据库字段命名习惯等等。
  2. 对于这种复杂场景,要设计好并发测试场景,以发现和拦截手工测试难以发现的问题。

3、串行调用

现象:发票特征查询接口性能不佳,平均响应时间高达3s,梳理逻辑发现,该接口依赖大量外部接口获取数据后再进行计算,而这些外部接口是比较耗时的,加之是串行调用,导致接口性能十分低下。

解决方案:利用线程池管理并发任务,并借助CompletableFuture异步编程工具进行编排,可以使这些调用被同时发起。整个调用的总耗时将不再是个服务耗时的累加,而是取决于其中最慢的那个服务的响应时间,从而大幅降低整体延迟。这种方法尤其适用于依赖服务众多、逻辑链较长的复杂业务场景,是提升系统吞吐量和响应速度的关键技术手段。
在这里插入图片描述

4、频繁GC

GC,也就是垃圾回收,对系统的性能影响主要表现在CPU使用率、响应时间抖动,
在这里插入图片描述
对于GC问题的治理是一个非常庞大的课题了,这里简单记录几个遇到的案例。
1、循环前提前排除不匹配目标

private void handleByRule(BusinessReqContent businessReqContent) {
    Iterator<ReqVo> iterator = subReqs.iterator();
    while (iterator.hasNext()) {
        BusinessReqVo businessReqVo = iterator.next();
        // 根据规则再次循环调用
        for (int i = 0; i < preBizRuleList.size(); i++) {
            BizFilter bizFilter = preBizRuleList.get(i);
            //人群过滤
            ..........
        }
    }
}

对集合直接双重循环处理,当请求量增大时,导致频繁的函数调用和对象创建,引发大量GC,影响性能。

解决方案:增加预处理逻辑,将不匹配目标提前排除,剩余目标进入循环。此举大幅减少循环次数,实现时间和空间占中的双重降低。

2、集合迭代器使用不当

private boolean filter( List<Filter> filters){
    Iterator<Filter> iterator = filters.iterator();
    while (iterator.hasNext()) {
        Filter filter = iterator.next();
        // 处理逻辑
    }
}

遍历集合时使用迭代器(Iterator)会带来额外的对象创建和方法调用开销。迭代器对象的实例化及其hasNext()、next()等方法会增加调用的成本,使得循环体内的指令更加复杂。

解决方案:
改用数组循环,可以显著提升执行效率。

3、字符串使用问题

String的不当使用会导致创建大量对象,例如:

  1. 字符串拼接:
public static BizResult bizHandler(){
    // 拼接测试标识
    CallerInfo callerInfoTotal = Profiler.registerInfo("taobao.marketing.engine.selectAll"+(RpcContext.isTest()?".stressFlag":""));
    return callerInfoTotal;
}

对于这种情况,建议直接设置字符串常量,直接使用。

  1. String.format()替换为StringBuilder
// 不当使用
public static String handle(String key) {
    return String.format("vip_{%s}", key);
}

// 替换为
public static String handle(String key) {
     return new StringBuilder().append("vip_{").append(key).append("}").toString();
}

4、减少反射的使用

反射过程中获取Method对象会创建大量临时对象,这些临时对象会迅速占满新生代,导致GC被频繁触发,在高并发场景下,这种开销是必须优化的重点。

// 使用反射
Object oldVal = CacheService.class.getMethod(methodName, ClassLoaderUtils.getClazzByArgs(args)).invoke(cacheServiceOld, args);
return oldVal;
// 直接方法调用
cacheServiceImpl.sMembers(key, cacheListKey)

5、避免大对象的直接分配

JVM为提升效率,设有“大对象直接进入老年代”的机制。频繁创建大数组、大字符串或未指定初始容量而导致频繁扩容的集合(如ArrayList、HashMap),会瞬间撑满老年代,引发频繁的老年代回收。

private Set<String> handle(String str){
	if (!StringUtils.isEmpty(str)) {
  		Set<String> set = new HashSet<>(JSONArray.parseArray(str, String.class));
    	return set;
	}
}

将大对象按功能拆分,可以有效避免其因存活时间过长而直接进入老年代,从而显著降低老年代的内存占用,是一种重要的内存优化策略。

//多线程查询
private Set<String> handle(String str){
	List<Future<Set<String>>> futureList = new ArrayList<>();
	for (int i = 1; i <= keyNum; i++){
	    int finalI = i;
	    Future<Set<String>> submit = consumeBigKeyThreadPool.submit(() -> {
	        
	        if (!StringUtils.isEmpty(str)) {
	            Set<String> set = new HashSet<>(JSONArray.parseArray(str, String.class));
	            return set;
	        }
	        return new HashSet<>();
	    });
	    futureList.add(submit);
	}
}

3 性能调优原则

  1. 循环优化:在进入循环前尽可能排除不符合条件的对象,减少循环次数;遇到多层循环时,好好考虑能否用空间换时间;
  2. 减少传输报文大小:在与数据层面或者应用间传输对象时,只传递有效字段的值,减少传输体积、做好内容压缩。
  3. 减少网络传输报文大小:例如目前商品主数据接口就是这么做的,需要获取的字段信息需要申请,只返回自己需要的字段,不会返回过多别的字段;减少报文大小、字段精简、长度压缩、内容压缩等也是常用手段;
  4. 做好流量漏斗:采用布隆进行流量漏斗,或者将热点请求提前计算好,减少下游代码的调用;
  5. 批量调用:这也是减少网络消耗的常用手段,对于缓存可以使用hashtag将相同用户的数据打到一个集群的一个分片中,然后使用pipeline一次获取;
  6. 选择适合的数据结构和集合大小:减少copy操作;java的集合操作addAll、putAll等操作会消耗大量CPU;可尽量减少集合类的使用和操作,特别是大数据情况;
  7. 快速失败机制:这个话题有很长的历史,但真正能做好的系统非常少;还是需要对系统进行深入研究,做好流量漏斗;如果后续流程中都可能存在流程中断的情况,那么就可以好好考虑能否前置;
  8. 并行处理:可以用多线程、进行并行处理;但需要注意别浪费了主线程;
  9. 异步处理:当前核心流程非必须100%依赖的操作考虑异步处理,要注意对异步失败场景进行兜底和补偿处理。

另外还有减少GC原则:

  1. 使用合适的数据结构:数据结构优先考虑简单化,比如尽量使用int而非Integer、long,尽量不适用Map。
  2. 减少复制:对象的复制和集合的扩容都容易产生大量垃圾,引发GC。
  3. 减少长生命周期的大对象:避免直接创建大对象,而是将大数据拆分后创建小对象。

经历有限,先写这些,感兴趣的同学可以多多发表意见,后续有新的心得会进行更新。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

云深i不知处

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值