記一次dubbo服務發現導致的OOM


 

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

image.png
我們查看了一下進來的請求也很平穩,並沒有突然爆發,那這個地方的罪魁禍首會是誰呢?為了方便讀者接下來的閱讀,在介紹這次故障之前,我們首先介紹一下我司的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。
image.png
我們找到應用B的ItemLockService服務,查看它的源代碼,發現它的注解寫在實現類而不是接口上面,導致消費端無法共享相應的REST配置信息,比如httpMethod等。這樣消費者在創建rest invoker建立連接的時候就會因為不知道httpMethod而失敗。
image.png

為什么消費者會去創建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。
image.png

為什么會重復創建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的問題算是徹底解決了。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM