侧边栏壁纸
博主头像
东家博主等级

东家不是家,心里有个她!

  • 累计撰写 6 篇文章
  • 累计创建 8 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

慢接口优化之线程池调参

Administrator
2023-03-05 / 0 评论 / 0 点赞 / 93 阅读 / 9534 字

业务场景

《员工成长》系统中,有很多的配置页面和输入录入页面,有的影响员工的KPI的正常计算,有的影响奖金的计算等等。为了避免使用者忘记配置这些前置数据,从而造成KPI和奖金计算错误带来的后续不必要的工作,也为了保证系统正常运行,我们需要开发一个自检页面,供相关人员查询和监督这些数据的配置情况。

成本页面:

image-20230301162210632

系统设计

主要讲优化,不讲设计,所以直接简述设计方案

该功能主要采用 策略模式 + 线程池来实现。

  • 策略模式:使用策略模式主要是将各个检测项目进行拆分,方便后续扩展
  • 线程池:主要是为多个策略同时检测的时候提速

慢接口

阿里云日志:

img

上线后,从阿里云日志数据统计图中可以看到该检测接口直接飙到900ms左右的响应时间。

第一次优化

思路

1、SQL执行太慢

2、线程池太慢

复现

PINPOINT复现截图:

image-20230301163904732

在测试环境进行复现后,到PINPOINT监控中查看请求链路(上图)。发现SQL查询并不慢,基本上在50ms左右都可以返回。到这里基本可以确定不是SQL的问题,那么压力就给到线程池这边了。

查找问题

/**
* 默认线程池大小
*/
private static final Integer DEFAULT_POOL_SIZE = 6;

/**
* 初始化一个线程池
*
* @param poolName 池名称
* @param poolSize 池大小
* @return ExecutorService 线程池服务
*/
private static ExecutorService init(String poolName) {
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(poolSize, DEFAULT_POOL_SIZE,
                                                                   1L, TimeUnit.MILLISECONDS,
                                                                   new LinkedBlockingQueue<>(32),
                                                                   new ThreadFactoryBuilder().setNameFormat("Pool-" + poolName).setDaemon(false).build(),
                                                                   new ThreadPoolExecutor.CallerRunsPolicy());

    threadPoolExecutor.allowCoreThreadTimeOut(true);
    // JVM钩子
    Runtime.getRuntime().addShutdownHook(
        new Thread(new FutureTask<Void>(() -> {
            threadPoolExecutor.shutdown();
            log.info(ThreadPoolsUtil.class, new Object[]{},
				String.format("钩子[%s]正在关闭线程池[%s]", Thread.currentThread().getName(), poolName));
            return null;
        })));
    return threadPoolExecutor;
}

可以看到线程池的核心线程数使用了默认大小(DEFAULT_POOL_SIZE = 6),由于本次的检测策略数量远远超过这个值(本次策略增加到了10个以上),很大概率就是由于线程池核心不足,将其他的处理请求放到了阻塞队列。

解决方案

直观解决方案:增加核心线程大小

/**
* 初始化一个线程池
*
* @param poolName 池名称
* @param poolSize 池大小
* @return ExecutorService 线程池服务
*/
private static ExecutorService init(String poolName, int poolSize) {
    final int poolSizeDealt = poolSize < DEFAULT_POOL_SIZE ? DEFAULT_POOL_SIZE : poolSize;
    // poolSizeDealt * 1.25
    int maxPoolSizeDealt = poolSizeDealt + (poolSizeDealt >> 2);

    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(poolSizeDealt, maxPoolSizeDealt,
                                                                   1L, TimeUnit.MILLISECONDS,
                                                                   new LinkedBlockingQueue<>(poolSizeDealt << 1),
                                                                   new ThreadFactoryBuilder().setNameFormat("Pool-" + poolName).setDaemon(false).build(),
                                                                   new ThreadPoolExecutor.CallerRunsPolicy());

    threadPoolExecutor.allowCoreThreadTimeOut(true);
    // JVM钩子
    Runtime.getRuntime().addShutdownHook(
        new Thread(new FutureTask<Void>(() -> {
            threadPoolExecutor.shutdown();
            log.info(ThreadPoolsUtil.class, new Object[]{},
				String.format("钩子[%s]正在关闭线程池[%s]", Thread.currentThread().getName(), poolName));
            return null;
        })));
    return threadPoolExecutor;
}

由于我们开放了核心线程的设置,所以直接在调用的地方修改即可,这次直接修改到16

public static final String POOL_NAME = "BUSINESS_DATA_EXCEPTION_CHECK_POOL";
public static final Integer POOL_SIZE = 2 << 3;

ExecutorService executorService = ThreadPoolsUtil.getOrInitExecutors(POOL_NAME, POOL_SIZE);

修改后性能有所提升(400ms左右),但是还有提升空间

第二次优化

问题:既然核心线程打满,其他线程会被挂到队列中,那么是不是可以优化一下队列呢?

查看一下线程池支持的队列:

  1. ArrayBlockingQueue:这是一个基于数组的有界队列。它按照先进先出(FIFO)的顺序存储元素,并且在队列已满时会阻塞添加操作。
  2. LinkedBlockingQueue:这是一个基于链表的有界队列。它按照先进先出(FIFO)的顺序存储元素,并且在队列已满时会阻塞添加操作。
  3. SynchronousQueue:这是一个没有任何容量的队列。它在插入操作时会等待相应的删除操作,反之亦然。因此,它通常用于直接将生产者和消费者线程进行同步。
  4. PriorityBlockingQueue:这是一个支持优先级的无界队列。它按照元素的优先级进行存储,并且在插入操作时会根据优先级进行排序。
  5. DelayQueue:这是一个支持延迟元素的无界队列。它按照延迟时间进行存储,并且只有在延迟时间到达时才可以获取元素。

