又踩到Dubbo的坑,可是此次我笑不出來

前言

直入主題,線上應用發現,偶發性出現以下異常日誌java

圖片

圖片

固然因爲線上具體異常包含信息量過大,秉承讓肥朝的粉絲沒有難調試的代碼的原則,我特地抽取了一個復現的demo放在了git,讓你不在現場,同樣享受到排查的快樂!可是最近,太多假粉伸手黨拿到地址就跑,所以我把地址藏在本文某個角落,所以認真看文的才能找到!(重點)git

圖片

因爲工做性質的緣由,上班時間根本抽不出時間作其餘事,修bug,都只能下班時間來作,所以週六就到公司搬磚了。面試

什麼是ConcurrentModificationException?

中文意思就是,併發修改異常。也就是咱們常說的fail-fast(快速失敗)。固然肥朝更認爲,快速失敗是一種思想,好比Spring會在啓動的時候作大量的檢查,什麼bean找不到,依賴注入錯誤等等,都會把一些顯而易見的錯誤檢查出來,防止在項目跑着跑着期間再失敗,也就是提早檢查。不管是業務開發,仍是基礎組件開發,亦或是生活中,這個思想都是能夠用到的。apache

那麼,言歸正傳,這個異常到底什麼意思啊。簡單說就是,當一個集合在遍歷的時候,他的元素也正在被修改。剛學java那會,咱們邊遍歷邊刪除就會出現這個異常。ConcurrentModificationException的原理這些網上太多,肥朝就暫且不提。那麼咱們來看下異常棧。api

好了,咱們已經找到了RpcContext.getContext().getObjectAttachments()正在遍歷。那麼,只要找到誰在修改他就好了啊,就這?微信

難點分析

很明顯,這裏面並不存在遍歷的同時修改元素,Dubbo的代碼還不至於有這個明顯的bug。出現ConcurrentModificationException,就有多是,A線程在遍歷,B線程在修改。併發

可是肥朝,你說了這麼多,我仍是沒發現這個問題有什麼難的啊!app

這個問題難點主要在於,在Dubbo裏面,RpcContext是對應一個線程的,你能夠簡單理解爲ThreadLocal的加強版。也就是說,A線程拿出來的,和B線程拿出來的RpcContext都不是同一個,何來併發修改同一個之說?固然官方文檔給了我一個啓示異步

圖片

會不會有同窗在線程開啓前拿到RpcContext,而後在新線程中,作set操做(圖中的get操做是沒有問題的)。async

圖片

因而,彷佛豁然開朗的我,順着這條線索,週六加了一天班,把代碼翻了個遍,最後發現沒有找到。

圖片

索然無味仍是柳暗花明?

併發這東西,要麼不出問題,一旦出問題都是很難找。觀察了線上日誌,重現機率很小,就一小段日誌,而且業務方很忙,也沒時間配合你查問題。因而只能順着源碼,把Dubbo的整個請求到響應的過程在腦海中快速過幾遍,看看哪一個環節有可能出問題,作了無數的假設。隨着一次次的假設失敗,在即將身體索然無味之際,還真發現了一些蛛絲馬跡!(注意,本文所用到的,都是dubbo2.7.6)

咱們先來看一下官方文檔對RpcContext的介紹

好了,那麼我問你,下面這段代碼,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 "歡迎關注微信公衆號:肥朝";
    }
}

我在圖都圈得這麼明顯了,看得懂中文都知道,發起一次遠程調用後,參數會被清空,下面確定get不到的啦。可是實際上是get獲得的,不要問肥朝爲何都知道圖是有問題的,還特地圈起來騙你,我只想讓你知道社會險惡。

源碼細節

閱讀過源碼,和對源碼有細節深刻思考,效果是很大不同的。

咱們來看一下源碼就知道了。文中說的會清除,對應的代碼是怎麼樣的呢?

若是做爲正常的客戶端調用,那麼,在調用後確實是會刪除的。可是若是你對源碼細節足夠熟悉你就會發現,在org.apache.dubbo.rpc.filter.ContextFilter這個類中

你不看代碼直接聽我說也行,這幾段代碼的意思是,在一個提供者的方法中,canRemove會設置爲false的,因此,他們在這個方法體遠程調用中,是沒辦法清空RpcContext的,須要在總體調用完纔會清空。

