你遇到過Dubbo調用報空指針異常嗎?
下面我要介紹的,是一次真實的產線事件。由於各種因素,導致Dubbo多打印了一行日誌後,出現空指針異常。希望能幫到苦苦尋找答案的你!
備註:文章所用Dubbo版本爲2.6.3
問題復現
- Consumer A服務依賴Provider B服務。
- Consumer A服務先啓動了,此時註冊中心無B服務實現,而後Provider B啓動。
- 兩個系統均正常啓動,但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服務啓動,根據配置方式的不同,會有以下幾種場景
通過測試結果,可以看到,只有使用註解@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
…
-
首先,使用了@Reference註解的Bean對象,在Spring啓動注入時,會執行com.alibaba.dubbo.config.spring.beans.factory.annotation.ReferenceAnnotationBeanPostProcessor#postProcessPropertyValues方法,進行@Reference字段的注入
-
然後,執行進入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了,被直接攔截了!真讓人哭笑不得~
綜合以上因素,可以總結如下:
- 由於打印日誌的需要,提前調用了ReferenceBean的初始化方法,理論上檢測服務可用性而拋出的異常,被日誌打印的catch捕獲了!
- 由於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
番外篇
如果你從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號
根據這個2657號,去GitHub上Dubbo項目下搜索issue,追蹤溯源,你能看到原始的問題:Dubbo use diferent behavior when log’s level greater than info
原來早就有人發現了相同的問題了。。。
看來下次看到這類問題,還是需要去官方代碼庫中看看有沒有人問過類似的問題,這樣可以減少我們很多的時間和精力!