平凡的下午,我們突然收到大量的線上告警:應用A的老年代內存使用率大於95%。登陸到監控管理平台可以看到3點半之后該應用的老年代內存使用率一路飆升,直逼100%,接着年輕代也一路上升

我們查看了一下進來的請求也很平穩,並沒有突然爆發,那這個地方的罪魁禍首會是誰呢?為了方便讀者接下來的閱讀,在介紹這次故障之前,我們首先介紹一下我司的dubbo服務發現的流程。
dubbo服務發現流程
我們的開發人員在使用遠程服務的時候首先需要配置一個dubbo xml文件或者在使用的地方加上@Reference,二者都是用來對dubbo消費者引用服務進行一些配置,然后應用在啟動的時候會將配置信息轉化為一個ReferenceBean對象,並調用createProxy方法創建一個遠程服務接口的代理對象。因為我們的消費者並不是和服務端直接地址相連的,而是訂閱到公司的注冊中心etcd上。訂閱完之后會顯示地調用一次notify接口,這個接口會嘗試將遠程服務注冊的url轉換成一個本地的invoker,如果消費者端配置的消費protocol時dubbo的,就會生成一個dubbo invoker;如果配置的是rest,就會生成一個rest invoker;如果消費端的protocol什么都沒有配置,那么服務端提供什么服務,它就會生成什么服務的invoker。
第二個流程是應用啟動完畢之后,如果服務注冊地址發生了改變的話,會通知給消費者,注意這個地方dubbo協議里面明確講到全量更新而不是增量更新。也就是說如果服務端有100台機器,現在其中某一台服務器我重啟了,這個時候這台服務器的注冊url就會發生變化,但是注冊中心會將這100台機器的注冊url全部通知給消費者。消費者會拿着更新之后的服務url嘗試更新本地的invoker,如果這個url之前已經成功創建過invoker的話,那么就不會再創建了,如果沒有的話就會去嘗
熟悉相關概念和流程之后,接下來我們會詳細介紹一下我們定位OOM的過程。
OOM定位
我們登陸到故障機器,查看jvm內存的使用情況。

可以看到是hashmap的entry太多,難道是哪位童鞋使用了一個全局hashmap,並且忘了清理不用的數據了嗎?但是即使要用hashmap一般也不太會用fastjson的IdentityHashMap啊?並且我們在應用中也沒看到相關的使用代碼。為了做更進一步的分析,我們通過jmap命令生成內存dump文件,並在運維的幫助下下載到本地,利用工具MAT對其進行分析。

從概況下可以看到,大對象占用了大概2.4G的內存空間。然后我們對dominator_tree進行分析,可以定位到大對象是dubbo協議類RestProtocol的一個list。

找到RestProtocol代碼,我們可以定位到屬性List clients,這個對象是一個Collections.synchronizedList(new LinkedList())。RestProtocol協議對象在生成遠程服務invoker的時候會往這個list里面添加ResteasyClient對象,並且在它的生命周期結束之后才會把這個list清理掉。我們猜測由於某種原因導致這個RestProtocol對象不停的生成invoker,直至OOM。至此我們算是定位到OOM的地方,接下來將會探尋具體的泄漏原因。
內存泄漏分析
clients.add這塊到底發生了什么?
我們在本地啟動應用A,調試一下dubbo服務發現的過程,在RestProtocol的clients.add(client)打上斷點。

