數據庫鏈接池內存泄漏問題的分析和解決方案

1、問題描述

上週五晚上主營出現部分設備掉線,通過查看日誌發現是因爲緩存系統出現長時間gc致使的。這裏的gc日誌的特色是:java

  • 1.gc時間都在2s以上,部分節點甚至出現12s超長時間gc。
  • 2.同一個節點距離上次gc時間間隔爲廣泛爲13~15天。

而後緊急把剩餘未gc的一個節點內存dump下來,使用mat工具打開發現,com.mysql.jdbc.NonRegisteringDriver 對象佔了堆內存的大部分空間。

查看對象數量,發現com.mysql.jdbc.NonRegisteringDriver$ConnectionPhantomReference 這個對象堆積了10140 個

初步判斷長時間gc的問題應該是因爲 com.mysql.jdbc.NonRegisteringDriver$ConnectionPhantomReference 這個對象大量堆積引發的mysql

2、問題分析

目前正式環境使用數據庫相關依賴以下:linux

依賴 版本
mysql 5.1.47
hikari 2.7.9
Sharding-jdbc 3.1.0

根據以上描述,提出如下問題:git

  • 一、com.mysql.jdbc.NonRegisteringDriver$ConnectionPhantomReference 究竟是個什麼對象呢?
  • 二、這種對象爲何會大量堆積,JVM回收不過來了?

NonRegisteringDriver$ConnectionPhantomReference 究竟是個什麼對象呢?

簡單來講,NonRegisteringDriver類有個虛引用集合connectionPhantomRefs用於存儲全部的數據庫鏈接,NonRegisteringDriver.trackConnection方法負責把新建立的鏈接放入connectionPhantomRefs集合。源碼以下:github

1.public class NonRegisteringDriver implements java.sql.Driver {  
2.	   protected static final ConcurrentHashMap<ConnectionPhantomReference, ConnectionPhantomReference> connectionPhantomRefs = new ConcurrentHashMap<ConnectionPhantomReference, ConnectionPhantomReference>();  
3.	   protected static final ReferenceQueue<ConnectionImpl> refQueue = new ReferenceQueue<ConnectionImpl>();
4.	  
5.	    ....  
6.	  
7.	   protected static void trackConnection(Connection newConn) {  
8.	  
9.	       ConnectionPhantomReference phantomRef = new ConnectionPhantomReference((ConnectionImpl) newConn, refQueue);  
10.	        connectionPhantomRefs.put(phantomRef, phantomRef);  
11.	   }  
12.	    ....  
13.	}  

複製代碼

咱們追蹤建立數據庫鏈接的過程源碼,發現其中會調到com.mysql.jdbc.ConnectionImpl的構造函數,該方法會調用createNewIO方法建立一個新的數據庫鏈接MysqlIO對象,而後調用咱們上面提到的NonRegisteringDriver.trackConnection方法,把該對象放入NonRegisteringDriver.connectionPhantomRefs集合。源碼以下:算法

1.public class ConnectionImpl extends ConnectionPropertiesImpl implements MySQLConnection {  
2.	  
3.	   public ConnectionImpl(String hostToConnectTo, int portToConnectTo, Properties info, String databaseToConnectTo, String url) throws SQLException {  
4.	        ...  
5.	       createNewIO(false);  
6.	        ...  
7.	       NonRegisteringDriver.trackConnection(this);  
8.	        ...  
9.	   }  
10.} 
複製代碼

connectionPhantomRefs 是一個虛引用集合,何爲虛引用?爲何設計爲虛引用隊列sql

  • 虛引用隊列也稱爲「幽靈引用」,它是最弱的一種引用關係。
  • 若是一個對象僅持有虛引用,那麼它就和沒有任何引用同樣,在任什麼時候候均可能被垃 圾回收器回收。
  • 爲一個對象設置虛 引用關聯的惟一目的只是爲了能在這個對象被收集器回收時收到一個系統通知。
  • 當垃圾回收器準備回收一個對象時,若是發現它還有虛引用,就會在垃圾回收後,將這個虛引用加入引用隊列,在其關聯的虛引用出隊前,不會完全銷燬該對象。因此能夠經過檢查引用隊列中是否有相應的虛引用來判斷對象是否已經被回收了。

connectionPhantomRefs 這種對象爲何會大量堆積,JVM回收不過來了?

這裏結合項目中hikaricp數據配置和官方文檔結合說明~shell

咱們先查閱hikaricp數據池的官網地址,看看部分屬性介紹以下:數據庫

maximumPoolSize緩存

This property controls the maximum size that the pool is allowed to reach, including both idle and in-use connections. Basically this value will determine the maximum number of actual connections to the database backend. A reasonable value for this is best determined by your execution environment. When the pool reaches this size, and no idle connections are available, calls to getConnection() will block for up to connectionTimeout milliseconds before timing out. Please read about pool sizing. Default: 10

maximumPoolSize控制最大鏈接數,默認爲10

minimumIdle

