數據庫的調用方式是先獲取數據庫的連接,然后依靠這條連接從數據庫中查詢數據,最后關閉連接釋放數據庫資源。這種調用方式下,每次執行SQL都需要重新建立連接,頻繁地建立數據庫連接耗費時間長導致了訪問慢的問題。
那么為什么頻繁創建連接會造成響應時間慢呢?來看一個實際的測試。
我用"tcpdump -i bond0 -nn -tttt port 4490"命令抓取了線上MySQL建立連接的網絡包來做分析,從抓包結果來看,整個MySQL的連接過程可以分為兩部分:
第一部分是前三個數據包。第一個數據包是客戶端向服務端發送的一個“SYN”包,第二個包是服務端回給客戶端的“ACK”包以及一個“SYN”包,第三個包是客戶端回給服務端的“ACK”包,熟悉TCP協議的同學可以看出這是一個TCP的三次握手過程。
第二部分是MySQL服務端校驗客戶端密碼的過程。其中第一個包是服務端發給客戶端要求認證的報文,第二和第三個包是客戶端將加密后的密碼發送給服務端的包,最后兩個包是服務端回給客戶端認證OK的報文。從圖中,你可以看到整個連接過程大概消耗了4ms(969012-964904)。
用連接池預先建立數據庫連接
其實,在開發過程中我們會用到很多的連接池,像是數據庫連接池、HTTP連接池、Redis連接池等等。而連接池的管理是連接池設計的核心,我就以數據庫連接池為例,來說明一下連接池管理的關鍵點。
數據庫連接池有兩個最重要的配置:最小連接數和最大連接數,它們控制着從連接池中獲取連接的流程:
- 如果當前連接數小於最小連接數,則創建新的連接處理數據庫請求;
- 如果連接池中有空閑連接則復用空閑連接;
- 如果空閑池中沒有連接並且當前連接數小於最大連接數,則創建新的連接處理請求;
- 如果當前連接數已經大於等於最大連接數,則按照配置中設定的時間(C3P0的連接池配置是checkoutTimeout)等待舊的連接可用;
- 如果等待超過了這個設定時間則向用戶拋出錯誤。
在這里,你需要注意池子中連接的維護問題,出現問題原因可能有以下幾種:
1.數據庫的域名對應的IP發生了變更,池子的連接還是使用舊的IP,當舊的IP下的數據庫服務關閉后,再使用這個連接查詢就會發生錯誤;
2.MySQL有個參數是“wait_timeout”,控制着當數據庫連接閑置多長時間后,數據庫會主動的關閉這條連接。這個機制對於數據庫使用方是無感知的,所以當我們使用這個被關閉的連接時就會發生錯誤。
用線程池預先創建線程
JDK 1.5中引入的ThreadPoolExecutor就是一種線程池的實現,它有兩個重要的參數:coreThreadCount和maxThreadCount,這兩個參數控制着線程池的執行過程。
- 如果線程池中的線程數少於coreThreadCount時,處理新的任務時會創建新的線程;
- 如果線程數大於coreThreadCount則把任務丟到一個隊列里面,由當前空閑的線程執行;
- 當隊列中的任務堆積滿了的時候,則繼續創建線程,直到達到maxThreadCount;
- 當線程數達到maxTheadCount時還有新的任務提交,那么我們就不得不將它們丟棄了。
這個任務處理流程看似簡單,實際上有很多坑,你在使用的時候一定要注意。
首先, JDK實現的這個線程池優先把任務放入隊列暫存起來,而不是創建更多的線程,它比較適用於執行CPU密集型的任務,也就是需要執行大量CPU運算的任務。這是為什么呢?因為執行CPU密集型的任務時CPU比較繁忙,因此只需要創建和CPU核數相當的線程就好了,多了反而會造成線程上下文切換,降低任務執行效率。所以當當前線程數超過核心線程數時,線程池不會增加線程,而是放在隊列里等待核心線程空閑下來。
但是,我們平時開發的Web系統通常都有大量的IO操作,比方說查詢數據庫、查詢緩存等等。任務在執行IO操作的時候CPU就空閑了下來,這時如果增加執行任務的線程數而不是把任務暫存在隊列中,就可以在單位時間內執行更多的任務,大大提高了任務執行的吞吐量。所以你看Tomcat使用的線程池就不是JDK原生的線程池,而是做了一些改造,當線程數超過coreThreadCount之后會優先創建線程,直到線程數到達maxThreadCount,這樣就比較適合於Web系統大量IO操作的場景了,你在實際運用過程中也可以參考借鑒。
其次,線程池中使用的隊列的堆積量也是我們需要監控的重要指標,對於實時性要求比較高的任務來說,這個指標尤為關鍵。
我在實際項目中就曾經遇到過任務被丟給線程池之后,長時間都沒有被執行的詭異問題。最初,我認為這是代碼的Bug導致的,后來經過排查發現,是因為線程池的coreThreadCount和maxThreadCount設置的比較小,導致任務在線程池里面大量的堆積,在調大了這兩個參數之后問題就解決了。跳出這個坑之后,我就把重要線程池的隊列任務堆積量,作為一個重要的監控指標放到了系統監控大屏上。
最后,如果你使用線程池請一定記住不要使用無界隊列(即沒有設置固定大小的隊列)。也許你會覺得使用了無界隊列后,任務就永遠不會被丟棄,只要任務對實時性要求不高,反正早晚有消費完的一天。但是,大量的任務堆積會占用大量的內存空間,一旦內存空間被占滿就會頻繁地觸發Full GC,造成服務不可用,我之前排查過的一次GC引起的宕機,起因就是系統中的一個線程池使用了無界隊列。
理解了線程池的關鍵要點,你在系統里加上了這個特性,至此,系統穩定,你圓滿完成了公司給你的研發任務。
這時,你回顧一下這兩種技術,會發現它們都有一個共同點:它們所管理的對象,無論是連接還是線程,它們的創建過程都比較耗時,也比較消耗系統資源。所以,我們把它們放在一個池子里統一管理起來,以達到提升性能和資源復用的目的。
這是一種常見的軟件設計思想,叫做池化技術,它的核心思想是空間換時間,期望使用預先創建好的對象來減少頻繁創建對象的性能開銷,同時還可以對對象進行統一的管理,降低了對象的使用的成本,總之是好處多多。
不過,池化技術也存在一些缺陷,比方說存儲池子中的對象肯定需要消耗多余的內存,如果對象沒有被頻繁使用,就會造成內存上的浪費。再比方說,池子中的對象需要在系統啟動的時候就預先創建完成,這在一定程度上增加了系統啟動時間。
可這些缺陷相比池化技術的優勢來說就比較微不足道了,只要我們確認要使用的對象在創建時確實比較耗時或者消耗資源,並且這些對象也確實會被頻繁地創建和銷毀,我們就可以使用池化技術來優化。