從右下角的方框我們可以看到遠程服務是應用B的interface xx.xx.service.ItemLockService,並且提供的是rest服務。我們繼續調試往下走,結果在調用target.proxy方法的時候發生異常,導致生成rest invoker失敗了。我們定位到異常的地方發現httpMethod為null。
我們找到應用B的ItemLockService服務,查看它的源代碼,發現它的注解寫在實現類而不是接口上面,導致消費端無法共享相應的REST配置信息,比如httpMethod等。這樣消費者在創建rest invoker建立連接的時候就會因為不知道httpMethod而失敗。
為什么消費者會去創建rest invoker?
到目前為止,我們知道消費者在創建rest invoker的時候,嘗試和服務端建立rest連接,最后卻失敗了,並且導致rest invoker也沒有創建成功,那么為什么消費者會去創建rest invoker呢?從上面圖6的歷史堆棧信息,我們發現ItemLockService服務的ReferenceConfig中的protocol為null,我們找到應用A消費端配置信息,發現itemLockService消費端沒有配置protocol這個字段。從下面的圖9可以看出,如果消費者配置protocol字段的話,會根據這個protocol對服務端提供的協議進行過濾。如果沒有配置的話,會創建服務端提供的所有協議的invoker,ItemLockService服務端提供兩種協議,即dubbo和rest,所以這個地方應用A的消費端會創建rest invoker。
為什么會重復創建rest invoker?
到這里,我們知道ItemLockService的消費者因為沒有配置protocol字段導致會去創建rest invoker,並且會創建失敗,但是為什么會去重復創建rest invoker呢?接下來我們需要探尋一下創建rest invoker的時機。消費者一般在兩個場景下會去rest invoker,一個是應用啟動的時候消費者在訂閱到注冊中心的時候,會主動拉去服務注冊地址;另一個場景是當服務注冊地址,即invoker url發生變化之后,注冊中心會將變化之后的invoker url通知給消費端,這個時候消費端會將新的invoker url轉換成invokers,如果這個url沒有創建成功過invoker,就會嘗試重新創建invoker。而消費者每次在創建rest invoker的時候都會失敗,這樣就會導致下次收到服務端的消息通知的時候還會去創建invoker。
真相大白
在和商品中心溝通之后,我們了解到他們下午3點半左右重新發布了應用B,應用B的100台機器是依次上線的,至此我們可以還原整個事情發生的過程:
- 應用A在調用應用B的ItemLockService服務時,它的消費端dubbo reference沒有配置protocol,應用正常啟動,但是它的rest invoker都創建失敗了。
- 應用B機器有100台,然后發布的時候這些機器依次啟動,每啟動一台就會導致注冊中心上ItemLockService服務的注冊地址都會發生變化,每次變化都會導致注冊中心會通知一次消費者,這樣注冊中心會通知100次消費者。
- 注冊中心每次把變化之后的地址通知給消費者的時候,消費者這邊會根據這個注冊地址列表重新生成rest invoker,這個注冊地址列表會包含當前在線的100台應用B的機器。由於之前所有的rest invoker都創建失敗了,所以這次需要對這100個url生成rest invoker,這樣每次注冊中心通知一次消費者,消費者都要去創建100次invoker,並且都創建失敗,這樣下次會接着創建。
- RestProtocol每次根據url創建rest invoker的時候,會生成ResteasyClient大對象,並且把這個對象放到列表里面去,這樣應用B發布一次的話,就會創建100*100=10000個大對象,於是就導致了內存的溢出。這也解釋了為什么故障發生之后我們重啟了應用A就臨時解決了內存溢出的問題,但是一旦應用B重新發布的時候,應用A就會OOM。
思考
對開發人員來說,這個問題主要是由於使用方沒有配置protocol字段所致,所以平時在寫代碼的時候盡量了解參數具體的含義,否則可能會出現一些意料之外的場景。
對dubbo框架而言,需要做好參數校驗和防御性編程。在本次故障之后,我們中間件童鞋添加了如下代碼

另外目前ResteasyClient對象由RestProtocol協議對象持有,只有當RestProtocol對象銷毀的時候才會把restclient對象銷毀掉,這樣是否合適?對於那種沒有創建成功invoker的場景是不是應該把其對應的ResteasyClient銷毀掉會更合適一點,因此認為可以對代碼做如下修改
ResteasyClient client = new ResteasyClientBuilder().httpEngine(engine).build(); client.register(RpcContextFilter.class); for (String clazz : Constants.COMMA_SPLIT_PATTERN.split(url.getParameter(Constants.EXTENSION_KEY, ""))) { if (!StringUtils.isEmpty(clazz)) { try { client.register(Thread.currentThread().getContextClassLoader().loadClass(clazz.trim())); } catch (ClassNotFoundException e) { throw new RpcException("Error loading JAX-RS extension class: " + clazz.trim(), e); } } } // TODO protocol ResteasyWebTarget target = client.target("http://" + url.getHost() + ":" + url.getPort() + "/" + getContextPath(url)); try{ T t=target.proxy(serviceType); //invoker創建成功才會存儲client clients.add(client); return t; }catch(Exception e){ logger.warn("fail to create proxy,serviceType:{}",serviceType,e); //invoker創建失敗的話會把client也一起銷毀掉 try { if(null!=client){ client.close(); } } catch (Throwable t) { logger.warn("Error closing rest client", t); } throw e; }
因此我們給apache dubbo提了個issue,從溝通的結果來看,也有其他的開發者碰到了這個問題,在pull request 4629里面他們詳細記錄了解決方案。他們首先用Map代替之前的List,key為url,這樣可以保證同一個url只會創建一次ResteasyClient,接着為了進一步保證能夠回收不用的ResteasyClient,他們又引入了WeakHashMap了,至此OOM的問題算是徹底解決了。

