快一点!再快一点!!接口性能优化多线程篇

2,169 阅读13分钟

大家好,我是程序员木木熊。

欢迎大家点赞-评论-关注,也可以关注公众号【程序员木木熊】,了解更多后端技术知识!!

接口性能优化是程序员必备的技能之一,随着系统的上线,业务数据的累积、业务需求和逻辑越来越复杂,原本性能尚可的接口,可能会因为最初不合理的设计导致性能越来越差,影响客户体验,严重一点可能导致系统不可用。

接口优化秘籍18式

掌握接口优化的常用套路和方法,不仅可以在系统初期就充分考虑到性能问题,也能在系统出现性能瓶颈时使用适当的方法进行优化。

木木熊结合平时的工作和学习中常见的性能问题,对一些场景和解决方案,进行了梳理,分享出来大家一起探讨学习。本文分享的是利用多线程对接口进行优化,姑且叫做多线程篇。

一、异步处理:主次有序

异步处理最常见的处理方式有线程、MQ、事件通知等,本主要介绍线程相关的方式。

一般的业务接口都会有一个主要业务逻辑次要业务逻辑,有点像游戏里面的主线任务和支线任务。主要业务逻辑一般是重要的、核心的、影响执行结果和流程的,像订单创建,支付等,而次要业务逻辑,一般是主要逻辑的附属操作不影响整体业务的结果,像消息通知、数据埋点、日志记录等。

以创建订单的例子来说明,先看看不使用异步处理,代码同步串行执行,createOrder方法总耗时为200ms。如果改用线程进行异步处理,在保存完订单,向线程池提交发消息和记录日志的任务后,就可以立即返回。提交任务时间基本可以忽略不计,故createOrder方法总耗时直接降为100ms,为串行执行的50%。

异步执行.drawio.png

同步执行示例代码如下

    //创建订单-串行执行
    public void createOrder() {
        //核心逻辑 - 保存order 100ms
        saveOrder();
        //发送消息 - 50ms
        msgService.sendMsg();
        //记录日志 - 50ms
        oprLogService.saveOprLog();
        //执行完成后返回,总耗时100 + 50 + 50 = 200ms
    }

下面介绍三种,常见的通过线程进行异步处理的方法,线程池Spring注解@AsyncCompletableFuture,代码如下

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);
    }

下面介绍两种常见的串行改并行的实现方案CountDownLatchCompletableFuture

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);
        }
    }

三、线程池优化:合理调配系统资源

线程在实际工程实现中,往往都不会直接创建线程,而是配合线程池进行,这样能更好的对线程进行管理,同时减少频繁创建和销毁线程的开销。

我们先回顾一下线程池的基本流程

线程池.png 从图中我们不难发现,核心线程、阻塞队列、最大线程数量、拒绝策略是线程池中需要特别关注的几个参数。

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提供的线程池。 其中最重要的两个就是,并行流parallelStreamCompletableFuture

image.png ForkJoinPool.commonPool() 返回的是ForkJoinPool的一个静态常量common,是一个基于系统参数创建的公共线程池,初始化代码如下 image.png

CompletableFuture在使用时,如果不指定线程池,出现线程池资源竞争激烈时,也会导致接口性能下降,解决方案就是指定业务自定义线程池。

2.2 @Async 默认线程池

Spring的@Async注解默认使用的是Spring内部定义的名为"taskExecutor"的线程池,具体类型为ThreadPoolTaskExecutor(Spring对java线程池的封装)。

image.png

image.png

image.png

从上面的代码可以看出,默认线程池的默认配置任务队列大小最大线程数都是Integer.MAX_VALUE,极端情况也可能导致系统崩溃不可用。

所有用@Async注解标识的方法,如果没有设置独立的线程池都会使用这个默认线程池,那么如果其中有执行时间非常长的任务,比如批量处理的定时任务,那么可能导致其他对实时性要求很高的任务阻塞。

使用@Async注解,最好自己定义业务线程池,进行隔离

2.3 @Schedule 默认线程池

翻看@Schedule相关的核心源码ScheduledTaskRegistrar类中的scheduleTasks方法。

image.png 不难发现,如果使用@Schedule来实现定时任务,在未指定线程池的情况下,使用的是一个单线程线程池,并行度太低,如果某个任务执行时间太长,可能会导致任务阻塞,不能按照提前预定时间如期执行。

并且使用@Schedule执行定时任务没法终止或暂停任务,也不能实现动态的调整任务的执行周期,且任务是单机执行,集群环境下还需要考虑任务冲突和任务执行效率底下的问题,所以不推荐实现,建议使用分布式定时任务,如xxl-job。(PS:后续会单独写一篇文章,介绍xxl-job)

3. 线程池业务隔离

实际工程中,我们可能存在多种多样的需求要用到线程池,不同的场景对时效的要求不一样,核心业务和边缘业务对时效的要求也不一样。

如果线程池不区分业务隔离,有可能核心业务被边缘业务拖垮,最终导致系统不可用。因此建议实际工程中应该对线程池进行统一管理,区分主次,区分业务。线程池的参数设置上,也应该按照前文所述的IO密集型和CPU密集型有所不同。

image.png

--- END

本文是接口【性能优化秘籍】系列的第一篇,篇幅有限,部分问题并没有进行深入探究,如果大家有什么疑问和建议,欢迎评论区讨论~~

欢迎大家点赞-评论-关注,另外也可以关注公众号【程序员木木熊】,了解更多后端技术知识!!

微信公众号海量Java、架构、面试、算法资料免费送~

参考文章

使用spring框架时,你可能会遇到的6个并发问题!

不看绝对后悔的@Async深度解析【不仅仅是源码那么简单】

用了这18种方案,接口性能提高了100倍!