tomcat 集群监控与弹性伸缩详解

如何给 tomcat 配置合适的线程池

任务分为 CPU 密集型和 IO 密集型

对于 CPU 密集型的应用来说,需要大量 CPU 计算速度很快,线程池如果过多,则保存和切换上下文开销过高反而会影响性能,可以适当将线程数量调小一些

对于 IO 密集型应用来说常见于普通的业务系统,比如会去查询 mysql、redis 等然后在内存中做简单的数据组装和逻辑判断,此时由于有 epoll、dma 等支持,消耗较长时间的线程不会长时间占据 CPU 时间,所以可以适当将线程数量调大一些

对于线程数到底该设置多大,网上有很多的方案

我们在实际情况中基于 wrk 等压测工具进行压测,压测逻辑相对复杂请求量相对较高的接口先配置一个相对保守的值

先预估假设当前接口处理平均耗时 300MS,1S 一个线程平均处理 3 个请求,最大 100 个线程 1S 能处理 300 TPS

粗略估算每个请求会消耗多大的内容空间,假如是 2KB,那么每秒会消耗 600KB 以此来估算 YGC 多久会触发一次,假设年轻代为 1GB 那么大约 29 分钟触发一次 YGC

然后再看 100 个线程持续处理 300TPS 的时候 CPU 消耗情况

观察内存几分钟 GC 一次,或者 CPU 使用率稳定在 50% 左右就好,假设此时线程池 70 个线程都在运作

那么监控系统采集到线程池线程使用率达到了 80% 就开始扩容,因为扩容拉起新的服务器到提供服务可能也需要1-2分钟的时间,所以需要预留服务器资源能够处理弹性扩容期间的流量

实际场景中是相对比较复杂的,前期最好设置一个相对保守的扩容阈值,如果发现在达到这个阈值前服务器已经顶不住了就可以实时的缩小阈值

同时还可以根据在达到扩容阈值扩容的时候,观察线上真实的 CPU 和内存使用情况,可以基于 promethues + grafana 来进行监控,如果发现扩容时候 CPU 使用率和内存使用率 GC 评率比较低,那么可以再配置中心动态的调整线程池的大小

所以说与其寻找一个准确的计算线程池数量的配置方式,不如提供一个可以动态调整的线程池作为 tomcat 的线程池

如何监控 tomcat 线程池的工作情况

spring boot 在启动过程中会调用 TomcatProtocolHandlerCustomizer 的实现类,此处可以自定化 tomcat 线程池,也可以获取到 tomcat 线程池

public class MyTomcatProtocolHandlerCustomizer implements TomcatProtocolHandlerCustomizer<ProtocolHandler> {
 private final ThreadPoolExecutor tomcatThreadPoolExecutor;
 public TomcatProtocolHandlerCustomizer(ThreadPoolExecutor tomcatThreadPoolExecutor) {
 this.tomcatThreadPoolExecutor = tomcatThreadPoolExecutor;
 }
 @Override
 public void customize(ProtocolHandler protocolHandler) {
 protocolHandler.setExecutor(tomcatThreadPoolExecutor);
 }
 public ThreadPoolExecutor getThreadPoolExecutor() {
 return tomcatThreadPoolExecutor;
 }
}

然后将线程池装入一个容器 bean 中注册到 promethues 监控中,当每秒进行采集的时候通过回调的方法去获取线程池的最新指标

promethues 每秒采集一次容器的线程池运行指标、服务器测 CPU 使用率当满足定义的扩容阈值时就拉起新的 POD

grafana 每秒采集一次 promethues 的数据汇聚图标展示

@Bean
public AllServersThreadPoolCollector allServersThreadPoolCollector(@Qualifier(value = "GrpcThreadPoolExecutor") ThreadPoolExecutor GrpcThreadPoolExecutor,
 @Qualifier(value = "jdTomcatThreadPoolExecutor") org.apache.tomcat.util.threads.ThreadPoolExecutor TomcatThreadPoolExecutor,
 MeterRegistry registry) {
 return new AllServersThreadPoolCollector(GrpcThreadPoolExecutor, jdTomcatThreadPoolExecutor, registry);
}
public void init() {
 try {
 createGauge(this, "grpc_tomcat_core_pool_size", pool -&gt; this.getCorePoolSize());
 createGauge(this, "grpc_tomcat_maximum_pool_size", pool -&gt; this.getMaximumPoolSize());
 createGauge(this, "grpc_tomcat_busy_pool_size", pool -&gt; this.getActiveCount());
 createGauge(this, "grpc_tomcat_wait_queue_size", pool -&gt; this.getWaitQueueSize());
 } catch (Exception e) {
 log.error("注册 all servers 监控失败", e);
 }
}
private void createGauge(AllServersThreadPoolCollector weakRef, String metric, ToDoubleFunction&lt;AllServersThreadPoolCollector&gt; measure) {
 Gauge.builder(metric, weakRef, measure)
 .register(this.registry);
}
public int getWaitQueueSize() {
 return grpcThreadPoolExecutor.getQueue().size() + tomcatThreadPoolExecutor.getQueue().size();
}

tomcat 线程池扩缩容

Java 线程池是支持直接修改 corePoolSize、maximumPoolSize 的

  • 修改 corePoolSize

(1)数量小于 maximumPoolSize 抛异常

(2)如果 corePoolSize - 原来的 = delta,delta 大于 0 那么创建等待队列任务数量和 delta 个线程来处理等待处理的任务

(3)如果正在运行的线程数量 > corePoolSize 那么就中断多余的线程

  • 修改 maximumPoolSize

(1)maximumPoolSize 小于 corePoolSize 抛错

(2)如果运行的线程大于 maximumPoolSize 中断掉一些空闲的线程

基于这些机制就能在运行期间动态调整线程池内容

无需担心会中断掉正在运行的任务,因为线程池 worker 线程每执行一个任务的时候

tomcat 是如何避免原生线程池的缺陷的

原生线程池的工作原理

(1)运行的线程数小于核心线程,就创建一个 worker 线程去执行任务

(2)运行的线程数 >= 核心线程了,将任务全部积压到队列中

(3)队列如果满了继续创建非核心线程 worker 去执行任务

假如 tomcat 采用了原生线程池,核心线程为 10 个,最大线程为 100,队列为 200,并发来了 100 个请求,那么同时系统只能处理 10 个,剩下 90 个都得放入队列中让 10 个核心线程慢慢排队处理,延时必然非常高

tomcat 如何优化线程池,核心在于阻塞队列的实现,因为阻塞队列满了才会继续创建非核心 worker 线程处理任务

(1)运行的线程数小于核心线程,就创建一个 worker 线程去执行任务

(2)当前已经创建的核心+非核心线程数等于最大线程数,任务压入队列

(3)正在处理的请求数量小于核心+非核心线程数,任务压入队列

(4)当前已经创建的核心+非核心线程数小于最大线程数,创建 worker 线程处理请求

总结就是一句话当高并发流量过来的时候,会去创建最大线程数的 worker 去处理请求用以降低尾延迟,超过最大线程后,任务将被压入队列中进行处理

作者:随风21

%s 个评论

要回复文章请先登录注册