大家好,我是程序员木木熊。
欢迎大家点赞-评论-关注,也可以关注公众号【程序员木木熊】,了解更多后端技术知识!!
接口性能优化是程序员必备的技能之一,随着系统的上线,业务数据的累积、业务需求和逻辑越来越复杂,原本性能尚可的接口,可能会因为最初不合理的设计导致性能越来越差,影响客户体验,严重一点可能导致系统不可用。
掌握接口优化的常用套路和方法,不仅可以在系统初期就充分考虑到性能问题,也能在系统出现性能瓶颈时使用适当的方法进行优化。
木木熊结合平时的工作和学习中常见的性能问题,对一些场景和解决方案,进行了梳理,分享出来大家一起探讨学习。本文分享的是利用多线程对接口进行优化,姑且叫做多线程篇。
一、异步处理:主次有序
异步处理最常见的处理方式有线程、MQ、事件通知等,本主要介绍线程相关的方式。
一般的业务接口都会有一个主要业务逻辑和次要业务逻辑,有点像游戏里面的主线任务和支线任务。主要业务逻辑一般是重要的、核心的、影响执行结果和流程的,像订单创建,支付等,而次要业务逻辑,一般是主要逻辑的附属操作不影响整体业务的结果,像消息通知、数据埋点、日志记录等。
以创建订单的例子来说明,先看看不使用异步处理,代码同步串行执行,createOrder方法总耗时为200ms。如果改用线程进行异步处理,在保存完订单,向线程池提交发消息和记录日志的任务后,就可以立即返回。提交任务时间基本可以忽略不计,故createOrder方法总耗时直接降为100ms,为串行执行的50%。
同步执行示例代码如下
//创建订单-串行执行
public void createOrder() {
//核心逻辑 - 保存order 100ms
saveOrder();
//发送消息 - 50ms
msgService.sendMsg();
//记录日志 - 50ms
oprLogService.saveOprLog();
//执行完成后返回,总耗时100 + 50 + 50 = 200ms
}
下面介绍三种,常见的通过线程进行异步处理的方法,线程池、Spring注解@Async和CompletableFuture,代码如下
1.直接使用线程池
//创建订单-异步执行-直接使用线程池
public void createOrderAsync1() {
//核心逻辑 - 保存order 100ms
saveOrder();
//次要逻辑(附属操作) - 发送消息、记录日志
//发送消息
msgThreadPool.submit(msgService::sendMsg);
//记录日志
oprLogThreadPool.submit(oprLogService::saveOprLog);
//提交任务后就可以返回,无需等待执行,总耗时100ms
}
2.使用Spring注解@Async
@EnableAsync注解启用Spring的异步支持
@SpringBootApplication
@EnableAsync
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
@Async注解修饰需要异步的方法,最好指定线程池
//消息service
public class MsgService {
//指定线程池
@Async("msgThreadPool")
public void sendMsg() {
log.info("发送消息成功");
}
}
//日志service
public class OprLogService {
//指定线程池
@Async("oprLogThreadPool")
public void saveOprLog() {
log.info("保存操作日志");
}
}
createOrder方法和同步执行一致,无需做特殊改动。同事务注解@Transactional一样,需要注意注解失效的相关情况,这里不再赘述,后续可能单独写一篇关于Spring注解失效的文章。
3.使用CompletableFuture.runAsync()方法
使用CompletableFuture.runAsync方法,异步执行
//创建订单-异步执行-CompletableFuture.runAsync
public void createOrderAsync2() {
//核心逻辑 - 保存order 100ms
saveOrder();
//次要逻辑(附属操作) - 发送消息、记录日志
//发送消息
CompletableFuture.runAsync(msgService::sendMsg, msgThreadPool);
//记录日志
CompletableFuture.runAsync(oprLogService::saveOprLog, oprLogThreadPool);
//提交任务后就可以返回,无需等待执行,总耗时100ms
}
二、串行改并行:多车道高速公路
通常我们的业务逻辑不都是靠系统内部的逻辑完成,可能需要依赖Rpc调用,存在不同数据源的查询(MySQL,ES,MongoDB),最终对数据进行聚合。当这些操作之间不具有先后逻辑关系时,我们可以考虑通过串行代码改成并行执行的方式,来减少因为网络请求带来的性能消耗。
我们已查询订单详情的例子来说明,需要新查询订单基本信息,再基于订单中关联的字段,依次获取客户信息、产品信息、支付信息,最后基于这些信息进行组装。
如果正常的串行执行,接口queryOrderDetail的总耗时为250ms。如果并行改串行,把客户信息、产品信息、支付信息并行查询,那么接口耗时理想情况下可以优化到150ms。
下面是串行执行的代码
public OrderDetailDTO queryOrderDetail(String orderNum) {
//查询订单信息 100ms
OrderDTO orderDTO = queryByOrderNum(orderNum);
//查询客户信息 50ms
String uid = orderDTO.getUid();
UserDTO userDTO = userApi.queryByUid(uid);
//查询产品信息 50ms
String productId = orderDTO.getProductId();
ProductDTO productDTO = productApi.queryId(productId);
//查询支付信息 50ms
String payId = orderDTO.getPayId();
PayDTO payDTO = payApi.queryId(payId);
//组装结果,累计耗时,100 + 50 + 50 + 50 = 250ms
return assemblyOrderDetail(orderDTO, userDTO, productDTO, payDTO);
}
下面介绍两种常见的串行改并行的实现方案CountDownLatch 和CompletableFuture
1.使用CountDownLatch + Future阻塞等待
这里不直接使用Future.get,而是使用CountDownLatch 的方式进行阻塞等待,这样可以减少主线程的空闲等待时间。设置超时时间500ms,避免以避免长时间等待某个任务完成而阻塞主线程
public OrderDetailDTO queryOrderDetailParallel1(String orderNum) {
// 查询订单信息 100ms
OrderDTO orderDTO = queryByOrderNum(orderNum);
// 等待3个并行任务完成
CountDownLatch countDownLatch = new CountDownLatch(3);
// 查询客户信息 50ms
String uid = orderDTO.getUid();
Future<UserDTO> userFuture = orderQueryThreadPool.submit(() -> {
UserDTO userDTO = userApi.queryByUid(uid);
countDownLatch.countDown();
return userDTO;
});
// 查询产品信息 50ms
String productId = orderDTO.getProductId();
Future<ProductDTO> productFuture = orderQueryThreadPool.submit(() -> {
ProductDTO productDTO = productApi.queryId(productId);
countDownLatch.countDown();
return productDTO;
});
// 查询支付信息 50ms
String payId = orderDTO.getPayId();
Future<PayDTO> payFuture = orderQueryThreadPool.submit(() -> {
PayDTO payDTO = payApi.queryId(payId);
countDownLatch.countDown();
return payDTO;
});
try {
// 等待所有任务完成
countDownLatch.await(500L, TimeUnit.MILLISECONDS);
UserDTO userDTO = userFuture.get();
ProductDTO productDTO = productFuture.get();
PayDTO payDTO = payFuture.get();
// 组装结果,累计耗时,100 + 50 = 150ms
return assemblyOrderDetail(orderDTO, userDTO, productDTO, payDTO);
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException("查询订单信息异常", e);
}
}
2.使用CompletableFuture.supplyAsync()方法
使用CompletableFuture.allOf(...).join()的方式进行阻塞等待
public OrderDetailDTO queryOrderDetailParallel2(String orderNum) {
// 查询订单信息 100ms
OrderDTO orderDTO = queryByOrderNum(orderNum);
// 查询客户信息 50ms
String uid = orderDTO.getUid();
CompletableFuture<UserDTO> userFuture = CompletableFuture.supplyAsync(
() -> userApi.queryByUid(uid), orderQueryThreadPool);
// 查询产品信息 50ms
String productId = orderDTO.getProductId();
CompletableFuture<ProductDTO> productFuture = CompletableFuture.supplyAsync(
() -> productApi.queryId(productId), orderQueryThreadPool);
// 查询支付信息 50ms
String payId = orderDTO.getPayId();
CompletableFuture<PayDTO> payFuture = CompletableFuture.supplyAsync(
() -> payApi.queryId(payId), orderQueryThreadPool);
try {
//阻塞等待
CompletableFuture.allOf(userFuture, payFuture, payFuture).join();
UserDTO userDTO = userFuture.get();
ProductDTO productDTO = productFuture.get();
PayDTO payDTO = payFuture.get();
// 组装结果,累计耗时,100 + 50 = 150ms
return assemblyOrderDetail(orderDTO, userDTO, productDTO, payDTO);
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException("查询订单信息异常", e);
}
}
三、线程池优化:合理调配系统资源
线程在实际工程实现中,往往都不会直接创建线程,而是配合线程池进行,这样能更好的对线程进行管理,同时减少频繁创建和销毁线程的开销。
我们先回顾一下线程池的基本流程
从图中我们不难发现,核心线程、阻塞队列、最大线程数量、拒绝策略是线程池中需要特别关注的几个参数。
1. 合理的参数设置
对线程池的合理配置,也是充分利用系统资源和提升系统性能的的有效手段。
如果核心线程数过小,可能出现任务堆积的问题。
如果阻塞队列设置不合理,长度设置过大或使用无界队列,可能会导致OOM,而设置过小,又起不到缓冲的作用。
如果最大线程数设置过大,会导致线程无限创建,最终导致OOM。
而拒绝策略则需要我们基于实际的业务需要,进行合理选择。
如果任务允许丢弃,使用默认的AbortPolicy直接抛出异常即可,也可选择DiscardOldestPolicy策略,丢弃最老的任务;
如果任务不允许丢弃,使用CallerRunsPolicy策略,让当前线程执行任务;
如果有特殊要求,比如对于无法执行的任务,需要插入数据库或者发MQ进行重试处理,那么可以选择自行实现RejectedExecutionHandler接口。
1.1 核心线程数设置:N * (x + y) / x
核心线程数的设置,需要参考CPU核数和线程使用CPU时间占比。 如果CPU核数为N,业务线程的本地计算时间x,等待时间为y,核心线程数为N * (x + y) / x, 可以让CPU利用率最大化。
另外,下面是比较常见的经验设置
①CPU密集型应用,则线程池大小设置为N+1(或者是N),线程的应用场景:主要是复杂算法
②IO密集型应用,则线程池大小设置为2N+1(或者是2N),线程的应用场景:主要是:数据库数据的交互,文件上传下载,网络数据传输等等。
+1的原因:即使当计算密集型的线程偶尔由于缺失故障或者其他原因而暂停时,这个额外的线程也能确保CPU的时钟周期不会被浪费
我们参看前面的公式N * (x + y) / x,看看上面的经验设置
①对于CPU密集型,相当高于(x + y) / x = 1,即x >> y,即计算时间远远大于等待时间
②对于IO密集型,相当高于(x + y) / x = 2,即计算时间和网络或IO等待时间各占50%
这么一看,好像也挺合理的,哈哈哈
如果要把CPU性能压榨到极致,可能需要基于对线程的CPU时间占比进行量化分析,一般可以通过日志或监控工具绩效统计。
1.2 不推荐使用Executors创建线程池
①newFixedThreadPool固定大小线程池
//固定大小线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
//无界队列
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
创建了一个核心线程数和最大线程数相等线程池,但是队列使用的是无界队列,默认大小为Integer.MAX_VALUE 如果超过核心线程数之,后续的任务只会往队列中放,而不会触发流程图里的创建新线程和拒绝策略。当任务无限堆积时,可能导致OOM,故不推荐使用
②newSingleThreadExecutor单线程线程池
//单线程线程池
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
单线程线程池其实就是上面newFixedThreadPool固定数量为1的特例,故不推荐使用
③newCachedThreadPool缓存的线程池
//缓存的线程池
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
创建一个核心线程为0,最大线程为Integer.MAX_VALUE的线程池,线程存活时间是60s,且使用了同步队列SynchronousQueue
当任务量上来时,会源源不断的创建线程,而不是进入队列。如果并发很高,巨量的线程创建会导致内存耗尽和CPU调度压力激增,最终导致系统崩溃,故不推荐使用
④ScheduledThreadPoolExecutor定时调度线程池
//定时调度线程池
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue());
}
创建了一个指定核心线程数,最大线程数为Integer.MAX_VALUE,且队列使用DelayedWorkQueue延迟队列,但是因为DelayedWorkQueue是一个无界队列,所以最大线程数实际也是不会生效的
因为使用的是无界队列,当任务无限堆积时,也可能导致OOM,故不推荐使用
2. 不要使用默认线程池
2.1 CompletableFuture默认线程池
在JDK8中,有多个API的默认实现都使用了ForkJoinPool.commonPool提供的线程池。 其中最重要的两个就是,并行流parallelStream和CompletableFuture。
ForkJoinPool.commonPool() 返回的是ForkJoinPool的一个静态常量common,是一个基于系统参数创建的公共线程池,初始化代码如下
CompletableFuture在使用时,如果不指定线程池,出现线程池资源竞争激烈时,也会导致接口性能下降,解决方案就是指定业务自定义线程池。
2.2 @Async 默认线程池
Spring的@Async注解默认使用的是Spring内部定义的名为"taskExecutor"的线程池,具体类型为ThreadPoolTaskExecutor(Spring对java线程池的封装)。
从上面的代码可以看出,默认线程池的默认配置任务队列大小和最大线程数都是Integer.MAX_VALUE,极端情况也可能导致系统崩溃不可用。
所有用@Async注解标识的方法,如果没有设置独立的线程池都会使用这个默认线程池,那么如果其中有执行时间非常长的任务,比如批量处理的定时任务,那么可能导致其他对实时性要求很高的任务阻塞。
使用@Async注解,最好自己定义业务线程池,进行隔离
2.3 @Schedule 默认线程池
翻看@Schedule相关的核心源码ScheduledTaskRegistrar类中的scheduleTasks方法。
不难发现,如果使用@Schedule来实现定时任务,在未指定线程池的情况下,使用的是一个单线程线程池,并行度太低,如果某个任务执行时间太长,可能会导致任务阻塞,不能按照提前预定时间如期执行。
并且使用@Schedule执行定时任务没法终止或暂停任务,也不能实现动态的调整任务的执行周期,且任务是单机执行,集群环境下还需要考虑任务冲突和任务执行效率底下的问题,所以不推荐实现,建议使用分布式定时任务,如xxl-job。(PS:后续会单独写一篇文章,介绍xxl-job)
3. 线程池业务隔离
实际工程中,我们可能存在多种多样的需求要用到线程池,不同的场景对时效的要求不一样,核心业务和边缘业务对时效的要求也不一样。
如果线程池不区分业务隔离,有可能核心业务被边缘业务拖垮,最终导致系统不可用。因此建议实际工程中应该对线程池进行统一管理,区分主次,区分业务。线程池的参数设置上,也应该按照前文所述的IO密集型和CPU密集型有所不同。
--- END
本文是接口【性能优化秘籍】系列的第一篇,篇幅有限,部分问题并没有进行深入探究,如果大家有什么疑问和建议,欢迎评论区讨论~~
欢迎大家点赞-评论-关注,另外也可以关注公众号【程序员木木熊】,了解更多后端技术知识!!
微信公众号海量Java、架构、面试、算法资料免费送~