線程池如何監控,才能幫助開發者快速定位線上錯誤?
大部分情況下,線程池的運行情況對于使用者來說是個黑盒
運行情況不可知,會導致 生產出現事故問題排查困難,以及線程池參數難以定義
文章圍繞線程池監控展開,討論 線程池如何監控、監控的指標以及監控數據的存儲展示
01如何監控運行數據
設想一下,如果想監控線程池的運行數據,你會怎么操作?這里提供兩種常規思路
線程池運行時埋點,每一次運行任務都進行統計
定時獲取線程池的運行數據
這里我推薦第二種,因為線程池的監控 API 會通過 獲取主鎖來控制結果的相對準確性,性能相對較差,后面會詳細說明
為什么叫相對準確?因為任務和線程的狀態在計算過程中可能會動態變化,只能給到一個近似值,保證不了絕對準確
模擬下定時采集線程池運行時數據的代碼
- private ScheduledThreadPoolExecutor collectVesselExecutor;
- String collectVesselTaskName = "client.scheduled.collect.data";
- collectVesselExecutor = new ScheduledThreadPoolExecutor(
- new Integer(1),
- ThreadFactoryBuilder.builder().daemon(true).prefix(collectVesselTaskName).build()
- );
- // 延遲 initialDelay 后循環調用. scheduleWithFixedDelay 每次執行時間為上一次任務結束時, 向后推一個時間間隔
- collectVesselExecutor.scheduleWithFixedDelay(
- () -> runTimeGatherTask(),
- properties.getInitialDelay(),
- properties.getCollectInterval(),
- TimeUnit.MILLISECONDS
- );
一般線程池分為兩種方式創建,Spring Bean 和非 Spring Bean,假設創建的線程池是 Spring 管理的
我們只需要在 Spring 容器啟動成功后,延遲一段時間后開始采集運行數據就 OK 了
不論線程池是否由 Spring 管理,采集的方式大致相同。一種從 Spring 容器取,一種是創建好線程池后放到一個自定義容器
02監控的指標有哪些?
說一下目前 Hippo4J 定義的線程池監控指標,包括不限于。大家有業務中使用到的監控指標都可以討論下
- 線程池當前負載:當前線程數 / 最大線程數
- 線程池峰值負載:當前線程數 / 最大線程數,線程池運行期間最大的負載
- 核心線程數:線程池的核心線程數
- 最大線程數:線程池限制同時存在的線程數
- 當前線程數:當前線程池的線程數
- 活躍線程數:執行任務的線程的大致數目
- 最大出現線程數:線程池中運行以來同時存在的最大線程數
- 阻塞隊列:線程池暫存任務的容器
- 隊列容量:隊列中允許元素的最大數量
- 隊列元素:隊列中已存放的元素數量
- 隊列剩余容量:隊列中還可以存放的元素數量
- 線程池任務完成總量:已完成執行的任務的大致總數
- 拒絕策略執行次數:運行時拋出的拒絕次數總數
這些指標可以幫助我們解決大多數因為線程池而導致的問題排查。但是,事情往往不能盡善盡美
當前線程數、活躍線程數、最大出現線程數、線程池任務完成總量 的線程池 API 會先獲取到 mainLock,然后才開始計算
mainLock 是線程池的主鎖,線程執行、線程銷毀和線程池停止等都會使用到這把鎖
- final ReentrantLock mainLock = this.mainLock;
- mainLock.lock();
- try {
- xxxxx
- } finally {
- mainLock.unlock();
- }
如果頻繁獲取這把鎖,會導致原有線程池任務執行性能受到影響
所以,我們應該避免頻繁獲取這幾項參數,這也是不使用線程池任務執行埋點最重要的原因
03監控數據存儲
上面的線程池監控指標如果只能支持實時查看,并不能幫忙開發日常排查錯誤
大部分場景下,生產上的問題發現會有延遲。比如 12:30 出現的問題,業務13:00 進行的反饋
為了更好幫助開發排錯,我們需要將線程池的歷史運行數據進行存儲
說到線程池歷史運行數據的存儲,使用 時序數據庫(TSDB) 是最合適的
但大部分情況下,公司不會為了這一個需求搭建或者采購時序數據庫,那就可以使用折中方案,比如說 MySQL、ES 等
我們以 MySQL 為例,his_run_data 歷史運行數據表,建表語句如下:
- CREATE TABLE `his_run_data` (
- `thread_pool_id` varchar(56) DEFAULT NULL COMMENT '線程池ID',
- `instance_id` varchar(256) DEFAULT NULL COMMENT '實例ID',
- `current_load` bigint(20) DEFAULT NULL COMMENT '當前負載',
- `peak_load` bigint(20) DEFAULT NULL COMMENT '峰值負載',
- `pool_size` bigint(20) DEFAULT NULL COMMENT '線程數',
- `active_size` bigint(20) DEFAULT NULL COMMENT '活躍線程數',
- `queue_capacity` bigint(20) DEFAULT NULL COMMENT '隊列容量',
- `queue_size` bigint(20) DEFAULT NULL COMMENT '隊列元素',
- `queue_remaining_capacity` bigint(20) DEFAULT NULL COMMENT '隊列剩余容量',
- `completed_task_count` bigint(20) DEFAULT NULL COMMENT '已完成任務計數',
- `reject_count` bigint(20) DEFAULT NULL COMMENT '拒絕次數',
- `timestamp` bigint(20) DEFAULT NULL COMMENT '時間戳',
- `gmt_create` datetime DEFAULT NULL COMMENT '創建時間',
- `gmt_modified` datetime DEFAULT NULL COMMENT '修改時間',
- PRIMARY KEY (`id`),
- KEY `idx_group_key` (`tp_id`,`instance_id`) USING BTREE,
- KEY `idx_timestamp` (`timestamp`) USING BTREE
- ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='歷史運行數據表';
可以看到,建表語句中有三個關鍵字段:
thread_pool_id:表示當前數據的線程池標識
instance_id:應用可能集群部署,標識集群下唯一的線程池
timestamp:記錄線程池運行數據產生時的時間戳
有一個問題,線上的線程池是源源不斷產生運行數據的,遲早不得把表的數據量推到上億?
因為數據是有時效性的,過了一定時間之后,就沒有必要再占用實時的資源
針對上述問題提供兩種解決方案:
- 假設數據存儲 1 天,如果超出這個時間,直接刪除即可
- 同上所述,過期數據可以保留到備份表中,并刪除 his_run_data 數據
可能有的小伙伴還會擔心,數據量太大會不會導致查詢時過慢?
我們可以算一下,假設有 100 個應用,每個應用部署 10 個節點
假設數據有效期為 1 小時,那么可以產出的數據是 72 萬,一天也就是 1728 萬
對于 MySQL 而言,幾千萬數據量以下針對索引的查詢,都不會產生性能瓶頸
04如何定義公共監控?
抽象線程池存儲
上面說到,線程池的采集歷史運行數據在各個應用系統中,數據的存儲、定期刪除是否可以抽象出來,避免重復的工作
如果選擇抽象數據存儲,客戶端節點與服務端之間的交互如下:
- 客戶端定時采集線程池歷史運行數據,將數據打包好發送服務端
- 服務端接收客戶端上報的數據,進行數據入庫持久化存儲
- 服務端定期刪除或存檔客戶端線程池歷史運行數據
- 由服務端統一對外提供線程池運行圖表的數據展示
這里有個小問題,客戶端如何打包發送給服務端?定時采集數據后直接上報是不是可行呢
不推薦采集、上報兩種行為放到一個流程中,好的設計應該是要 分離開職責;而且,如果在上報過程中網絡出現阻塞等等問題,會耽誤采集線程的下一次采集結果
我們可以使用多線程生產、消費模型來做,相信大家初學多線程一定都學過這個設計
- // 緩沖隊列
- private BlockingQueue<Message> messageCollectVessel = new ArrayBlockingQueue(bufferSize);
- // 生產者
- Message message = collector.collectMessage();
- boolean offer = messageCollectVessel.offer(message);
- if (!offer) {
- log.warn("Buffer data starts stacking data...");
- }
- // 消費者
- while (true) {
- try {
- Message message = messageCollectVessel.take();
- messageSender.send(message);
- } catch (Throwable ex) {
- log.error("Consumption buffer container task failed. Number of buffer container tasks :: {}", messageCollectVessel.size(), ex);
- }
- }
創建阻塞緩沖隊列,由定時線程池采集歷史運行數據,并放到緩沖隊列中;然后起一個線程,循環消費即可
極端情況下緩沖隊列元素會出現堆積,最新采集的線程池數據也就無法插入成功,為了不影響客戶端的運行,僅做異常警告處理
使用最新抽象出來的客戶端、服務端交互流程,有以下幾個優點
- 數據的存儲和查詢展示由服務端提供功能,減輕客戶端壓力和重復工作量
- 歷史運行數據的刪除或備份操作由服務端統一執行
- 不同的項目不需要為線程池歷史運行數據分別創建表結構存儲
- 形成交互規范,避免業務發散單獨開發,中心化的設計更利于技術的迭代和管理
監控圖表展示
不同公司對于線程池的監控不盡相同,出于各種考慮,會將監控封裝成最符合自己業務場景的流程
Hippo4J 從最基本的指標出發,封裝出了最小代價的監控體系,并提供可視化頁面的圖標展示
有興趣可以查看 Hippo4J 框架官網介紹
Site:https://www.hippox.cn
還有一個功能點,考慮到很多公司搭建了一套監控體系,其中以 Prometheus + Grafana 為主
后續 Hippo4J 會接入 Prometheus,應用內部存儲線程池的運行數據,適配 Prometheus 采集存儲,最終展示到 Grafana
05總結回顧
線程池作為企業級應用廣泛的技術,對它的監控是不可或缺的穩定性保障之一