如何給 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 -> this.getCorePoolSize());
createGauge(this, "grpc_tomcat_maximum_pool_size", pool -> this.getMaximumPoolSize());
createGauge(this, "grpc_tomcat_busy_pool_size", pool -> this.getActiveCount());
createGauge(this, "grpc_tomcat_wait_queue_size", pool -> this.getWaitQueueSize());
} catch (Exception e) {
log.error("注冊 all servers 監控失敗", e);
}
}
private void createGauge(AllServersThreadPoolCollector weakRef, String metric, ToDoubleFunction<AllServersThreadPoolCollector> 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 去處理請求用以降低尾延遲,超過最大線程後,任務将被壓入隊列中進行處理