咱們再回顧一下案發現場

@Override
public String sayHello() throws Exception{

    bHelloService.sayHello();
    Thread.sleep(10L);
    bHelloService.sayHello();

    return "歡迎關注微信公衆號:肥朝";
}

從目前獲得的信息很明顯知道,第一次遠程調用,和第二次遠程調用,用的是同一個RpcContext,而且,在第二次遠程調用的時候。這個RpcContext的內容,給人動了手腳了。

那麼,到底是何人所爲!咱們隨着鏡頭,再次深刻源碼!既然是RpcContext給人搞了,那麼咱們就從這裏順藤摸瓜,這裏先省略肥朝的心裏戲,咱們來看重點。在RpcContext中發現一段可疑片斷

public static void restoreContext(RpcContext oldContext) {
    LOCAL.set(oldContext);
}

接着繼續順豐摸瓜,發現調用這段代碼的邏輯是

/**
 * tmp context to use when the thread switch to Dubbo thread.
 */

private RpcContext tmpContext;

private RpcContext tmpServerContext;
private BiConsumer<Result, Throwable> beforeContext = (appResponse, t) -> {
    tmpContext = RpcContext.getContext();
    tmpServerContext = RpcContext.getServerContext();
    RpcContext.restoreContext(storedContext);
    RpcContext.restoreServerContext(storedServerContext);
};

private BiConsumer<Result, Throwable> afterContext = (appResponse, t) -> {
    RpcContext.restoreContext(tmpContext);
    RpcContext.restoreServerContext(tmpServerContext);
};
public Result whenCompleteWithContext(BiConsumer<Result, Throwable> 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);
            }
        }
    });
}

看不懂代碼不要怕,肥朝大白話解釋一下。你就想象一個Dubbo異步場景,Dubbo異步回調結果的時候,是會開啓一個新的線程,那麼,這個回調就和當初請求不在一個線程裏面了,所以這個回調線程是拿不到當初請求的RpcContext。可是咱們清空RpcContext是須要在一次請求結束的時候,也就是說,雖然異步回調是另一個線程了,可是咱們仍然須要拿到當初請求時候的RpcContext來走Filter,作清空等操做。上面那段代碼就是作,切換線程怎麼拿回以前的RpcContext

聽完上面的分析,你是否是明白了點啥?新線程,還能拿到舊的RpcContext。那麼,有這麼一個場景,咱們在經過提供者方法中,發起兩個異步請求,第一個請求走FilteronResponse(響應結果)的時候,咱們若是在FilterRpcContext.getContext().setAttachment操做,第二個請求又正好發起,而發起又會經歷putAll這步驟,就會出現這個併發修改異常。因而乎,真相大白!

具體詳情,親自調試一番就會清楚,肥朝公衆號回覆modification獲取git地址

拓展性思考

真相大白就結束了?熟悉肥朝的粉絲都知道,咱們遇到問題,要儘可能壓榨問題的所有價值!好比,你說不要在攔截器中onResponse方法中用RpcContext.getContext().setAttachment這樣的操做,可是咱們確實有相似須要,那到底要怎麼寫代碼又不說,你這樣叫我怎麼給你轉發文章!

咱們要知道怎麼正確寫代碼,那直接去抄Dubbo其餘攔截器的代碼不就知道了?好比

@Activate(group = PROVIDER, order = -10000)
public class ContextFilter implements FilterFilter.Listener {


    @Override
    public void onResponse(Result appResponse, Invoker<?> invoker, Invocation invocation) {
        // pass attachments to result
        appResponse.addObjectAttachments(RpcContext.getServerContext().getObjectAttachments());
    }

}

咱們很明顯看到,你熟悉一下appResponse的api和他的做用,就很容易知道,有相似需求,代碼應該怎麼寫了。我光告訴你怎麼寫代碼沒用啊,我要告訴你,遇到問題,怎麼去抄正確代碼,讓你任什麼時候候,都有得cao!

寫在最後

和上一次的【面試官問我,使用Dubbo有沒有遇到一些坑?我笑了。】不同,此次雖然把問題分析並獲得解決,可是此次我笑不出來,由於,這個bug我也有一部分緣由。。。

相關文章
相關標籤/搜索