This property controls the minimum number of idle connections that HikariCP tries to maintain in the pool. If the idle connections dip below this value and total connections in the pool are less than maximumPoolSize, HikariCP will make a best effort to add additional connections quickly and efficiently. However, for maximum performance and responsiveness to spike demands, we recommend not setting this value and instead allowing HikariCP to act as a fixed size connection pool. Default: same as maximumPoolSize

minimumIdle控制最小鏈接數,默認等同於maximumPoolSize,10。

⌚idleTimeout

This property controls the maximum amount of time that a connection is allowed to sit idle in the pool. This setting only applies when minimumIdle is defined to be less than maximumPoolSize. Idle connections will not be retired once the pool reaches minimumIdle connections. Whether a connection is retired as idle or not is subject to a maximum variation of +30 seconds, and average variation of +15 seconds. A connection will never be retired as idle before this timeout. A value of 0 means that idle connections are never removed from the pool. The minimum allowed value is 10000ms (10 seconds). Default: 600000 (10 minutes)

鏈接空閒時間超過idleTimeout(默認10分鐘)後,鏈接會被拋棄

⌚maxLifetime

This property controls the maximum lifetime of a connection in the pool. An in-use connection will never be retired, only when it is closed will it then be removed. On a connection-by-connection basis, minor negative attenuation is applied to avoid mass-extinction in the pool. We strongly recommend setting this value, and it should be several seconds shorter than any database or infrastructure imposed connection time limit. A value of 0 indicates no maximum lifetime (infinite lifetime), subject of course to the idleTimeout setting. Default: 1800000 (30 minutes)

鏈接生存時間超過 maxLifetime(默認30分鐘)後,鏈接會被拋棄.

咱們再回頭看看項目的hikari配置

  • 配置了minimumIdle = 10,maximumPoolSize = 50,沒有配置idleTimeout和maxLifetime。因此這兩項會使用默認值 idleTimeout = 10分鐘,maxLifetime = 30分鐘。
  • 也就是說假如數據庫鏈接池已滿,有50個鏈接,假如系統空閒,40個鏈接會在10分鐘後(超過idleTimeout)被廢棄;假如系統一直繁忙,50個鏈接會在30分鐘後(超過maxLifetime)後被廢棄。

猜想問題產生的根源:

每次新建一個數據庫鏈接,都會把該鏈接放入connectionPhantomRefs集合中。數據鏈接在空閒時間超過idleTimeout或生存時間超過maxLifetime後會被廢棄,在connectionPhantomRefs集合中等待回收。由於鏈接資源通常存活時間比較久,通過屢次Young GC,通常都能存活到老年代。若是這個數據庫鏈接對象自己在老年代,connectionPhantomRefs中的元素就會一直堆積,直到下次 full gc。若是等到full gc 的時候connectionPhantomRefs集合的元素很是多,該次full gc就會很是耗時。

那麼怎麼解決呢?能夠考慮優化minimumIdle、maximumPoolSize、idleTimeout、maxLifetime這些參數,下一小節咱們分析一波

3、問題驗證

線上模擬環境

爲了驗證問題,咱們須要模擬線上環境,調整maxLifetime等參數~壓測思路以下

  • 1.緩存系統模擬線上的配置,使用壓測系統一段時間內持續壓緩存系統,使緩存系統短期建立/廢棄大量數據庫鏈接,觀察 NonRegisteringDriver 對象是否如期大量堆積,再手動調用 System.gc() 觀察 NonRegisteringDriver 對象是否被清理。
  • 2.調整maxLifetime 參數,觀察相同的壓測時間內 NonRegisteringDriver 對象是否還發生堆積。

這裏有如下注意點:

  • 一、 要知足 (gc 間隔時間 * 新生代進入老年代前的存活次數 < maxLifetime)這個條件,NonRegisteringDriver 對象才知足進入老年代的條件。
  • 二、 minimumIdle = 10,maximumPoolSize = 50(minimumIdle和maximumPoolSize和線上配置一致),idleTimeout設置10s,maxLifetime設 100s(gc時間約20s,因此要大於 20 * 3 = 60s)。這樣預計在持續壓測下每30s就會產生10個新鏈接(就算設置了maximumPoolSize = 50,這種程序的壓測10個鏈接足以應付)
  • 三、 項目內存分配小一點,以及把新生代進入老年代前的存活次數調小一點,方便新生代的NonRegisteringDriver對象在較短期能進入老年代,方便在較短期觀察到明顯的對象增加。
  • 四、 要監測緩存系統數據鏈接池的鏈接存活狀況,以及系統 gc狀況。

最終環境配置以下:

模擬實驗結果

  • 啓用jvisualvm工具對緩存系統進行實時觀察
  • 打開hikari相關debug日誌觀察鏈接池狀況

設置 maxLifetime = 100s,啓動緩存系統

確認hikari和jvm配置生效

觀察jvisualvm,發現產生20個NonRegisteringDriver 對象

觀察 hikari日誌,確認有20個鏈接對象生成,以及產生總鏈接10個,空閒鏈接10個。

初步判斷一個數據庫鏈接會生成兩個 NonRegisteringDriver 對象。

啓動壓測程序,壓測1000s