从特性可以分析出:DelayQueuePriorityBlockingQueue 者两种队列并不是我需求所需要的队列,直接排除,那么就剩下2种队列了(LinkedBlockingQueue已经使用过了)。

问题:在此处ArrayBlockingQueue和LinkedBlockingQueue,那种数据结构性能更好?

ArrayBlockingQueueLinkedBlockingQueue都是常见的Java队列实现,但它们的性能和适用场景略有不同。

ArrayBlockingQueue是一个有界阻塞队列,内部使用一个定长的数组作为缓冲区。由于数组长度固定,因此在队列已满时,新的插入操作将被阻塞,直到队列中的元素被消费或者队列被清空。ArrayBlockingQueue适用于在生产者和消费者之间进行通信,因为它可以确保先进先出(FIFO)的顺序,并且在添加元素时不需要进行任何内存分配或移动操作。

相比之下,LinkedBlockingQueue是一个基于链表的无界阻塞队列,可以动态地调整大小以容纳任意数量的元素。在队列已满时,新的插入操作将被阻塞,直到队列中的元素被消费或者队列被清空。由于LinkedBlockingQueue的容量不受限制,因此它适用于那些不知道需要存储多少元素的场景。但是,在大量添加或删除元素时,由于链表结构的特性,LinkedBlockingQueue的性能可能会受到一定的影响。

在给定代码中,由于ThreadPoolExecutor的核心线程数和最大线程数都已经确定,因此队列的容量也可以确定。在这种情况下,ArrayBlockingQueue是一个更好的选择,因为它可以避免链表结构的性能影响,并且在添加元素时不需要进行任何内存分配或移动操作。

private static ExecutorService init(String poolName, int poolSize) {
    final int poolSizeDealt = poolSize < DEFAULT_POOL_SIZE ? DEFAULT_POOL_SIZE : poolSize;
    // poolSizeDealt * 1.25
    int maxPoolSizeDealt = poolSizeDealt + (poolSizeDealt >> 2);

    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(poolSizeDealt, maxPoolSizeDealt,
                                                                   1L, TimeUnit.MILLISECONDS,
                                                                   new ArrayBlockingQueue<>(poolSizeDealt << 1),
                                                                   new ThreadFactoryBuilder().setNameFormat("Pool-" + poolName).setDaemon(false).build(),
                                                                   new ThreadPoolExecutor.CallerRunsPolicy());

    threadPoolExecutor.allowCoreThreadTimeOut(true);
    // JVM钩子
    Runtime.getRuntime().addShutdownHook(
        new Thread(new FutureTask<Void>(() -> {
            threadPoolExecutor.shutdown();
            log.info(ThreadPoolsUtil.class, new Object[]{},
				String.format("钩子[%s]正在关闭线程池[%s]", Thread.currentThread().getName(), poolName));
            return null;
        })));
    return threadPoolExecutor;
}

到此,第二次优化已经OK,上线测试,接口降到了200ms左右

第三次优化

其实200ms的接口,并不算快,还有优化的空间

分析

线程池执行流程

从上图我们可以知道线程池处理线程的流程:

1、当小于核心线程数的时候,直接创建

2、大于核心线程数,但小于队列容量,放入队列

3、拒绝策略

思考

我们是否能够将放入队列这个步骤跳过呢?

只要不超过最大线程数就无脑新建线程,这样就可以避开放入队列中进行排队的耗时,但是当线程池的最大线程数都已经满足不了需求的时候就会报错,这也是我们不想看到的。

能满足我们这里的两点需求的只有一种方法了:颠倒线程池放入阻塞队列和创建线程的顺序

实现

采用同步队列(SynchronousQueue

如果希望颠倒线程池放入阻塞队列和创建线程的顺序,可以考虑使用SynchronousQueue来实现。SynchronousQueue是一个没有容量限制的阻塞队列,生产者线程在插入元素时必须等待消费者线程取走该元素,反之亦然。这种队列可以实现线程池在提交任务时不创建线程,而是直接将任务交给空闲的线程执行。

private static ExecutorService init(String poolName, int poolSize) {
    final int poolSizeDealt = poolSize < DEFAULT_POOL_SIZE ? DEFAULT_POOL_SIZE : poolSize;
    // poolSizeDealt * 2
    int maxPoolSizeDealt = poolSizeDealt << 1;

    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(poolSizeDealt, maxPoolSizeDealt,
                                                                   1L, TimeUnit.SECONDS,
                                                                   new SynchronousQueue<>(),
                                                                   new ThreadFactoryBuilder().setNameFormat("Pool-" + poolName).setDaemon(false).build(),
                                                                   new ThreadPoolExecutor.CallerRunsPolicy());

    threadPoolExecutor.allowCoreThreadTimeOut(true);
    // JVM钩子
    Runtime.getRuntime().addShutdownHook(
        new Thread(new FutureTask<Void>(() -> {
            threadPoolExecutor.shutdown();
            log.info(ThreadPoolsUtil.class, new Object[]{},
				String.format("钩子[%s]正在关闭线程池[%s]", Thread.currentThread().getName(), poolName));
            return null;
        }
                                       ))
    );
    return threadPoolExecutor;
}

SynchronousQueue还可以实现公平锁和非公平锁两种策略。由于业务并没有这方面的需求,而且使用公平锁会有一点性能损耗,可以直接忽略这部分的特性,保持默认即可。

最总结果

image-20230301174918140

0

评论区