震驚!日誌級別居然可能導致Dubbo出現空指針異常

你遇到過Dubbo調用報空指針異常嗎?

下面我要介紹的,是一次真實的產線事件。由於各種因素,導致Dubbo多打印了一行日誌後,出現空指針異常。希望能幫到苦苦尋找答案的你!

備註:文章所用Dubbo版本爲2.6.3

問題復現

  1. Consumer A服務依賴Provider B服務。
  2. Consumer A服務先啓動了,此時註冊中心無B服務實現,而後Provider B啓動。
  3. 兩個系統均正常啓動,但A服務調用B服務時,報空指針異常
    報錯如下:
[dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException] with root cause
java.lang.NullPointerException: null
…

其中,消費者A的Dubbo注入使用了@Reference註解,類似如下代碼

@Service
public class AuthServiceImpl implements AuthService {

    @Reference
    private UserService userService;

    @Override
    public String getNameById(String id) {
        return userService.getNameById(id);
    }
}

出現問題後,我們仔細排查。注意到此處Dubbo服務引用使用的是@Reference註解形式,且未指定check參數(默認爲true)。按照常理來說:

  • 如果A依賴的B服務沒有啓動的話,A服務啓動就應當報錯,但是啓動並沒有報錯
  • B服務上線後,按照Dubbo的功能說明,應當可以做到服務自動注入與發現,A服務調用時不應當出現空指針異常

據此推測,可能是@Reference註解+check參數配置原因,導致Dubbo代理沒有生成,此時注入的服務爲null,因此Dubbo的那一套都失效了。

場景測試

爲了判斷到底是不是@Reference註解+check配置的原因,我特意做了幾個場景測試。包括xml配置和註解配置方式。總結如下:

若A服務依賴Dubbo服務B,當A服務先於B服務啓動,根據配置方式的不同,會有以下幾種場景
Dubbo配置場景測試結果
通過測試結果,可以看到,只有使用註解@Reference進行配置,且check=true場景,不符合我們的預期。
按照官方說明,配置check=true,服務在啓動時進行服務可用性的校驗。如果服務不可用,應當無法啓動纔對。

順着這種異常的場景代碼,進入Dubbo的源碼進行debug,經歷了一系列坑之後,我終於找到產生的原因。下面從Dubbo源碼的角度來分析一下產生的原因。

源碼分析

首先先明確一下,Dubbo中@Reference註解解析的相關代碼在ReferenceConfig類中。
調用關係是
com.alibaba.dubbo.config.ReferenceConfig#init --> com.alibaba.dubbo.config.ReferenceConfig#createProxy

在createProxy方法中,有關check參數解析的代碼如下:

Boolean c = check;
if (c == null && consumer != null) {
    c = consumer.isCheck();
}
if (c == null) {
    c = true; // default true
}
if (c && !invoker.isAvailable()) {
    throw new IllegalStateException("Failed to check the status of the service " + interfaceName + ". No provider available for the service " + (group == null ? "" : group + "/") + interfaceName + (version == null ? "" : ":" + version) + " from the url " + invoker.getUrl() + " to the consumer " + NetUtils.getLocalHost() + " use dubbo version " + Version.getVersion());
}
if (logger.isInfoEnabled()) {
    logger.info("Refer dubbo service " + interfaceClass.getName() + " from url " + invoker.getUrl());
}
// create service proxy
return (T) proxyFactory.getProxy(invoker);

可以看到,check參數默認爲true。如果服務不可用,會拋出IllegalStateException異常!如果check設置爲false,則會跳過校驗邏輯,會繼續下面的生成代理邏輯。

但是爲啥沒有調用到呢?

順着debug調用鏈路往下看,在Spring啓動時,Dubbo註解相關的工作執行代碼調用鏈如下:

  • com.alibaba.dubbo.config.spring.beans.factory.annotation.ReferenceAnnotationBeanPostProcessor#postProcessPropertyValues
  • org.springframework.beans.factory.annotation.InjectionMetadata#inject
  • com.alibaba.dubbo.config.spring.beans.factory.annotation.ReferenceAnnotationBeanPostProcessor.ReferenceFieldElement#inject
  • com.alibaba.dubbo.config.spring.beans.factory.annotation.ReferenceAnnotationBeanPostProcessor#buildReferenceBean
  • com.alibaba.dubbo.config.spring.beans.factory.annotation.AbstractAnnotationConfigBeanBuilder#build
  • com.alibaba.dubbo.config.AbstractConfig#toString
  • com.alibaba.dubbo.config.spring.ReferenceBean#getObject
  • com.alibaba.dubbo.config.ReferenceConfig#get
  • com.alibaba.dubbo.config.ReferenceConfig#init
  • com.alibaba.dubbo.config.ReferenceConfig#createProxy
  1. 首先,使用了@Reference註解的Bean對象,在Spring啓動注入時,會執行com.alibaba.dubbo.config.spring.beans.factory.annotation.ReferenceAnnotationBeanPostProcessor#postProcessPropertyValues方法,進行@Reference字段的注入

  2. 然後,執行進入com.alibaba.dubbo.config.spring.beans.factory.annotation.ReferenceAnnotationBeanPostProcessor.ReferenceFieldElement#inject方法,進行referenceBean對象的構建,構建完成後注入到指定的@Reference字段中

  @Override
  protected void inject(Object bean, String beanName, PropertyValues pvs) throws Throwable {
       Class<?> referenceClass = field.getType();
		// 構建reference對象
       referenceBean = buildReferenceBean(reference, referenceClass);
       ReflectionUtils.makeAccessible(field);
		// 反射注入值
       field.set(bean, referenceBean.getObject());
   }

