⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 geekhalo 「geekhalo」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

还原故障现场,挖掘通用场景,寻求最佳解决方案!!!

1. 问题&分析

当我们在处理慢接口问题时,经常会使用多线程技术,将能够并行处理的任务拆分到不同的线程中处理,等任务处理完成后,再收集各线程的处理结果,进行后续的处理。整体思路如下图所示:

这样可以将并行部分的总耗时从 sum 降为 max,从而大幅降低接口的响应时间。

1.1. 案例

订单详情页耗时严重,p99 将近3秒,已经验证影响用户体验,本次迭代小艾专门对该接口进行优化。迭代刚上线,该接口的响应时间大幅降低,p99 降低到 800 毫秒以内,大家纷纷向小艾发来祝贺。但好景不长,随着流量的增加,接口响应时间也在逐渐变长,p99 超过 5 秒,最后系统抛出大量的 RejectedExecutionException 异常,这个接口不可用。最终,QA伙伴火速进行回滚操作,系统恢复正常。

系统恢复后,小艾仔细查看系统监控,CPU使用率并不高,内存也处于正常水位,接口性能居然比优化前还差,真心不知道哪里出了问题。

优化前代码:

public RestResult<OrderDetailVO> getOrderDetail(@PathVariable Long orderId){
Stopwatch stopwatch = Stopwatch.createStarted();
OrderService.Order order = this.orderService.getById(orderId);
if (order == null){
return RestResult.success(null);
}
OrderDetailVO orderDetail = new OrderDetailVO();
orderDetail.setUser(userService.getById(order.getUserId()));
orderDetail.setAddress(addressService.getById(order.getUserAddressId()));
orderDetail.setCoupon(couponService.getById(order.getCouponId()));
orderDetail.setProduct(productService.getById(order.getProductId()));
log.info("串行 Cost {} ms", stopwatch.stop().elapsed(TimeUnit.MILLISECONDS));
return RestResult.success(orderDetail);
}

优化前耗时:

优化后代码:

public RestResult<OrderDetailVO> getOrderDetailNew(@PathVariable Long orderId){
Stopwatch stopwatch = Stopwatch.createStarted();
OrderService.Order order = this.orderService.getById(orderId);
if (order == null){
return RestResult.success(null);
}
Future<UserService.User> userFuture = this.executorService.submit(() -> userService.getById(order.getUserId()));
Future<AddressService.Address> addressFuture = this.executorService.submit(() -> addressService.getById(order.getUserAddressId()));
Future<CouponService.Coupon> couponFuture = this.executorService.submit(() -> couponService.getById(order.getCouponId()));
Future<ProductService.Product> productFuture = this.executorService.submit(() -> productService.getById(order.getProductId()));

OrderDetailVO orderDetail = new OrderDetailVO();
orderDetail.setUser(getFutureValue(userFuture));
orderDetail.setProduct(getFutureValue(productFuture));
orderDetail.setAddress(getFutureValue(addressFuture));
orderDetail.setCoupon(getFutureValue(couponFuture));
log.info("并行 Cost {} ms", stopwatch.stop().elapsed(TimeUnit.MILLISECONDS));
return RestResult.success(orderDetail);
}

优化后耗时:

可见采用并行优化后,接口的响应时间从 4 秒 将至 1 秒,效果还是非常明显的。

但,继续加大请求量,系统便出现问题,如下图所示:

在流量逐渐增加的过程中,从日志中可以得到以下信息:

  1. 初期耗时稳定,基本在 1 秒左右
  2. 接口耗时逐渐增大,甚至远超串行处理的耗时(大于 4 秒)
  3. 有些请求直接抛出 RejectedExecutionException 异常

1.2. 问题分析

从代码中并未发现任何问题,设计思路也非常清晰,其核心问题在线程池使用上,项目线程池配置如下:

int coreSize = Runtime.getRuntime().availableProcessors();
executorService = new ThreadPoolExecutor(coreSize, coreSize * 5,
5L, TimeUnit.MINUTES,
new LinkedBlockingQueue<Runnable>(1024)
);

核心配置为:

  1. 核心线程数为 cpu 核数
  2. 最大线程数为 cpu 核数的 5 倍
  3. 空闲线程存活时间为 5 分钟
  4. 任务队列为 LinkedBlockingQueue 大小为 1024

在这个配置下,我们推演下以上的三个现象。

1.2.1. 线程资源充足

如下图所示:

整体流程如下:

  1. 主线程向线程池提交 Task
  2. 由于线程处于空闲状态,立即接受并处理问题
  3. 线程池线程处理完任务,将最终的处理结果写回到 Future
  4. 主线程等待所有任务执行完成,获取所有执行结果,然后执行后续流程

这正是想要的执行结果,任务被并行执行,大幅降低接口耗时。

1.2.2. 任务进入等待队列

随着流量的增加,所有的核心线程都处于忙碌状态,此时新任务将进入等待队列,具体如下:

整体流入如下:

  1. 主线程向线程池提交任务

  2. 由于没有核心线程可用,任务被放置到任务队列

  3. 主线程进入等待状态,等待时间包括两部分:

    1. 任务在队列中等待线程调度时间
  4. 任务分配到线程后,任务实际执行时间

  5. 如果前面等待的任务非常多,那等待时间将变的非常长

主线程等待时间 = 队列等待时间 + 任务执行时间。当任务队列非常长时,整体时间将远超串行执行时间。

1.2.3. 资源耗尽触发拒绝策略

