存溢出排查:數(shù)據(jù)庫(kù)連接對(duì)象堆積與連接池泄漏實(shí)戰(zhàn))
某個(gè)線上服務(wù)在凌晨開始陸續(xù)出現(xiàn)接口超時(shí)堆內(nèi)存監(jiān)控曲線一路向上從2GB爬到3.8GB只用了幾個(gè)小時(shí)隨后接連拋出OutOfMemoryError: Java heap space。容器重啟后過了一會(huì)兒又反復(fù)。打開GC日志和堆轉(zhuǎn)儲(chǔ)一看老年代里堆滿了同一個(gè)類名com.mysql.cj.jdbc.ConnectionImpl對(duì)象數(shù)量達(dá)到六位數(shù)。這就是典型的“JVM內(nèi)存溢出-數(shù)據(jù)庫(kù)連接對(duì)象大量累積”問題。數(shù)據(jù)庫(kù)連接本身不算龐然大物但成千上萬個(gè)連接對(duì)象疊加在一起足以把堆內(nèi)存榨干。真正有價(jià)值的不只是知道“發(fā)生了什么”而是搞清楚連接對(duì)象為什么只增不減、怎樣在不影響線上業(yè)務(wù)的情況下把它定位出來、修完之后又怎么防止同類問題再次出現(xiàn)。這篇文章就按這個(gè)脈絡(luò)寫給同樣被內(nèi)存溢出和連接池問題折磨的人一份可復(fù)用的排查手冊(cè)。1. 先搞清楚連接對(duì)象累積與堆溢出是什么關(guān)系在動(dòng)手抓堆轉(zhuǎn)儲(chǔ)之前一定要先把問題定性。很多人一看到內(nèi)存溢出第一反應(yīng)是堆太小或者GC參數(shù)不對(duì)實(shí)際上連接對(duì)象累積類的問題根因往往藏在業(yè)務(wù)代碼和連接池策略里??梢园堰@類故障從兩個(gè)層面拆開看一個(gè)是單條連接在JVM里到底占多少內(nèi)存一個(gè)是這些連接究竟是怎么積累起來的。1.1 一條數(shù)據(jù)庫(kù)連接在JVM里到底占多大地方“一條Connection能占多大頂多幾百字節(jié)”這是對(duì)數(shù)據(jù)庫(kù)連接對(duì)象的典型誤解。連接在JVM堆里的內(nèi)存占用遠(yuǎn)不止接口對(duì)象本身。以最常見的MySQL客戶端連接器為例一個(gè)Connection對(duì)象背后至少包含這樣幾塊內(nèi)容Socket通道的輸入緩沖區(qū)和輸出緩沖區(qū)每塊默認(rèn)就是8KB到16KB兩個(gè)加起來已經(jīng)有十幾KB協(xié)議解析層、編碼轉(zhuǎn)換層、事務(wù)狀態(tài)機(jī)等內(nèi)部對(duì)象預(yù)編譯Statement緩存如果打開useServerPrepStmts每條SQL對(duì)應(yīng)一個(gè)對(duì)象連接上下文、字符集元數(shù)據(jù)、用戶權(quán)限相關(guān)的緩存把這些疊加起來一條空閑連接在MySQL 8.x連接器下實(shí)測(cè)常常是150KB往上。要是連接上執(zhí)行過較多SQL產(chǎn)生了較多預(yù)編譯緩存單條連接突破200KB到300KB也不奇怪。做道算術(shù)題堆內(nèi)存設(shè)4GB按單條連接200KB估算累積到2萬條連接就是4GB左右。也就是說堆里躺上2萬個(gè)連接對(duì)象整個(gè)堆就基本廢了。而且連接數(shù)量越多GC掃描這些對(duì)象的時(shí)間越長(zhǎng)Full GC停頓會(huì)越來越明顯服務(wù)表現(xiàn)為周期性卡頓一直到OOM徹底爆發(fā)。1.2 兩類累積泄漏型累積與池容量型累積“大量連接對(duì)象”這個(gè)描述太籠統(tǒng)實(shí)際要分成兩類來看排查方向完全不同。第一類是泄漏型累積。連接從池里借出來之后關(guān)閉動(dòng)作因?yàn)楫惓!⑻崆皉eturn、分支遺漏等原因沒有執(zhí)行連接對(duì)象就一直被某個(gè)線程棧或者靜態(tài)容器引用著永遠(yuǎn)回不到池里。池本身有上限但借出的連接只要還沒有歸還池的數(shù)據(jù)結(jié)構(gòu)里就一直持有它的狀態(tài)記錄。于是連接對(duì)象數(shù)量只增不減堆內(nèi)存持續(xù)爬升。這種在堆轉(zhuǎn)儲(chǔ)里的特征是連接對(duì)象被某條業(yè)務(wù)線程的棧幀、或者某個(gè)全局容器持久引用而不是掛在連接池的空閑隊(duì)列上。第二類是池容量型累積。這種更隱蔽代碼沒有泄漏但連接池配置的maximumPoolSize本身極大池策略又允許大量空閑連接長(zhǎng)期存活。比如把池上限設(shè)成500系統(tǒng)實(shí)際日常只需要20個(gè)連接剩下的480個(gè)連接一直活著堆里躺著大量空閑連接純屬浪費(fèi)。每個(gè)空閑連接的內(nèi)存雖然不如活躍連接那么高但架不住數(shù)量多。更麻煩的是池創(chuàng)建連接的速度遠(yuǎn)大于釋放速度高峰期內(nèi)存占用會(huì)迅速膨脹。兩類問題都會(huì)讓你在堆轉(zhuǎn)儲(chǔ)里看到大量ConnectionImpl對(duì)象但前者要改代碼后者改配置加監(jiān)控就能解決。如果在第一步就把問題歸錯(cuò)類后面所有的排查動(dòng)作都會(huì)跑偏。2. 根因在哪里常見的連接對(duì)象累積路徑把這些年遇到過的線上事故疊在一起看連接對(duì)象累積基本逃不出下面幾種模式。2.1 手動(dòng)管理連接關(guān)閉路徑被異常跳過有些老項(xiàng)目里存在不少手寫JDBC的場(chǎng)景開發(fā)對(duì)連接池的理解停留在“調(diào)用getConnection就能拿連接”但關(guān)閉邏輯寫得非常隨意。Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery(); while (rs.next()) { // 業(yè)務(wù)處理 } // 這里沒有close連接池里永遠(yuǎn)少一個(gè)連接這種代碼在低并發(fā)下長(zhǎng)期不炸因?yàn)檫B接池會(huì)自動(dòng)擴(kuò)容你總覺得連接夠用??闪髁恳坏┥蟻沓乩锏倪B接被借光新請(qǐng)求就開始排隊(duì)超時(shí)。同時(shí)泄漏一直在發(fā)生池在擴(kuò)容到上限之后也無法滿足需求堆內(nèi)存被新連接和等待隊(duì)列不斷撐大量變積累成質(zhì)變OOM就來了。還有一種更容易被忽視的寫法明明寫了finally塊做close但方法里catch塊把異常吞了而close操作又依賴前面某個(gè)資源的中間狀態(tài)。比如在try里初始化PreparedStatement時(shí)就拋了異常對(duì)象還是nullfinally卻直接調(diào)用ps.close()空指針把關(guān)閉動(dòng)作徹底打斷。這種問題在代碼評(píng)審時(shí)極易漏掉因?yàn)檎B窂娇雌饋硎峭暾P(guān)閉的只有異常路徑才露餡。2.2 連接池參數(shù)配置與業(yè)務(wù)容量嚴(yán)重失配另一類常見情況是代碼邏輯沒有明顯泄漏問題出在連接池容量規(guī)劃上。我見過有團(tuán)隊(duì)一遇到“連接池耗盡”的報(bào)錯(cuò)就簡(jiǎn)單粗暴把maxPoolSize從10改成1000。從工程角度講這不是在解決問題而是給數(shù)據(jù)庫(kù)下毒。連接池的上限不只是內(nèi)存問題1000個(gè)連接同時(shí)建到數(shù)據(jù)庫(kù)實(shí)例上數(shù)據(jù)庫(kù)端的線程、內(nèi)存、鎖全都會(huì)被拖垮整體吞吐量不升反降。更合理的做法是估算連接需求。經(jīng)驗(yàn)公式大概是需要連接數(shù) 接口QPS × 單次操作持有連接的平均秒數(shù)舉個(gè)例子某接口QPS是200每次請(qǐng)求大約占用連接200毫秒那么需要的連接數(shù)就是200 × 0.2 40再留20%到30%余量設(shè)置50就非常寬裕。如果QPS和平均耗時(shí)不變硬把池上限設(shè)成500大部分時(shí)間池里躺著450條空閑連接對(duì)JVM堆是純浪費(fèi)對(duì)數(shù)據(jù)庫(kù)是無謂壓力。2.3 Spring事務(wù)與MyBatis會(huì)話的隱性持有還有一類比顯式泄漏更隱蔽的問題連接并沒有丟失但被長(zhǎng)時(shí)間占用數(shù)量一多效果等同于泄漏。Spring的Transactional會(huì)把數(shù)據(jù)庫(kù)連接綁定到當(dāng)前線程的ThreadLocal資源管理器只有事務(wù)提交或回滾之后才釋放綁定。如果方法內(nèi)部嵌套了耗時(shí)很長(zhǎng)的本地緩存操作、大批量文件處理、或者一次外部HTTP調(diào)用連接就會(huì)一直被線程攥著不放。消息隊(duì)列消費(fèi)邏輯套事務(wù)注解是重災(zāi)區(qū)。一條消息被消費(fèi)時(shí)觸發(fā)了復(fù)雜的業(yè)務(wù)鏈路處理10秒連接就被綁定10秒。消費(fèi)線程池32個(gè)連接池上限30瞬時(shí)并發(fā)上來池里可用連接直接歸零。這類問題的特征是堆轉(zhuǎn)儲(chǔ)里的連接對(duì)象數(shù)量可能并不夸張但連接池的active指標(biāo)一直頂滿pending指標(biāo)持續(xù)上漲。修復(fù)方向不是調(diào)大連接池而是收縮事務(wù)邊界把遠(yuǎn)程調(diào)用、耗時(shí)操作全部挪到事務(wù)方法外面去。2.4 多數(shù)據(jù)源場(chǎng)景下的DataSource重復(fù)創(chuàng)建動(dòng)態(tài)數(shù)據(jù)源也是一個(gè)高頻坑位。為了做讀寫分離或者多租戶隔離有些項(xiàng)目用Map緩存了多個(gè)DataSource實(shí)例每次切換數(shù)據(jù)源就new一個(gè)舊的實(shí)例卻沒有關(guān)閉。HikariDataSource創(chuàng)建后池后臺(tái)線程會(huì)一直存活。堆轉(zhuǎn)儲(chǔ)里不只是ConnectionImpl數(shù)量多還能看到多個(gè)HikariPool實(shí)例以及成倍增長(zhǎng)的池控制線程。這種場(chǎng)景下連接對(duì)象是被多個(gè)連接池對(duì)象分開持有的單看數(shù)量可能沒到嚇人的程度但加上線程對(duì)象、隊(duì)列對(duì)象、緩沖區(qū)對(duì)象以后內(nèi)存占用依然非??捎^。定位后處理方式也很明確要么統(tǒng)一走路由層整個(gè)JVM只保留真正在用的數(shù)據(jù)源實(shí)例要么為動(dòng)態(tài)數(shù)據(jù)源設(shè)計(jì)完整的生命周期管理確保切換后舊實(shí)例能被正常關(guān)閉和回收。3. 從現(xiàn)象到定位一次完整的在線排查這一章是整個(gè)排查過程最實(shí)操的部分按我當(dāng)時(shí)處理事故的順序?qū)憽?.1 第一步用jstat觀察GC與堆內(nèi)存趨勢(shì)定位JVM內(nèi)存問題最忌諱一上來就抓堆轉(zhuǎn)儲(chǔ)。正確的第一步是先看趨勢(shì)。登錄到報(bào)警機(jī)器先通過jps找到進(jìn)程PID然后執(zhí)行jstat -gcutil pid 1000 2000這條命令每秒采樣一次持續(xù)2000秒。輸出里重點(diǎn)看E、O、FGC這三列。E代表Eden區(qū)使用率O代表老年代使用率FGC是Full GC次數(shù)。如果你看到O列持續(xù)上漲Full GC之后O列也不回落說明確實(shí)有對(duì)象在老年代不斷累積。再配合系統(tǒng)負(fù)載和接口監(jiān)控就能把問題范圍先從“整臺(tái)JVM內(nèi)存溢出”壓縮到“某類對(duì)象只增不減”。這一步的目的有兩個(gè)一是確認(rèn)堆轉(zhuǎn)儲(chǔ)有沒有必要抓二是為后續(xù)分析提供內(nèi)存使用基線。如果老年代穩(wěn)定不動(dòng)Full GC正常單純是堆參數(shù)設(shè)太小那調(diào)整-Xmx就行跟連接對(duì)象沒關(guān)系。如果像當(dāng)時(shí)那樣O列一路爬升就可以放心進(jìn)入下一步了。3.2 第二步抓堆轉(zhuǎn)儲(chǔ)確認(rèn)對(duì)象構(gòu)成確認(rèn)老年代在持續(xù)上漲后需要抓一份堆轉(zhuǎn)儲(chǔ)jcmd pid GC.heap_dump /tmp/heap-pid.hprof用jcmd而不是jmap -dump抓是因?yàn)閖cmd默認(rèn)不帶live參數(shù)不會(huì)先觸發(fā)一次Full GC能保留堆現(xiàn)場(chǎng)的原始狀態(tài)。抓堆之前先確認(rèn)磁盤剩余空間堆如果配置4GBhprof文件通常也會(huì)有接近4GB的大小別把磁盤寫爆了。抓完堆之后先用快速命令看對(duì)象分布jmap -histo pid | head -40這段輸出的價(jià)值在于給出對(duì)象數(shù)量的Top N。在連接泄漏事故里你經(jīng)常會(huì)在最前面看到這樣幾個(gè)類名com.mysql.cj.jdbc.ConnectionImplMySQL 8.x連接器com.zaxxer.hikari.pool.PoolEntryHikariCP池條目com.alibaba.druid.pool.DruidPooledConnectionDruid池包裝連接當(dāng)ConnectionImpl的對(duì)象數(shù)量達(dá)到幾十萬級(jí)時(shí)基本可以確定方向。但我必須提醒一句對(duì)象數(shù)量多只是信號(hào)不直接等于泄漏。繼續(xù)往下看引用鏈才是真正的判定依據(jù)。3.3 第三步用MAT定位GC Root持有鏈路把hprof文件下載到本地用Eclipse MAT打開。操作步驟是先看Histogram按對(duì)象個(gè)數(shù)排序過濾出ConnectionImpl這類類名右鍵選擇List objects - with outgoing references看這些連接都引用了什么再右鍵選擇Path to GC Roots - exclude weak references把GC Root路徑完整顯示出來。CG Root路徑通常會(huì)呈現(xiàn)三種典型情況連接被HikariPool內(nèi)部的ConcurrentBag引用這是連接池自己在持有的正常連接。如果數(shù)量已經(jīng)明顯超過池配置的上限說明池被撐爆需要回到池配置審查。連接被某條線程棧的局部變量引用這是正在使用中的連接算不上異常。但如果這些線程都停留在同一個(gè)業(yè)務(wù)方法里而且數(shù)量持續(xù)上漲說明這條方法大概率在泄漏連接。連接被某個(gè)實(shí)例字段或靜態(tài)容器引用這是最典型的泄漏實(shí)錘。代碼把Connection對(duì)象塞進(jìn)了數(shù)組、Map、ThreadLocal之類容器里使用完沒有清理導(dǎo)致GC Root能一路找到它。通過MAT的引用路徑可以直接對(duì)應(yīng)到具體類名和字段名再結(jié)合源碼倉(cāng)庫(kù)就能定位到方法行號(hào)比在代碼里瞎搜快得多。3.4 第四步結(jié)合業(yè)務(wù)代碼確認(rèn)泄漏點(diǎn)拿到MAT的結(jié)論后回到代碼倉(cāng)庫(kù)打開對(duì)應(yīng)的類。我在實(shí)際項(xiàng)目里遇到的漏法主要有三種catch分支里沒有釋放連接只有正常try路徑執(zhí)行了close方法返回前提前return跳過了統(tǒng)一釋放邏輯在循環(huán)體里多次調(diào)getConnection最后只關(guān)閉了一個(gè)前面的全部漏掉修好泄漏點(diǎn)之后先別急著擴(kuò)堆。部署新版本觀察連接池的活躍連接數(shù)和JVM內(nèi)存曲線。如果active數(shù)能隨著流量回落內(nèi)存曲線走平才算真正修好。這里還要提個(gè)建議修復(fù)一個(gè)泄漏點(diǎn)往往不夠建議在代碼庫(kù)全局搜索getConnection的調(diào)用點(diǎn)把所有手工獲取連接的代碼統(tǒng)一審一遍把同類型的隱患一次性清理掉。4. 修復(fù)方案與參數(shù)調(diào)優(yōu)落地定位到根因之后修復(fù)方案要落在三個(gè)層面代碼寫法、連接池配置、監(jiān)控告警。缺一不可。4.1 代碼層修復(fù)連接獲取與釋放的規(guī)范寫法如果項(xiàng)目還在使用原生JDBC或者裸連接管理務(wù)必統(tǒng)一成try-with-resources寫法try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { // 業(yè)務(wù)處理 } } catch (SQLException e) { log.error(query failed, e); }這種寫法的好處是資源關(guān)閉順序由語言層面保證無論正常執(zhí)行還是拋出異常都會(huì)按逆序調(diào)用close方法。對(duì)于從連接池取出的代理Connection這個(gè)close的語義是“歸還連接池”并不是真正斷開物理連接所以不用擔(dān)心頻繁開關(guān)連接帶來的性能損耗。如果項(xiàng)目Java版本受限無法用try-with-resources也要遵守一個(gè)原則close動(dòng)作只寫在finally里不要分散到try和catch的多個(gè)位置否則極易漏掉分支。還有一個(gè)實(shí)用技巧是給連接操作寫一個(gè)統(tǒng)一的工具方法把連接獲取、關(guān)閉邏輯收斂到一處。業(yè)務(wù)代碼只調(diào)用工具方法從源頭杜絕寫法混亂。4.2 連接池參數(shù)調(diào)整以HikariCP為例Spring Boot默認(rèn)使用的連接池是HikariCP配置文件可以這樣調(diào)整spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000 max-lifetime: 1800000 idle-timeout: 600000 leak-detection-threshold: 60000每個(gè)參數(shù)為什么要這樣設(shè)背后都有邏輯maximum-pool-size池上限。不要拍腦袋設(shè)一個(gè)整數(shù)按前面說的QPS × 平均連接占用時(shí)間估算留20%到30%余量。連接數(shù)過大對(duì)數(shù)據(jù)庫(kù)本身也是一種壓力。minimum-idle池保持的最小空閑連接數(shù)。如果服務(wù)空閑期不想保留太多空閑連接可以把值調(diào)低配合idle-timeout使用如果日常流量波動(dòng)大建議設(shè)置成與maximum-pool-size一致避免池頻繁擴(kuò)縮容導(dǎo)致性能抖動(dòng)。connection-timeout客戶端等待連接的時(shí)間線上建議30秒。設(shè)太短突發(fā)流量時(shí)大量請(qǐng)求直接超時(shí)設(shè)太長(zhǎng)用戶側(cè)等待感過強(qiáng)。max-lifetime連接最大存活時(shí)間必須小于數(shù)據(jù)庫(kù)的wait_timeout。MySQL默認(rèn)wait_timeout是28800秒HikariCP默認(rèn)30分鐘。保持默認(rèn)值是安全的只要低于數(shù)據(jù)庫(kù)端即可。idle-timeout空閑連接最大存活時(shí)間。注意它只在池中連接數(shù)超過minimumIdle時(shí)才生效如果minimumIdle和maximumPoolSize相等空閑連接不會(huì)被銷毀。leak-detection-threshold連接被借出超過這個(gè)時(shí)間沒有歸還HikariCP會(huì)輸出警告日志。設(shè)成60000毫秒可以較早捕獲泄漏點(diǎn)。前提是它必須小于max-lifetime否則配置會(huì)沖突。這組參數(shù)改完后連接數(shù)曲線通常兩三個(gè)發(fā)布周期就能看到明顯收斂。參數(shù)本身不是越多越好而是要和業(yè)務(wù)容量的真實(shí)需求匹配。4.3 Druid的特殊兜底手段removeAbandoned如果你用的是Druid連接池它提供了一套針對(duì)連接泄漏的兜底配置spring: datasource: druid: initial-size: 5 min-idle: 5 max-active: 50 max-wait: 30000 remove-abandoned: true remove-abandoned-timeout-millis: 300000 log-abandoned: trueremove-abandoned的意思是連接被借出超過remove-abandoned-timeout-millis指定的毫秒數(shù)后還沒歸還Druid會(huì)從代碼里強(qiáng)制回收這個(gè)連接。log-abandoned打開后Druid會(huì)打印包含調(diào)用棧的告警日志定位泄漏非常方便。但這把雙刃劍要用得謹(jǐn)慎。如果業(yè)務(wù)里存在合法的長(zhǎng)事務(wù)比如一個(gè)20分鐘的大批量導(dǎo)入任務(wù)而你設(shè)置的回收閾值是300秒事務(wù)后段的數(shù)據(jù)操作會(huì)被強(qiáng)制回收打斷引發(fā)數(shù)據(jù)一致性問題。因此這類兜底配置只建議在確實(shí)存在泄漏、且業(yè)務(wù)里沒有超過閾值的正常長(zhǎng)任務(wù)時(shí)才啟用。更穩(wěn)妥的順序是先用log-abandoned定位泄漏分支修復(fù)代碼再?zèng)Q定是否保留remove-abandoned作為最后防線。4.4 建立三層監(jiān)控防線排查和修復(fù)都完成后最后一步是把防線建立起來避免同類問題在無人值守的深夜再次爆發(fā)。我一般分三層來做第一層JVM內(nèi)存層。使用Spring Boot Actuator加Micrometer把指標(biāo)暴露給Prometheus重點(diǎn)監(jiān)控jvm_memory_used_bytes和Full GC次數(shù)。一旦老年代內(nèi)存持續(xù)增長(zhǎng)連續(xù)多個(gè)采集周期環(huán)比為正立刻告警。第二層連接池層。HikariCP會(huì)輸出hikaricp_connections_active、hikaricp_connections_idle、hikaricp_connections_pending這幾個(gè)關(guān)鍵指標(biāo)。建議給active占總?cè)萘勘壤O(shè)置80%閾值pending持續(xù)大于0且維持5分鐘以上就觸發(fā)告警。Druid也可以打開監(jiān)控頁但生產(chǎn)環(huán)境一定要做訪問鑒權(quán)。第三層SQL與事務(wù)層。開啟數(shù)據(jù)庫(kù)慢查詢?nèi)罩景殉^1秒的SQL記錄下來。應(yīng)用側(cè)記錄每個(gè)事務(wù)方法的耗時(shí)超過閾值就預(yù)警。一條慢SQL執(zhí)行5秒就有一個(gè)連接被占用5秒。慢SQL一旦集中出現(xiàn)連接池很快見底。三層指標(biāo)互相配合連接對(duì)象的任何異常累積都能在早期被捕捉到。5. 常見問題排查與避坑記錄這一路排查下來有些錯(cuò)誤和坑值得單獨(dú)記錄下來以后遇到類似問題能少走彎路?,F(xiàn)象可能原因排查或處理建議堆內(nèi)存持續(xù)上漲連接對(duì)象數(shù)量龐大連接泄漏或池配置過大先看GC Root引用鏈區(qū)分泄漏型還是容量型HikariCP連接池請(qǐng)求超時(shí)池耗盡多與慢SQL或長(zhǎng)事務(wù)有關(guān)查數(shù)據(jù)庫(kù)慢日志收縮事務(wù)邊界日志顯示連接一直未歸還方法提前return或異常分支漏close用try-with-resources重寫復(fù)查catch分支連接池頻繁擴(kuò)縮容minimumIdle與maximumPoolSize差距過大把minimum-idle調(diào)高接近maximum-pool-sizeDruid強(qiáng)制回收后事務(wù)中斷長(zhǎng)任務(wù)超過回收閾值調(diào)大回收時(shí)間或去掉兜底優(yōu)先修代碼再分享三個(gè)實(shí)際踩過的坑第一個(gè)坑不要在業(yè)務(wù)高峰期直接抓dump并立刻重啟實(shí)例。抓dump本身會(huì)給服務(wù)增加一次內(nèi)存快照和磁盤寫入如果堆已經(jīng)接近裝滿可能直接觸發(fā)又一次Full GC。更穩(wěn)妥的順序是先記錄現(xiàn)場(chǎng)指標(biāo)把實(shí)例從負(fù)載均衡上摘掉流量再抓dump最后重啟。先留證據(jù)再補(bǔ)救否則重啟完現(xiàn)場(chǎng)沒了根因也找不到了。第二個(gè)坑不要只盯著ConnectionImpl類名就下結(jié)論。之前看一個(gè)案例dump里ConnectionImpl數(shù)量非常多我差點(diǎn)判定是代碼泄漏。但順著GC Root追下去發(fā)現(xiàn)這些連接幾乎全部被HikariPool自己持有原來是某次擴(kuò)容把maximumPoolSize沖到了800代碼本身反而沒問題。引用鏈才是判官類名數(shù)量只是線索。第三個(gè)坑連接池參數(shù)修完之后不要直接撒手。一個(gè)泄漏點(diǎn)往往只在一類流量場(chǎng)景下才觸發(fā)比如某個(gè)爬蟲任務(wù)、某個(gè)特殊的對(duì)賬流程。修完連接池配置把內(nèi)存壓下來不代表所有場(chǎng)景都安全了。只要代碼庫(kù)里還存在手工獲取連接的調(diào)用點(diǎn)建議花時(shí)間統(tǒng)一改造讓連接回收路徑經(jīng)得起評(píng)審。我個(gè)人在處理這類問題時(shí)的習(xí)慣是一開始就拒絕調(diào)堆參數(shù)。加大-Xmx只是把癥狀往后推不會(huì)讓泄漏消失。正確路徑永遠(yuǎn)是把GC趨勢(shì)、堆轉(zhuǎn)儲(chǔ)、GC Root引用鏈走完讓證據(jù)說話。如果你也被JVM內(nèi)存溢出和連接池累積問題纏住按這套流程走一遍大部分事故都能在幾小時(shí)內(nèi)鎖定根因。最后送一句實(shí)操口訣連接泄漏看GC Root連接池容量看指標(biāo)修復(fù)之后看曲線。