期間觀察gc日誌,gc時間間隔約20s,100s後發生5次 gc

觀察 hikari日誌,確認有20個鏈接對象生成

觀察jvisualvm變成 40個 NonRegisteringDriver 對象,符合預期。

持續觀察,1000s後理論上會產生220個對象(20 + 20 * 1000s / 100s),查看 jvisualvm 以下

產生了240個對象,基本和預期符合。

實驗結果分析

再結合咱們生產的問題,假設咱們天天14個小時高峯期(12:00 ~ 凌晨2:00),期間鏈接數20,10個小時低峯期,期間鏈接數10,每次 full gc 間隔14天,等到下次 full gc 堆積的 NonRegisteringDriver 對象爲 (20 * 14 + 10 * 10) * 2 * 14 = 10640,與問題dump裏面NonRegisteringDriver對象的數量10140 個基本吻合。

至此問題根源已經獲得徹底確認!!!

4、問題解決方案

由上面分析可知,問題產生的廢棄的數據庫鏈接對象堆積,最終致使 full gc 時間過長。因此咱們能夠從如下方面思考解決方案:

  • 一、減小廢棄的數據鏈接對象的產生和堆積。
  • 二、優化full gc時間.

【調整hikari參數】

咱們能夠考慮設置 maxLifetime 爲一個較大的值,用於延長鏈接的生命週期,減小產生被廢棄的數據庫鏈接的頻率,等到下次 full gc 的時候須要清理的數據庫鏈接對象會大大減小。

Hikari 推薦 maxLifetime 設置爲比數據庫的 wait_timeout 時間少 30s 到 1min。若是你使用的是 mysql 數據庫,可使用 show global variables like '%timeout%'; 查看 wait_timeout,默認爲 8 小時。

下面開始驗證,設置maxLifetime = 1小時,其餘條件不變。壓測啓動前觀察jvisualvm,NonRegisteringDriver 對象數量爲20

1000s,觀察 NonRegisteringDriver 對象仍然爲20

NonRegisteringDriver 對象沒有發生堆積,問題獲得解決。

同時另外注意:minimumIdle和maximumPoolSize不要設置得太大,通常來講配置minimumIdle=10,maximumPoolSize=10~20便可。

【使用G1回收器】

G1回收器是目前java垃圾回收器的最新成果,是一款低延遲高吞吐的優秀回收器,用戶能夠自定義最大暫停時間目標,G1會盡量在達到高吞吐量同時知足垃圾收集暫停時間目標。

下面開始驗證G1回收器的實用性,該驗證過程須要一段較長時間的觀察,同時藉助鏈路追蹤工具skywalking。最終觀察了10天,結果圖以下: 使用G1回收器,部分jvm參數-Xms3G -Xmx3G -XX:+UseG1GC

使用java 8默認的Parallel GC回收器組合,部分jvm參數-Xms3G -Xmx3G

以上圖中四個內容,從左到右分別爲

  • 一、堆內存,分爲已使用和空閒內存。
  • 二、方法區內存,這個不須要關注
  • 三、young gc和full gc時間
  • 四、程序啓動之後young gc和full gc次數

咱們能夠看到使用Parallel GC回收器組合的服務消耗的內存速度較快,發生了6996次young gc且發生了一次full gc,full gc時間長達5s。另一組使用G1回收器的服務消耗內存速度較爲平穩,只發生3827次young gc且沒有發生full gc。由此能夠看到G1回收器確實能夠用來解決咱們的數據庫鏈接對象堆積問題。

【創建巡查系統】

這個咱們目前尚未通過實踐,可是根據上面分析結果判斷,按期觸發full gc能夠達到每次清理少許堆積的數據庫鏈接的做用,避免過多數據庫鏈接一直堆積。採用該方法須要對業務的內容和高低峯週期很是熟悉。實現思路參考以下:

  • 一、建立java程序,使用定時任務按期調用System.gc()。該方法的缺點是即便手動調用了System.gc(),jvm不必定會馬上開始回收工做,有可能會根據它自己的算法,自行選擇最優時間纔開始進行回收工做。
  • 二、建立shell腳本調用jmap -dump:live,file=dump_001.bin PID,使用linux的crontab任務保證定時執行,執行完後再把dump_001.bin刪掉便可。該方法能保證必定發生full gc,缺點是功能過於單一零散,很差集中管理。

5、總結

咱們此次問題產生的根源是數據庫鏈接對象堆積,致使full gc時間過長。解決思路能夠從如下三點入手:

  • 一、調整hikari配置參數。例如把maxLifetime設置爲較大的值(比數據庫的wait_timeout少30s),minimumIdle和maximumPoolSize值不能設置太大,或者直接採用默認值便可。
  • 二、採用G1垃圾回收器。
  • 三、創建巡查系統,在業務低峯期主動觸發full gc。

我的公衆號

  • 若是你是個愛學習的好孩子,能夠關注我公衆號,一塊兒學習討論。
  • 若是你以爲本文有哪些不正確的地方,能夠評論,也能夠關注我公衆號,私聊我,你們一塊兒學習進步哈。
相關文章
相關標籤/搜索