流量继续增加,线程池的任务队列已满并且线程数量也达到上限,此时会触发拒绝策略,具体如下:

线程池默认拒绝策略为:AbortPolicy,直接抛出 RejectedExecutionException,从而触发接口异常。

还有更可怕的情况,就是部分提交,也就是主线程已经成功提交几个任务,如下图所示:

核心流程如下:

  1. 主线程已经成功提交两个任务
  2. 在提交第三个任务时,由于资源不够触发拒绝策略,抛出异常导致主线程提前结束
  3. 已经成功提交的任务仍旧会被线程执行,由于主线程已经退出,执行结果没有任何意义,从而白白浪费系统资源

2. 解决方案

前面已经分析的很清楚,问题的本质就是线程池资源分配不合理,核心参数设置错误:

  1. 队列设置错误。在该场景下,需要充分利用线程资源,将任务放入队列会增加任务在队列的等待时间,队列长度越大对系统的伤害越大;
  2. 拒绝策略设置错误。直接抛出异常会中断主流程,导致部分无效任务(无意义任务)提交,白白浪费系统资源;

除线程池参数问题外,还有个小问题:主线程完成任务提交后处于等待状态,未执行任何有意义的操作,存在资源浪费。

2.1. 线程池改进方案

改进线程池如下所示:

int coreSize = Runtime.getRuntime().availableProcessors();
executorService = new ThreadPoolExecutor(coreSize, coreSize * 5,
5L, TimeUnit.MINUTES,
new SynchronousQueue<>(),
new ThreadPoolExecutor.CallerRunsPolicy()
);

线程池配置如下:

  1. 核心线程数不变,仍旧是 cpu 数;
  2. 最大线程数不变,仍旧是 cpu 数的5倍;
  3. 空闲线程存活时间不变,仍旧是 5 分钟;
  4. 使用 SynchronousQueue 替代 LinkedBlockingQueue(1024)。SynchronousQueue 是一个特殊的队列,其最大容量是1。也就是说,任何一次插入操作都必须等待一个相应的删除操作,反之亦然。如果没有相应的操作正在进行,则该线程将被阻塞;
  5. 指定拒绝策略为 CallerRunsPolicy。当线程池资源不够时,由主线程来执行任务;

在这个配置下,及时线程池中的所有资源全部耗尽,也只会降级到串行执行,不会让系统变的更糟糕。

新配置下,系统表现如下:

在最差的情况下也仅仅与串行执行耗时一致。

总体来说就一句话:线程池有资源可用,那就为主线程分担部分压力;如果没有资源可用,那就由主线程独自完成。

2.1. 充分利用主线程

上面提到一个小问题,在资源充足情况下,所有任务均有线程池线程完成,主线程一致处于等待状态,存在一定的资源浪费。

如下图所示:

3 个任务耗费 4 个线程资源:

  1. 线程池3个线程负责执行任务
  2. 主线程等待执行结果,一直处于阻塞状态

为了充分利用线程资源,可以让主线程负责执行任意一个任务。如下图所示:

主线程不在盲目等待,也负责一个任务的执行,这样 3 个任务只需 3 个线程即可。

代码上也非常简单,具体如下:

public RestResult<OrderDetailVO> getOrderDetailNew(@PathVariable Long orderId){
Stopwatch stopwatch = Stopwatch.createStarted();
OrderService.Order order = this.orderService.getById(orderId);
if (order == null){
return RestResult.success(null);
}
Future<UserService.User> userFuture = this.executorService.submit(() -> userService.getById(order.getUserId()));
Future<AddressService.Address> addressFuture = this.executorService.submit(() -> addressService.getById(order.getUserAddressId()));
Future<CouponService.Coupon> couponFuture = this.executorService.submit(() -> couponService.getById(order.getCouponId()));
// Future<ProductService.Product> productFuture = this.executorService.submit(() -> productService.getById(order.getProductId()));

OrderDetailVO orderDetail = new OrderDetailVO();
// 由主线程负责运行
orderDetail.setProduct(productService.getById(order.getProductId()));

orderDetail.setUser(getFutureValue(userFuture));
orderDetail.setAddress(getFutureValue(addressFuture));
orderDetail.setCoupon(getFutureValue(couponFuture));
log.info("并行 Cost {} ms", stopwatch.stop().elapsed(TimeUnit.MILLISECONDS));
return RestResult.success(orderDetail);
}

主线程执行不同的任务,会对接口的响应时间产生影响吗?

不会,并行执行整体耗时为 max(任务耗时),主线程必须获取全部结果才能运行,所以必须等待这么长时间。

  1. 如果主线程运行的任务不是最耗时任务,则需要等待最耗时任务执行完成才能执行后续逻辑;
  2. 如果主线程运行的是最耗时任务,则其他线程已经执行完成并提前释放资源;

3. 示例&源码

https://gitee.com/litao851025/learnFromBug/tree/master/src/main/java/com/geekhalo/demo/thread/paralleltask

文章目录
  1. 1. 1. 问题&分析
    1. 1.1. 1.1. 案例
    2. 1.2. 1.2. 问题分析
      1. 1.2.1. 1.2.1. 线程资源充足
      2. 1.2.2. 1.2.2. 任务进入等待队列
      3. 1.2.3. 1.2.3. 资源耗尽触发拒绝策略
  2. 2. 2. 解决方案
    1. 2.1. 2.1. 线程池改进方案
    2. 2.2. 2.1. 充分利用主线程
  3. 3. 3. 示例&源码