注意在上述反射注入值時,調用了referenceBean的getObject()方法,此方法會依次調用以下鏈路

  • com.alibaba.dubbo.config.spring.ReferenceBean#getObject
  • com.alibaba.dubbo.config.ReferenceConfig#get
  • com.alibaba.dubbo.config.ReferenceConfig#init
  • com.alibaba.dubbo.config.ReferenceConfig#createProxy

從而完成ReferenceBean的代理生成和返回,理論上這裏應該是check發揮作用(拋異常)的地方,一次正常的調用鏈路應當如此。

但是回過頭來查看我們debug的調用鏈路,可以發現,referenceBean的getObject()方法好像在奇怪的地方被調用了!!!

  • com.alibaba.dubbo.config.spring.beans.factory.annotation.ReferenceAnnotationBeanPostProcessor#buildReferenceBean
  • com.alibaba.dubbo.config.spring.beans.factory.annotation.AbstractAnnotationConfigBeanBuilder#build
  • com.alibaba.dubbo.config.AbstractConfig#toString

這是什麼鬼?!
在構建ReferenceBean的時候(buildReferenceBean),referenceBean的getObject()居然被提前執行了?

先來看一下調用點,com.alibaba.dubbo.config.spring.beans.factory.annotation.AbstractAnnotationConfigBeanBuilder#build

 public final B build() throws Exception {
        checkDependencies();
        B bean = doBuild();
        configureBean(bean);
		// 日誌級別達到info,就會打印下面的bean,就會調用AbstractConfig的toString方法
        if (logger.isInfoEnabled()) {
            logger.info(bean + " has been built.");
        }
        return bean;
    }

這個方法看起來平平無奇,爲啥會調用到referenceBean的getObject方法呢?
答案就在這裏的日誌打印上!打印此處的bean(爲ReferenceBean類型),會調用到這個bean基類(AbstractConfig)的toString方法

我們進到AbstractConfig的toString方法裏看一下:

public String toString() {
        try {
            StringBuilder buf = new StringBuilder();
            buf.append("<dubbo:");
            buf.append(getTagName(getClass()));
            Method[] methods = getClass().getMethods();
            for (Method method : methods) {
                try {
                    String name = method.getName();
                    if ((name.startsWith("get") || name.startsWith("is"))
                            && !"getClass".equals(name) && !"get".equals(name) && !"is".equals(name)
                            && Modifier.isPublic(method.getModifiers())
                            && method.getParameterTypes().length == 0
                            && isPrimitive(method.getReturnType())) {
                        int i = name.startsWith("get") ? 3 : 2;
                        String key = name.substring(i, i + 1).toLowerCase() + name.substring(i + 1);
                        // 此處調用了一些類方法
                        Object value = method.invoke(this);
                        if (value != null) {
                            buf.append(" ");
                            buf.append(key);
                            buf.append("=\"");
                            buf.append(value);
                            buf.append("\"");
                        }
                    }
                } catch (Exception e) {
                    logger.warn(e.getMessage(), e);
                }
            }
            buf.append(" />");
            return buf.toString();
        } catch (Throwable t) {
            logger.warn(t.getMessage(), t);
            return super.toString();
        }
    }

哦!看到這裏就恍然大悟了。

