作者:郭奥门
爱可生 DBLE 研发成员,负责分布式数据库中间件的新功能开发,回答社区/客户/内部提出的一般性问题。
本文来源:原创投稿
*爱可生开源社区出品,原创内容未经授权不得随意使用,转载请联系小编并注明来源。
背景
在实际生产环境,项目上线初期流量比较小,等后面项目流量涨上来,dble 内原有的线程配置可能支撑不了上游的压力,此时可能会遇到一系列性能问题,这时就需要调大 processors、backendProcessors 等线程池参数,并根据预期指标及实际线程使用情况多次调整至最优。
在之前,修改完配置中的线程数之后需要重启才能让配置生效,但这种方式不是很灵活,甚至可能会影响上游的使用。dble也是考虑到这一点,在3.21.06.* 版本提供了不重启调整线程池数目的方式
命令
代码语言:javascript复制update dble_information.dble_thread_pool set core_pool_size = 2 where name = 'BusinessExecutor';
注意:
- 支持动态调整的线程池为:businessExecutor 、writeToBackendExecutor 、processors(nio 场景下:bootstrap.cnf 中的 usingAIO 值为0)、backendProcessors(nio场景下)、backendBusinessExecutor 、complexQueryExecutor
- 不支持动态调整 AIO 场景下的 processors 、backendProcessors 对应的线程池
- 由于 JDK 原生线程池(ThreadPoolExecutor)扩缩容机制问题,新建的线程和即将被回收的线程需要一定的时机才会被处理,所以设置 core_pool_size 并不会立即通过 dble_thread_pool 表查到,但此时使用该线程池不受影响
- 尽管线程池数目可以允许不停机的方式调整,但为了防止出现未知问题,建议不要在流量大的时候调整
原理解读
dble 内线程的使用方式
当前 dble 内使用线程池主要包含两种方式:一是 JDK 内置线程池,另外是外置队列 JDK 内置线程池,下面我们简单讲一下两种方式的原理
外置队列 线程池
DBLE 为了高并发、响应快等特点,在网络 IO 层面采用了经典的主从 Reactor 多线程模型,这里只说明 Reactor 模型如何在 DBLE 中落地,不清楚模型原理的同学可以参阅:彻底搞懂 Reactor 模型和 Proactor 模型(文末有链接)
DBLE 目前网络模型如上图所示:
1、Reactor主线程——NIOAcceptor通过select、accept事件读取并初步处理client连接,并通过frontRegisterQueue队列传递给Reactor子线程
2、Reactor子线程——RW从外置队列中(非线程池内部队列,为了区分称此时队列为外置队列)取到连接并注册到当前子线程中,后续通过read方法读取数据包并通过frontHandlerQueue队列或本地队列传递给工作线程
3、工作线程池内的子线程从外置队列中接收到任务,经过后续的一系列分析处理后,将结果经过writeQueue队列传递给writeToBackendExecutor线程,继而发送给前端client,或经过本地队列传递给backendBuinessExecutor/complexQueryExecutor线程直接将结果返回给前端
从DBLE网络模型可以看出其内部使用队列 线程池的方式来分发处理任务,除此之外在业务处理的过程中也是大量使用了线程池来处理一些耗时的任务
JDK 内置线程池
dble 内线程池是借助了 java 的 ThreadPoolExecutor 类,其运行流程如下:
线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:
(1)直接申请线程执行该任务;
(2)缓冲到队列中等待线程执行;
(3)拒绝该任务。线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。
结合 dble 中目前的结构,其内部主要线程使用的方式为:
- businessExecutor、writeToBackendExecutor 相关线程通过外置队列 线程池实现调度,在 dble 启动时初始化线程池,线程内部通过轮询方式获取任务(启动时创建的线程称之为常驻线程)
- processors 、backendProcessors 相关线程在 nio 情况下通过外置队列 线程池实现调度;在 aio 情况下通过 AsynchronousChannelGroup 机制(内置线程池)实现线程管理
- backendBusinessExecutor 相关线程在性能模式下(usePerformanceMode=1),通过外置队列 线程池实现调度;反之在后续任务到达时由线程池直接调度
- complexQueryExecutor 相关线程由线程池直接调度(不是在启动时创建的线程称之为非常驻线程,随任务朝生暮死)
具体实现
为了动态的调整线程池的数目,保证扩缩容之后任务都能正常被处理,需要针对以上两种方式作单独处理,具体实现方式如下:
线程池
JDK 原生线程池 ThreadPoolExecutor 提供了如下几个 public 的 setter 方法,如下图所示:
JDK 允许线程池使用方通过 ThreadPoolExecutor 的实例来动态设置线程池的核心策略,以 setCorePoolSize 为方法例,在运行期线程池使用方调用此方法设置 corePoolSize 之后,线程池会直接覆盖原来的 corePoolSize 值,并且基于当前值和原始值的比较结果采取不同的处理策略。对于当前值小于当前工作线程数的情况,说明有多余的 worker 线程,此时会向当前 idle 的 worker 线程发起中断请求以实现回收,多余空闲的 worker 在任务执行完成后也会被回收(有延迟);对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的 worker 线程来执行队列任务,如果当前线程内部队列没有待执行任务,则会在下次任务需要执行时新建 work 线程(有延迟)。
外置队列 线程池
线程池可以借助 JDK 提供的 set 方法动态设置池的大小,当前场景下扩容时额外需要为新建的线程绑定外置队列,保证后续的任务能通过外置队列被新建的线程接收并处理,那么在代码中新建线程时需要添加外置队列的引用
缩容时,ThreadPoolExecutor 线程池通过以上方式对空闲的线程进行回收,针对非常驻线程就只需要设置大小即可;针对正在运行的线程不会主动进行回收,解铃还须系铃人,此时就需要我们手动关闭,先通过 interrupt 方法把线程标记为 interrupted 状态,同时在线程内部根据状态值判断是否需要退出轮询,后续再借助线程池内部的缩容策略进行回收线程
通过以上两种方式可以实现动态的修改线程池数目,但是为了让 processors 、backendProcessors 相关 IO 线程能在扩缩容时平稳过渡,需要额外的做一些必要的“善后”工作
“善后”
processors 、backendProcessors 所对应的线程为 DBLE 的 IO 线程,负责对注册到当前线程的连接请求的接收和后端结果的接收,那么扩容时就需要保证新建的 IO 线程能处理后续的 IO 请求,相应的缩容时需要对回收的线程所绑定的连接能够转移到其他 IO 线程,保证连接后续请求能被正常处理
在 DBLE 中对于善后工作所做的处理就是先取消当前线程中的连接,再将这些连接重新注册到新的 IO 线程中,此时删除和重新注册的选取策略为:删除时优先选择线程中绑定连接数最小的,重新注册时优先选择线程中连接数最大的,并根据删除的线程往下选择
总结
dble 在3.21.06.* 版本及之后提供了可以不重启来修改线程参数的命令,由于 dble 内不只是简单的使用 JDK 内置的线程池,那么动态修改的命令也不只是使用 JDK 内置的方法去实现,同时也为了兼容外置队列、IO 连接而做了额外的工作。
虽然我们在并发测试中动态调整线程池数目并未发现异常情况,但是仍旧建议在并发量小的时候进行调整,不仅为了线程间切换平稳过渡,也是为了减少线程调整时资源的使用。实际在使用该命令的时候遇到一些问题,可以反馈在 actiontech/dble: A High Scalability Middle-ware for MySQL Sharding (github.com),帮助我们改善
参考
- 彻底搞懂 Reactor 模型和 Proactor 模型:https://cloud.tencent.com/developer/article/1488120
- Java 线程池实现原理及其在美团业务中的实践:https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html