又踩到Dubbo的坑,但是這次我笑不出來(lái)
前 言
直入主題,線上應(yīng)用發(fā)現(xiàn),偶發(fā)性出現(xiàn)如下異常日志
當(dāng)然由于線上具體異常包含信息量過(guò)大,秉承讓肥朝的粉絲沒有難調(diào)試的代碼
的原則,我特意抽取了一個(gè)復(fù)現(xiàn)的demo放在了git,讓你不在現(xiàn)場(chǎng),一樣享受到排查的快樂!但是最近,太多假粉伸手黨拿到地址就跑,因此我把地址藏在本文某個(gè)角落,因此認(rèn)真看文的才能找到!(重點(diǎn))
由于工作性質(zhì)的原因,上班時(shí)間根本抽不出時(shí)間做其他事,修bug,都只能下班時(shí)間來(lái)做,因此周六就到公司搬磚了。
什么是ConcurrentModificationException?
中文意思就是,并發(fā)修改異常
。也就是我們常說(shuō)的fail-fast
(快速失?。?。當(dāng)然肥朝更認(rèn)為,快速失敗
是一種思想,比如Spring會(huì)在啟動(dòng)的時(shí)候做大量的檢查,什么bean找不到,依賴注入錯(cuò)誤等等,都會(huì)把一些顯而易見的錯(cuò)誤檢查出來(lái),防止在項(xiàng)目跑著跑著期間再失敗,也就是提前檢查。無(wú)論是業(yè)務(wù)開發(fā),還是基礎(chǔ)組件開發(fā),亦或是生活中,這個(gè)思想都是可以用到的。
那么,言歸正傳,這個(gè)異常到底什么意思啊。簡(jiǎn)單說(shuō)就是,當(dāng)一個(gè)集合在遍歷的時(shí)候,他的元素也正在被修改。剛學(xué)java那會(huì),我們邊遍歷邊刪除就會(huì)出現(xiàn)這個(gè)異常。ConcurrentModificationException
的原理這些網(wǎng)上太多,肥朝就暫且不提。那么我們來(lái)看下異常棧。
好了,我們已經(jīng)找到了RpcContext.getContext().getObjectAttachments()
正在遍歷。那么,只要找到誰(shuí)在修改他就行了啊,就這?
難點(diǎn)分析
很明顯,這里面并不存在遍歷的同時(shí)修改元素,Dubbo的代碼還不至于有這個(gè)明顯的bug。出現(xiàn)ConcurrentModificationException
,就有可能是,A線程在遍歷,B線程在修改。
但是肥朝,你說(shuō)了這么多,我還是沒發(fā)現(xiàn)這個(gè)問(wèn)題有什么難的啊!
這個(gè)問(wèn)題難點(diǎn)主要在于,在Dubbo里面,RpcContext
是對(duì)應(yīng)一個(gè)線程的,你可以簡(jiǎn)單理解為ThreadLocal
的增強(qiáng)版。也就是說(shuō),A線程拿出來(lái)的,和B線程拿出來(lái)的RpcContext
都不是同一個(gè),何來(lái)并發(fā)修改同一個(gè)之說(shuō)?當(dāng)然官方文檔給了我一個(gè)啟示
會(huì)不會(huì)有同學(xué)在線程開啟前拿到RpcContext
,然后在新線程中,做set操作(圖中的get操作是沒有問(wèn)題的)。
于是,似乎豁然開朗的我,順著這條線索,周六加了一天班,把代碼翻了個(gè)遍,最后發(fā)現(xiàn)沒有找到。
索然無(wú)味還是柳暗花明?
并發(fā)這東西,要么不出問(wèn)題,一旦出問(wèn)題都是很難找。觀察了線上日志,重現(xiàn)概率很小,就一小段日志,并且業(yè)務(wù)方很忙,也沒時(shí)間配合你查問(wèn)題。于是只能順著源碼,把Dubbo的整個(gè)請(qǐng)求到響應(yīng)的過(guò)程在腦海中快速過(guò)幾遍,看看哪個(gè)環(huán)節(jié)有可能出問(wèn)題,做了無(wú)數(shù)的假設(shè)。隨著一次次的假設(shè)失敗,在即將身體索然無(wú)味
之際,還真發(fā)現(xiàn)了一些蛛絲馬跡?。ㄗ⒁猓疚乃玫降?,都是dubbo2.7.6)
我們先來(lái)看一下官方文檔對(duì)RpcContext
的介紹
好了,那么我問(wèn)你,下面這段代碼,love
能輸出什么?
@Service
public?class?AHelloServiceImpl?implements?AHelloService?{
????@Reference
????private?BHelloService?bHelloService;
????@Override
????public?String?sayHello()?throws?Exception{
????????RpcContext.getContext().setAttachment("我最愛的人是?","肥朝");
????????bHelloService.sayHello();
????????String?love?=?RpcContext.getContext().getAttachment("我最愛的人是?");
????????System.out.println("this?is:?"?+?love);
????????Thread.sleep(10L);
????????bHelloService.sayHello();
????????return?"歡迎關(guān)注微信公眾號(hào):肥朝";
????}
}
我在圖都圈得這么明顯了,看得懂中文都知道,發(fā)起一次遠(yuǎn)程調(diào)用后,參數(shù)會(huì)被清空,下面肯定get不到的啦。但是其實(shí)是get得到的,不要問(wèn)肥朝為什么都知道圖是有問(wèn)題的,還特意圈起來(lái)騙你,我只想讓你知道社會(huì)險(xiǎn)惡。
源碼細(xì)節(jié)
閱讀過(guò)源碼,和對(duì)源碼有細(xì)節(jié)深入思考,效果是很大不一樣的。
我們來(lái)看一下源碼就知道了。文中說(shuō)的會(huì)清除,對(duì)應(yīng)的代碼是怎么樣的呢?
如果作為正常的客戶端調(diào)用,那么,在調(diào)用后確實(shí)是會(huì)刪除的。但是如果你對(duì)源碼細(xì)節(jié)足夠熟悉你就會(huì)發(fā)現(xiàn),在org.apache.dubbo.rpc.filter.ContextFilter
這個(gè)類中
你不看代碼直接聽我說(shuō)也行,這幾段代碼的意思是,在一個(gè)提供者的方法中,canRemove
會(huì)設(shè)置為false的,所以,他們?cè)谶@個(gè)方法體遠(yuǎn)程調(diào)用中,是沒辦法清空RpcContext
的,需要在整體調(diào)用完才會(huì)清空。
我們?cè)倩仡櫼幌掳赴l(fā)現(xiàn)場(chǎng)
@Override
public?String?sayHello()?throws?Exception{
????bHelloService.sayHello();
????Thread.sleep(10L);
????bHelloService.sayHello();
????return?"歡迎關(guān)注微信公眾號(hào):肥朝";
}
從目前得到的信息很明顯知道,第一次遠(yuǎn)程調(diào)用,和第二次遠(yuǎn)程調(diào)用,用的是同一個(gè)RpcContext
,并且,在第二次遠(yuǎn)程調(diào)用的時(shí)候。這個(gè)RpcContext
的內(nèi)容,給人動(dòng)了手腳了。
那么,究竟是何人所為!我們隨著鏡頭,再次深入源碼!既然是RpcContext
給人搞了,那么我們就從這里順藤摸瓜,這里先省略肥朝的內(nèi)心戲,我們來(lái)看重點(diǎn)。在RpcContext
中發(fā)現(xiàn)一段可疑片段
public?static?void?restoreContext(RpcContext?oldContext)?{
????LOCAL.set(oldContext);
}
接著繼續(xù)順豐摸瓜,發(fā)現(xiàn)調(diào)用這段代碼的邏輯是
/**
?*?tmp?context?to?use?when?the?thread?switch?to?Dubbo?thread.
?*/
private?RpcContext?tmpContext;
private?RpcContext?tmpServerContext;
private?BiConsumer?beforeContext?=?(appResponse,?t)?->?{
????tmpContext?=?RpcContext.getContext();
????tmpServerContext?=?RpcContext.getServerContext();
????RpcContext.restoreContext(storedContext);
????RpcContext.restoreServerContext(storedServerContext);
};
private?BiConsumer?afterContext?=?(appResponse,?t)?->?{
????RpcContext.restoreContext(tmpContext);
????RpcContext.restoreServerContext(tmpServerContext);
};
public?Result?whenCompleteWithContext(BiConsumer?fn) ?{
????this.responseFuture?=?this.responseFuture.whenComplete((v,?t)?->?{
????????beforeContext.accept(v,?t);
????????fn.accept(v,?t);
????????afterContext.accept(v,?t);
????});
????return?this;
}
@Override
public?Result?invoke(Invocation?invocation)?throws?RpcException?{
????Result?asyncResult;
????try?{
????????interceptor.before(next,?invocation);
????????asyncResult?=?interceptor.intercept(next,?invocation);
????}?catch?(Exception?e)?{
????????//?onError?callback
????????if?(interceptor?instanceof?ClusterInterceptor.Listener)?{
????????????ClusterInterceptor.Listener?listener?=?(ClusterInterceptor.Listener)?interceptor;
????????????listener.onError(e,?clusterInvoker,?invocation);
????????}
????????throw?e;
????}?finally?{
????????interceptor.after(next,?invocation);
????}
????return?asyncResult.whenCompleteWithContext((r,?t)?->?{
????????//?onResponse?callback
????????if?(interceptor?instanceof?ClusterInterceptor.Listener)?{
????????????ClusterInterceptor.Listener?listener?=?(ClusterInterceptor.Listener)?interceptor;
????????????if?(t?==?null)?{
????????????????listener.onMessage(r,?clusterInvoker,?invocation);
????????????}?else?{
????????????????listener.onError(t,?clusterInvoker,?invocation);
????????????}
????????}
????});
}
看不懂代碼不要怕,肥朝大白話解釋一下。你就想象一個(gè)Dubbo異步場(chǎng)景,Dubbo異步回調(diào)結(jié)果的時(shí)候,是會(huì)開啟一個(gè)新的線程,那么,這個(gè)回調(diào)就和當(dāng)初請(qǐng)求不在一個(gè)線程里面了,因此這個(gè)回調(diào)線程是拿不到當(dāng)初請(qǐng)求的RpcContext
。但是我們清空RpcContext
是需要在一次請(qǐng)求結(jié)束的時(shí)候,也就是說(shuō),雖然異步回調(diào)是另外一個(gè)線程了,但是我們?nèi)匀恍枰玫疆?dāng)初請(qǐng)求時(shí)候的RpcContext
來(lái)走Filter
,做清空等操作。上面那段代碼就是做,切換線程怎么拿回之前的RpcContext
。
聽完上面的分析,你是不是明白了點(diǎn)啥?新線程,還能拿到舊的RpcContext
。那么,有這么一個(gè)場(chǎng)景,我們?cè)谕ㄟ^(guò)提供者方法中,發(fā)起兩個(gè)異步請(qǐng)求,第一個(gè)請(qǐng)求走Filter
的onResponse
(響應(yīng)結(jié)果)的時(shí)候,我們?nèi)绻?code style="margin-right: 2px;margin-left: 2px;font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;background: rgb(248, 245, 236);color: rgb(255, 53, 2);line-height: 1.5;font-size: 90%;padding: 3px 5px;border-radius: 2px;">Filter做RpcContext.getContext().setAttachment
操作,第二個(gè)請(qǐng)求又正好發(fā)起,而發(fā)起又會(huì)經(jīng)歷putAll
這步驟,就會(huì)出現(xiàn)這個(gè)并發(fā)修改異常。于是乎,真相大白!
具體詳情,親自調(diào)試一番就會(huì)清楚,肥朝公眾號(hào)回復(fù)modification
獲取git地址
拓展性思考
真相大白就結(jié)束了?熟悉肥朝的粉絲都知道,我們遇到問(wèn)題,要盡量壓榨問(wèn)題的全部?jī)r(jià)值!比如,你說(shuō)不要在攔截器中onResponse
方法中用RpcContext.getContext().setAttachment
這樣的操作,但是我們確實(shí)有類似需要,那到底要怎么寫代碼又不說(shuō),你這樣叫我怎么給你轉(zhuǎn)發(fā)文章!
我們要知道怎么正確寫代碼,那直接去抄Dubbo其他攔截器的代碼不就知道了?比如
@Activate(group?=?PROVIDER,?order?=?-10000)
public?class?ContextFilter?implements?Filter,?Filter.Listener?{
????@Override
????public?void?onResponse(Result?appResponse,?Invoker>?invoker,?Invocation?invocation)?{
????????//?pass?attachments?to?result
????????appResponse.addObjectAttachments(RpcContext.getServerContext().getObjectAttachments());
????}
}
我們很明顯看到,你熟悉一下appResponse
的api和他的作用,就很容易知道,有類似需求,代碼應(yīng)該怎么寫了。我光告訴你怎么寫代碼沒用啊,我要告訴你,遇到問(wèn)題,怎么去抄正確代碼,讓你任何時(shí)候,都有得cao!
特別推薦一個(gè)分享架構(gòu)+算法的優(yōu)質(zhì)內(nèi)容,還沒關(guān)注的小伙伴,可以長(zhǎng)按關(guān)注一下:
長(zhǎng)按訂閱更多精彩▼
如有收獲,點(diǎn)個(gè)在看,誠(chéng)摯感謝
免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺(tái)僅提供信息存儲(chǔ)服務(wù)。文章僅代表作者個(gè)人觀點(diǎn),不代表本平臺(tái)立場(chǎng),如有問(wèn)題,請(qǐng)聯(lián)系我們,謝謝!