可能是爲了打印日誌更詳細,AbstractConfig的toString方法裏利用反射調用了其相關的一些方法來獲取值。而我們的referenceBean是繼承自這個AbstractConfig的toString方法的,其getObject方法又正好滿足這裏的條件,所以就提前被調用了。(注意這裏因爲check=true,服務不可用而拋出的異常被catch給吃掉了

按道理說這裏提前調用了,應該也不影響後續的調用生成代理邏輯呀!
但是我在debug中,發現後續的調用居然直接被跳過了!
它是如何跳過的呢?

現在我們回頭看看check字段被解析的方法,它的入口在com.alibaba.dubbo.config.ReferenceConfig#init,該方法對類變量initialized進行了校驗。

  • 第一次執行此方法的時候initialized爲false,可以進入下面check的解析與服務可用性校驗的邏輯
  • 第二次執行時,initialized爲true,導致方法執行直接被中斷,無法執行到check字段被解析的地方了
private void init() {
        if (initialized) {
            return;
        }
        initialized = true;
        ......
}

也就是說,上述的toString方法裏先調用了這個方法,所以到真正需要調用這個方法的地方,反而因爲已經initialized了,被直接攔截了!真讓人哭笑不得~

綜合以上因素,可以總結如下:

  1. 由於打印日誌的需要,提前調用了ReferenceBean的初始化方法,理論上檢測服務可用性而拋出的異常,被日誌打印的catch捕獲了!
  2. 由於ReferenceBean初始化進行了次數校驗,只有第一次可以進入執行。所以第二次真正應當調用的地方,反而被攔截了

這樣,因爲一行日誌打印,就出現null指針異常了。。。

驗證

上面已經說明了,因爲打印了ReferenceBean這個對象,導致調用父類toString方法,提前完成了init(異常又被toString方法捕獲處理了),從而無法正確生成代理,且又能夠正常啓動。
我們看到,調用生成代碼的邏輯中加了判斷

if (logger.isInfoEnabled()) {
	logger.info(bean + " has been built.");
}

因此只有日誌級別達到了info級別,纔會打印這個對象。那麼如果我調整一下日誌,不打印日誌,或者打印日誌級別爲error,會發生什麼情況呢?

 <logger name="com.alibaba.dubbo" level="ERROR"></logger>

修改log4j2.xml中的日誌級別爲error,重新跑一下上述異常的測試場景,我們發現@Reference註解配置Dubbo的應用已經無法啓動了,報錯與XML配置場景一致。
這說明我們的猜測是正確的!日誌級別(小於等於info)居然真的導致Dubbo出現空指針異常!

其他場景說明

  • 爲啥XML配置不會出現空指針?

因爲XML配置走的調用路徑與註解不一樣啊,XML配置不會走到方法com.alibaba.dubbo.config.spring.beans.factory.annotation.AbstractAnnotationConfigBeanBuilder#build中哦

  • 爲啥註解配置 + check=false 能夠正確生成代理?

因爲check=false不會檢查服務狀態,所以toString方法調用時,仍然可以正確完成初始化,也就是說不會影響後面代理的生成邏輯

一些建議

這個bug在2.6.5版本已經修復了,但是許多公司產線上跑着的Dubbo版本,依然低於此版本,所以仍然有出現這個問題的可能。

  • 最好的修復建議是升版本,版本的選擇可以參考
    dubbo個版本總結與升級建議

  • 如果無法升版本,那麼儘量使用xml配置吧

  • 如果依然無法使用xml配置,那麼在@Reference註解處加一個 “check=false” 應該是可以的吧

  • 如果依然不行的話,那麼你只能通過運維保證,按照服務依賴的順序來發布了

debug中的坑

下面分享一下在調試過程中遇到的一些坑吧!

在排查這個問題過程中,我發現IDEA調試時,默認是開啓toString預覽的!也就是說會默認調用對象的toString方法。這對於Dubbo空指針問題的調試,無疑是雪上加霜!調試着,突然就跳去了一個奇怪的地方,着實讓人惱火。

好在IDEA提供了關閉的選項,IDEA debug關閉toString預覽方法如下

不要勾選複選框
Build,Execution,Deployment -> Debugger -> Data Views -> Java -> Enable ‘toString()’ object views

IDEA debug去除toString預覽

番外篇

如果你從GitHub上拉取Dubbo 2.6.5版本的代碼,可以看到此處的修復代碼如下:

public final B build() throws Exception {
        checkDependencies();
        B bean = doBuild();
        configureBean(bean);
        if (logger.isInfoEnabled()) {
            logger.info("The bean[type:" + bean.getClass().getSimpleName() + "] has been built.");
        }
        return bean;
    }

你看,不再直接打印bean了,而是改爲直接展示simpleName了。

如果你細心一點,查看一下提交記錄,可以看到此問題是 小馬哥2018-10-19 修復的。修復的備註包含了Github issue號
Github問題號
根據這個2657號,去GitHub上Dubbo項目下搜索issue,追蹤溯源,你能看到原始的問題:Dubbo use diferent behavior when log’s level greater than info

原來早就有人發現了相同的問題了。。。
看來下次看到這類問題,還是需要去官方代碼庫中看看有沒有人問過類似的問題,這樣可以減少我們很多的時間和精力!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章