某個平凡的下午,我們突然收到大量的線上告警:應用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台機器是依次上線的,至此我們可以還原整個事情發生的過程:
1)應用A在調用應用B的ItemLockService服務時,它的消費端dubbo reference沒有配置protocol,應用正常啟動,但是它的rest invoker都建立失敗了。
2)應用B機器有100台,然後釋出的時候這些機器依次啟動,每啟動一台就會導緻注冊中心上ItemLockService服務的注冊位址都會發生變化,每次變化都會導緻注冊中心會通知一次消費者,這樣注冊中心會通知100次消費者。
3)注冊中心每次把變化之後的位址通知給消費者的時候,消費者這邊會根據這個注冊位址清單重新生成rest invoker,這個注冊位址清單會包含目前線上的100台應用B的機器。由于之前所有的rest invoker都建立失敗了,是以這次需要對這100個url生成rest invoker,這樣每次注冊中心通知一次消費者,消費者都要去建立100次invoker,并且都建立失敗,這樣下次會接着建立。
4)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的問題算是徹底解決了。