線上生產(chǎn)環(huán)境JVM內(nèi)存泄露,我熬夜處理一通宵總結(jié)了一下經(jīng)驗(yàn)
相信對于大家多看一些類似的案例,以后對于大家自己在線上系統(tǒng)遇到各種生產(chǎn)問題的時(shí)候,進(jìn)行排查和優(yōu)化的思路會(huì)有很大的啟發(fā)。
事故背景
先給大家簡單說一下這個(gè)問題的發(fā)生背景,線上生產(chǎn)環(huán)境部署了兩個(gè)系統(tǒng),我們可以認(rèn)為是系統(tǒng) A 和系統(tǒng) B,同時(shí)系統(tǒng) B 因?yàn)槭谴罅髁亢诵南到y(tǒng),所以部署了幾十臺(tái)機(jī)器,定位就是集群部署要抗每秒幾萬的 TPS 的,兩臺(tái)系統(tǒng)之間是基于 dubbo 作為 rpc 調(diào)用框架,注冊中心用的是 zookeeper。
如下圖所示:
在這個(gè)背景之下,某一天系統(tǒng) B 因?yàn)楦铝舜a,因此發(fā)起了一次幾十臺(tái)機(jī)器的全量滾動(dòng)更新和部署。
也就是說,系統(tǒng) B 的開發(fā)團(tuán)隊(duì)基于最新的代碼把幾十臺(tái)機(jī)器依次用最新代碼重新部署了一遍,也就是每臺(tái)機(jī)器都會(huì)有一次系統(tǒng)停止和重啟的過程。
如下圖所示:
沒想到生產(chǎn)環(huán)境的災(zāi)難性故障就這么突然發(fā)生了,在系統(tǒng) B 的幾十臺(tái)機(jī)器依次重新部署之后,結(jié)果系統(tǒng) A 的開發(fā)團(tuán)隊(duì)驚訝的發(fā)現(xiàn)自己的系統(tǒng)居然過了一會(huì)就發(fā)送了 jvm 內(nèi)存使用率飆升超過 90% 的告警,而且很快系統(tǒng) A 居然就直接 OOM 內(nèi)存溢出崩潰了。
如下圖所示:
于是系統(tǒng) B 的開發(fā)團(tuán)隊(duì)順利的把一個(gè)大版本更新了幾十臺(tái)機(jī)器之后,心滿意足的欣賞自己的成果呢,系統(tǒng) A 的開發(fā)團(tuán)隊(duì)突然開始一臉懵逼的手忙腳亂進(jìn)行了生產(chǎn)故障的排查。
那么大家可以想想,這個(gè)時(shí)候,如果是你負(fù)責(zé)的線上系統(tǒng)突然給你發(fā)送內(nèi)存使用率飆升超過 90%,而且很快就 oom 內(nèi)存溢出,你會(huì)怎么排查?
排查思路
這里給大家說說當(dāng)時(shí)我們是怎么進(jìn)行排查的,首先,遇到這種內(nèi)存突然飆升然后導(dǎo)致 oom 的情況,先看看是不是外部對你的請求流量過大導(dǎo)致的。
因?yàn)橥@種突發(fā)性的問題,都是外部流量突然飆升導(dǎo)致的,這里先給分析一種外部流量突然飆升導(dǎo)致系統(tǒng) oom 的場景。
假設(shè)你平時(shí)常規(guī)化運(yùn)作的時(shí)候,每次一批請求過來會(huì)在你的 jvm 年輕代里創(chuàng)建一批對象,接著這批請求處理完畢了,之前創(chuàng)建的那批對象就會(huì)成為垃圾對象了,然后下一批請求過來,又在 jvm 年輕代里創(chuàng)建了一批對象。
如下圖所示:
那么正常情況下,你的 jvm 年輕代里肯定對象會(huì)越來越多是不是?但是其實(shí)一般到了一定時(shí)候,年輕代里的存活對象基本很少,因?yàn)榇蟛糠值膶ο蠖际侵耙呀?jīng)處理完畢的請求創(chuàng)建的對象,他們其實(shí)都是一些沒用的垃圾對象。
所以其實(shí)正常情況下跑一段時(shí)間后,會(huì)觸發(fā)一下 jvm 年輕代的垃圾回收,把垃圾對象都回收掉就行了。
如下圖:
所以正常情況下,是不會(huì)出現(xiàn)什么問題的,但是如果是突發(fā)性的大流量來襲呢?
這個(gè)時(shí)候就不好說了,因?yàn)楹芸赡茉诙虝r(shí)間內(nèi)突然涌入大量的請求,這些請求創(chuàng)建了大量的對象,瞬間就填滿了年輕代,然后這個(gè)時(shí)候觸發(fā)年輕代 gc 后,發(fā)現(xiàn)大量的對象是沒法回收的,此時(shí)只能怎么辦?
只能把這些對象轉(zhuǎn)移到老年代里去了,如下圖:
那么這個(gè)時(shí)候年輕代里的大量存活對象都轉(zhuǎn)移到老年代里去了,老年代里幾乎也被填滿了,然后此時(shí)年輕代里因?yàn)榱髁刻笏矔r(shí)再次被填滿,此時(shí)年輕代里大量的存活對象該何去何從?這個(gè)時(shí)候你去老年代嗎?
老年代都塞滿了存活對象,即使觸發(fā)了老年代 gc 也沒法回收他們,年輕代也沒地方放這些存活對象了,這個(gè)時(shí)候會(huì)如何?
很簡單,由于瞬時(shí)并發(fā)流量太大,同時(shí)創(chuàng)建了太多的存活對象,塞滿了老年代和年輕代,我們很可能會(huì)收到報(bào)警說 jvm 年輕代和老年代內(nèi)存使用率都超過了 90%。
而且這些對象都是存活的都沒法回收,此時(shí)再要?jiǎng)?chuàng)建新的對象,就沒地方創(chuàng)建了,接著就會(huì)報(bào)出 oom 內(nèi)存溢出異常來了。
如下圖:
所以說瞬時(shí)流量激增可能會(huì)導(dǎo)致系統(tǒng) A 發(fā)送內(nèi)存使用率超過 90%,而且很快就 oom 的問題,但是到底是不是這個(gè)問題導(dǎo)致的呢?
雖然我們可以思路順暢的推演出上述場景,但是我們這個(gè)時(shí)候趕緊看一下系統(tǒng) A 的線上 QPS 指標(biāo)監(jiān)控,結(jié)果一臉懵逼的發(fā)現(xiàn),系統(tǒng) A 根本就沒有流量激增,人家的流量一切都很平穩(wěn),所以根本不是這個(gè)原因?qū)е碌膯栴}。
那既然不是這個(gè)問題,那還有什么問題會(huì)導(dǎo)致這個(gè)現(xiàn)象呢?
很簡單,第二種問題就是內(nèi)存泄漏,也就是說,在某種特殊條件下,觸發(fā)了一個(gè)內(nèi)存泄漏的行為,就是你的系統(tǒng)不停的產(chǎn)生某一類對象,這一類對象明明都不用了,結(jié)果還一直放在內(nèi)存里,而且根本回收不掉。
就這么不停的積累這類對象,就會(huì)導(dǎo)致內(nèi)存使用率不停的攀升,最后導(dǎo)致 oom 內(nèi)存溢出。
如下圖:
那么針對這個(gè)內(nèi)存泄漏的問題,這個(gè)時(shí)候我們到底應(yīng)該怎么排查呢?很簡單,這個(gè)時(shí)候你到底是真程序員還是假程序員,得亮亮真功夫了。
往往這種內(nèi)存類的問題,過段的用 jmap 這個(gè)命令,去對線上運(yùn)行的系統(tǒng) jvm 進(jìn)程生成一個(gè)內(nèi)存 dump 快照出來,然后把 dump 快照下載到本地,用 MAT 這個(gè)工具就可以分析這個(gè)內(nèi)存快照。
在 MAT 工具中我們會(huì)看到你的 jvm 里到底是什么破對象占用了那么大的空間,才導(dǎo)致了你的內(nèi)存使用率飆升到 90%+ 的。
這個(gè)時(shí)候其實(shí)導(dǎo)致內(nèi)存泄漏的原因有很多種,比如說你們自己代碼寫的不好,就是每次請求都創(chuàng)建某一類對象,這類對象給扔到某個(gè) class 的靜態(tài) map 里一直放著,從來不回收,也沒法回收,導(dǎo)致這類無用對象一直增長,最后導(dǎo)致了 oom。
另外還有一種比較常見的現(xiàn)象,就是我們的系統(tǒng)使用了一些開源框架,這些開源框架在某種特殊場景下創(chuàng)建了一堆的對象,沒法回收,他自己也從來不回收,導(dǎo)致了開源框架悄咪咪創(chuàng)建的這批對象占用了大量內(nèi)存,導(dǎo)致了內(nèi)存泄漏。
所以在這里給大家說一下我們當(dāng)時(shí)遇到的一個(gè)問題,大家重點(diǎn)吸收排查思路,下面的具體 case by case 的個(gè)別案例可以作為一個(gè)例子看一下。
排查案例
就我們當(dāng)時(shí)的 case 來說,經(jīng)過 MAT 一通排查,發(fā)現(xiàn)占用了大量內(nèi)存的對象是 dubbo 框架創(chuàng)建的,dubbo 框架創(chuàng)建了一種用于進(jìn)行 rpc 調(diào)用的大對象,這類對象一直創(chuàng)建一直增長,然后從來不回收,最后導(dǎo)致了內(nèi)存泄漏和內(nèi)存溢出。
如下圖:
那么 dubbo 框架為什么會(huì)不停的創(chuàng)建一類用于進(jìn)行 rpc 調(diào)用的對象呢?
這就得分析 dubbo 框架的源碼了,當(dāng)時(shí)經(jīng)過 dubbo 框架源碼的分析,我們得出了以下的問題發(fā)生流程:
當(dāng)系統(tǒng) B 在線上進(jìn)行幾十臺(tái)機(jī)器的滾動(dòng)發(fā)布的時(shí)候,每一臺(tái)機(jī)器被發(fā)布,都會(huì)導(dǎo)致注冊中心感知到服務(wù)變動(dòng),然后注冊中心會(huì)把這幾十臺(tái)機(jī)器的地址列表都給系統(tǒng) A 推送過去。
也就是說,連續(xù)發(fā)布幾十臺(tái)機(jī)器,就會(huì)導(dǎo)致注冊中心推送幾十次最新地址列表,每一次推送都包含了幾十臺(tái)機(jī)器的地址。
因此,假設(shè)系統(tǒng) B 部署了 50 臺(tái)機(jī)器,等于隨著 50 臺(tái)機(jī)器依次重新發(fā)布,會(huì)導(dǎo)致注冊中心一共給系統(tǒng) A 推送 50*50=2500 條機(jī)器地址。
如下圖:
而系統(tǒng) A 的 dubbo 框架等于會(huì)收到短時(shí)間內(nèi)頻繁推送的幾千條機(jī)器地址,然后對每條機(jī)器地址,其實(shí) dubbo 框架都會(huì)去創(chuàng)建一個(gè)對應(yīng)的 rpc 調(diào)用類的對象。
如下圖所示:
其實(shí)本來 dubbo 創(chuàng)建幾千次 rpc 調(diào)用對象也沒什么,但是問題就出在了一個(gè)特殊的 case 上了。
那就是系統(tǒng) B 那邊并沒有去設(shè)置對外提供的是什么 rpc 協(xié)議,因?yàn)?dubbo 是支持多種不同的 rpc 協(xié)議的,比如說 dubbo 協(xié)議、http 協(xié)議,等等。
所以在當(dāng)時(shí)的那個(gè)較老的 dubbo 版本中,就出現(xiàn)了一個(gè)隱藏的問題,就是如果系統(tǒng) B 沒設(shè)置具體對外提供的協(xié)議版本,就會(huì)導(dǎo)致系統(tǒng) A 收到幾千條機(jī)器地址后,除了創(chuàng)建 dubbo 協(xié)議的對象,還會(huì)創(chuàng)建幾千個(gè)基于 http rest 類協(xié)議的 rpc 調(diào)用對象。
可是系統(tǒng) B 又沒提供 http rest 接口,因此創(chuàng)建會(huì)全部失敗,但是背后創(chuàng)建的大量對象又會(huì)放著,沒法回收。
這就導(dǎo)致了 dubbo 框架不停的創(chuàng)建出來大量的對象,占用了 90% 的內(nèi)存,最后導(dǎo)致了內(nèi)存溢出。
如下圖:
那么這個(gè)問題是如何解決的呢?其實(shí)問題的核心在于排查思路和背后的原理,最后問題的解決往往是 case by case 的。
比如我們這個(gè)case里,其實(shí)就很簡單,就是要讓系統(tǒng) B 設(shè)置好對外提供的 dubbo protocol 協(xié)議,避免上面那種因?yàn)?protocol 協(xié)議沒設(shè)置導(dǎo)致創(chuàng)建了大量的無用對象沒法回收。
總結(jié)
最后希望大家看完今天的生產(chǎn)排查與優(yōu)化案例后,未來在自己工作中遇到了類似的問題,能給大家提供一種問題排查的思路幫助大家。