生產(chǎn)環(huán)境一次詭異的NPE問題,反轉(zhuǎn)了4次
大家好,我是蘇三,又跟大家見面了。
前言
公司為了保證系統(tǒng)的穩(wěn)定性,加了很多監(jiān)控,比如:接口響應(yīng)時(shí)間、cpu使用率、內(nèi)存使用率、錯(cuò)誤日志等等。如果系統(tǒng)出現(xiàn)異常情況,會(huì)郵件通知相關(guān)人員,以便于大家能在第一時(shí)間解決隱藏的系統(tǒng)問題。此外,我們這邊有個(gè)不成文的規(guī)定,就是線上問題最好能夠當(dāng)日解決,除非遇到那種非常棘手的問題。
1.起因
有個(gè)周一的早上,我去公司上班,查看郵件,收到我們老大轉(zhuǎn)發(fā)的一封郵件,讓我追查線上的一個(gè)NPE(NullPointException)問題。
郵件是通過sentry發(fā)出來的,我們通過點(diǎn)擊郵件中的相關(guān)鏈接,可以直接跳轉(zhuǎn)到sentry的詳情頁面。在這個(gè)頁面中,展示了很多關(guān)鍵信息,比如:操作時(shí)間、請(qǐng)求的接口、出錯(cuò)的代碼位置、報(bào)錯(cuò)信息、請(qǐng)求經(jīng)過了哪些鏈路等等。真是居家旅行,查bug的良藥,有了這些,小case一眼就能查到原因。
我當(dāng)時(shí)沒費(fèi)吹灰之力,就訪問到了NPE的sentry報(bào)錯(cuò)頁面(其實(shí)只用鼠標(biāo)雙擊一下就搞定)。果然上面有很多關(guān)鍵信息,我一眼就看到了NPE的具體代碼位置:
notify.setName(CurrentUser.getCurrent().getUserName());
劇情發(fā)展得如此順利,我都有點(diǎn)不好意思了。
根據(jù)類名和代碼行號(hào),我在idea中很快找到那行代碼,不像是我寫的,這下可以放心不用背鍋了。于是接下來看了看那行的代碼修改記錄,最后修改人是XXX。
什么?是他?
他在一個(gè)月前已經(jīng)離職了,看來這個(gè)無頭公案已經(jīng)無從問起,只能自己查原因。
我當(dāng)時(shí)內(nèi)心的OS是:代碼沒做兼容處理。
為什么這么說呢?
這行代碼其實(shí)很簡(jiǎn)單,就是從當(dāng)前用戶上下文中獲取用戶名稱,然后設(shè)置到notify實(shí)體的inUserName字段上,最終notify的數(shù)據(jù)會(huì)保存到數(shù)據(jù)庫。
該字段表示那條推送通知的添加人,正常情況下沒啥卵用,主要是為了出現(xiàn)線上問題扯皮時(shí),有個(gè)地方可以溯源。如果出現(xiàn)冤案,可以還你清白。
順便提一嘴,這里說的
推送通知跟mq中的消息是兩回事,前者指的是websocket長(zhǎng)連接推送的實(shí)時(shí)通知,我們這邊很多業(yè)務(wù)場(chǎng)景,在頁面功能操作完之后,會(huì)實(shí)時(shí)推送通知給指定用戶,以便用戶能夠及時(shí)處理相關(guān)單據(jù),比如:您有一個(gè)審批單需要審批,請(qǐng)及時(shí)處理等。
CurrentUser內(nèi)部包含了一個(gè)ThreadLocal對(duì)象,它負(fù)責(zé)保存當(dāng)前線程的用戶上下文信息。當(dāng)然為了保證在線程池中,也能從用戶上下文中獲取到正確的用戶信息,這里用了阿里的TransmittableThreadLocal。偽代碼如下:
@Data
public class CurrentUser {
private static final TransmittableThreadLocal<CurrentUser> THREA_LOCAL = new TransmittableThreadLocal<>();
private String id;
private String userName;
private String password;
private String phone;
...
public statis void set(CurrentUser user) {
THREA_LOCAL.set(user);
}
public static void getCurrent() {
return THREA_LOCAL.get();
}
}
這里為什么用了阿里的
TransmittableThreadLocal,而不是普通的ThreadLocal呢?在線程池中,由于線程會(huì)被多次復(fù)用,導(dǎo)致從普通的ThreadLocal中無法獲取正確的用戶信息。父線程中的參數(shù),沒法傳遞給子線程,而TransmittableThreadLocal很好解決了這個(gè)問題。
然后在項(xiàng)目中定義一個(gè)全局的spring mvc攔截器,專門設(shè)置用戶上下文到ThreadLocal中。偽代碼如下:
public class UserInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
CurrentUser user = getUser(request);
if(Objects.nonNull(user)) {
CurrentUser.set(user);
}
}
}
用戶在請(qǐng)求我們接口時(shí),會(huì)先觸發(fā)該攔截器,它會(huì)根據(jù)用戶cookie中的token,調(diào)用調(diào)用接口獲取redis中的用戶信息。如果能獲取到,說明用戶已經(jīng)登錄,則把用戶信息設(shè)置到CurrentUser類的ThreadLocal中。
接下來,在api服務(wù)的下層,即business層的方法中,就能輕松通過CurrentUser.getCurrent();方法獲取到想要的用戶上下文信息了。
這套用戶體系的想法是很good的,但深入使用后,發(fā)現(xiàn)了一個(gè)小插曲:
api服務(wù)和mq消費(fèi)者服務(wù)都引用了business層,business層中的方法兩個(gè)服務(wù)都能直接調(diào)用。
我們都知道在api服務(wù)中用戶是需要登錄的,而mq消費(fèi)者服務(wù)則不需要登錄。
如果business中的某個(gè)方法剛開始是給api開發(fā)的,在方法深處使用了CurrentUser.getCurrent();獲取用戶上下文。但后來,某位新來的帥哥在mq消費(fèi)者中也調(diào)用了那個(gè)方法,并未發(fā)覺這個(gè)小機(jī)關(guān),就會(huì)中招,出現(xiàn)找不到用戶上下文的問題。
所以我當(dāng)時(shí)的第一個(gè)想法是:代碼沒做兼容處理,因?yàn)橹斑@類問題偶爾會(huì)發(fā)生一次。
想要解決這個(gè)問題,其實(shí)也很簡(jiǎn)單。只需先判斷一下能否從CurrentUser中獲取用戶信息,如果不能,則取配置的系統(tǒng)用戶信息。偽代碼如下:
@Autowired
private BusinessConfig businessConfig;
CurrentUser user = CurrentUser.getCurrent();
if(Objects.nonNull(user)) {
entity.setUserId(user.getUserId());
entity.setUserName(user.getUserName());
} else {
entity.setUserId(businessConfig.getDefaultUserId());
entity.setUserName(businessConfig.getDefaultUserName());
}
這種簡(jiǎn)單無公害的代碼,如果只是在一兩個(gè)地方加還OK。
但如果有多個(gè)地方都在獲取用戶信息,難道在每個(gè)地方都需要把相同的判斷邏輯寫一遍?對(duì)于有追求的程序員來說,這種簡(jiǎn)單的重復(fù)是寫代碼的大忌,如何更優(yōu)雅的解決問題呢?
答案將會(huì)在文章后面揭曉。
這個(gè)NPE問題表面上,已經(jīng)有答案了。根據(jù)以往的經(jīng)驗(yàn),由于在代碼中沒有做兼容處理,在mq消費(fèi)者服務(wù)中獲取到的用戶信息為空,對(duì)一個(gè)空對(duì)象,調(diào)用它的方法,就會(huì)出現(xiàn)NPE。
2.第一次反轉(zhuǎn)
但這個(gè)答案顯得有點(diǎn)草率,會(huì)不會(huì)還有什么機(jī)關(guān)?
于是我在項(xiàng)目工程中全局搜索CurrentUser.set關(guān)鍵字,竟然真的找到了一個(gè)機(jī)關(guān)。
劇情出現(xiàn)第一次反轉(zhuǎn)。
有個(gè)地方寫了一個(gè)rocketmq的AOP攔截器,偽代碼如下:
@Aspect
@Component
public class RocketMqAspect {
@Pointcut("execution(* onMessage(..)&&@within(org.apache.rocketmq.spring.annotation.RocketMQMessageListener))")
public void pointcut() {
}
...
@Around(value="pointcut")
public void around(ProceedingJoinPoint point) throws Throwable {
if(point.getArgs().length == 1 && point.getArgs()[0] instanceof MessageExt) {
Message message = (Message)point.getArgs()[0];
String userId = message.getUserProperty("userId");
String userName = message.getUserProperty("userName");
if(StringUtils.notEmpty(userId) && StringUtils.notEmpty(userName)) {
CurrentUser user = new CurrentUser();
user.setUserId(userId);
user.setUserName(userName);
CurrentUser.set(user);
}
}
...
}
}
它會(huì)攔截所有mq消費(fèi)者中的onMessage方法,在該方法執(zhí)行之前,從userProperty中獲取用戶信息,并且創(chuàng)建用戶對(duì)象,設(shè)置到用戶上下文中。
溫馨提醒一下,免得有些朋友依葫蘆畫瓢踩坑。上面的偽代碼只給出了設(shè)置用戶上下文的關(guān)鍵代碼,用完后,刪除用戶上下文的代碼沒有給出,感興趣的朋友可以找我私聊。
既然有獲取用戶信息的地方,我猜測(cè)必定有設(shè)置的地方。這時(shí)候突然發(fā)現(xiàn)自己有點(diǎn)當(dāng)偵探的潛力,因?yàn)楹竺孢€真找到了。
意不意外,驚不驚喜?
另外一個(gè)同事自己自定義了一個(gè)RocketMQTemplate。偽代碼如下:
public class MyRocketMQTemplate extends RocketMQTemplate {
@Override
public void asyncSend(String destnation, Meassage<?> message, SendCallback sendCallback, long timeout, int delayLevel) {
MessageBuilder builder = withPayload(message.getPayLoad());
CurrentUser user = CurrentUser.getCurrent();
builder.setHeader("userId", user.getUserId());
builder.setHeader("userName", user.getUserName());
super.asyncSend(destnation,message,sendCallback,timeout,delayLevel);
}
}
這段代碼的主要作用是在mq生產(chǎn)者在發(fā)送異步消息之前,先將當(dāng)前用戶上下文信息設(shè)置到mq消息的header中,這樣在mq消費(fèi)者中就能通過userProperty獲取到,它的本質(zhì)也是從header中獲取到的。

這個(gè)設(shè)計(jì)比較巧妙,完美的解決了mq的消費(fèi)者中通過CurrentUser.getCurrent();無法獲取用戶信息的問題。
此時(shí)線索一下子斷了,沒有任何進(jìn)展。
我再去查了一下服務(wù)器的日志。確認(rèn)了那條有問題的mq消息,它的header信息中確實(shí)沒有userId和userName字段。
莫非是mq生產(chǎn)者沒有往header中塞用戶信息?這是需要重點(diǎn)懷疑的地方。
因?yàn)閙q生產(chǎn)者是另外一個(gè)團(tuán)隊(duì)寫的代碼,在EOA(簽報(bào)系統(tǒng))回調(diào)他們系統(tǒng)時(shí),會(huì)給我們發(fā)mq消息,通知我們簽報(bào)狀態(tài)。
而EOA是第三方的系統(tǒng),用戶體系沒有跟我們打通。所以在另外一個(gè)團(tuán)隊(duì)的回調(diào)接口中,沒法獲取當(dāng)前登錄的用戶信息,AOP的攔截器就沒法自動(dòng)往header中塞用戶信息,這樣在mq的消費(fèi)者中自然就獲取不到了。

這樣想來還真的是順理成章。
3.第二次反轉(zhuǎn)
但真的是這樣的嗎?
我們抱著很大的希望,給他們發(fā)了一封郵件,讓他們幫忙查一下問題。
很快,他們回郵件了。
但他們說:已經(jīng)本地測(cè)試過,功能正常。
就這樣劇情第二次反轉(zhuǎn)了。
我此時(shí)有點(diǎn)好奇,他們是怎么往header中塞用戶信息的。帶著“學(xué)習(xí)的心態(tài)”,于是找他們一起查看了相關(guān)代碼。
他們?cè)诎l(fā)送mq消息之前,會(huì)調(diào)用一個(gè)UserUtil工具注入用戶。該工具類的偽代碼如下:
@Component
public class UserUtil{
@Value("${susan.userId}")
private String userId;
@Value("${susan.userName}")
private String userName;
public void injectUser() {
CurrentUser user = new CurrentUser();
user.setUserId(userId);
user.setUserName(userName);
CurrentUser.set(user);
}
}
好吧,不得不承認(rèn),這樣做確實(shí)可以解決header傳入用戶信息的問題,比之前需要手動(dòng)判斷用戶信息是否為空要優(yōu)雅得多,因?yàn)樽⑷胫蟮挠脩粜畔⒖隙ㄊ遣粸榭盏摹?/p>
折騰了半天,NPE問題還是沒有著落。
我回頭再仔細(xì)看了那個(gè)自定義的RocketMQTemplate類,發(fā)現(xiàn)里面重寫的方法:asyncSend,它包含了5個(gè)參數(shù)。而他們?cè)诮o我們推消息時(shí),調(diào)用的asyncSend卻只傳了3個(gè)參數(shù)。
一下子,問題又有了新的進(jìn)展,有沒有可能是他們調(diào)錯(cuò)接口了?
原本應(yīng)該調(diào)用5個(gè)參數(shù)的方法,但實(shí)際上他們調(diào)用了3個(gè)參數(shù)的方法。
這樣就能解釋通了。
4.第三次反轉(zhuǎn)
終于有點(diǎn)思路,我?guī)е环菹矏偅瑴?zhǔn)備開始證明剛剛的猜測(cè)。
但事實(shí)證明,我真的高興的太早了,馬上被啪啪打臉。
這次是反轉(zhuǎn)最快的一次。
怎么回事呢?
原本我以為是另外一個(gè)團(tuán)隊(duì)的人,在發(fā)mq消息時(shí)調(diào)錯(cuò)方法了,應(yīng)該調(diào)用5個(gè)參數(shù)的asyncSend方法,但他們的代碼中實(shí)際上調(diào)用的是3個(gè)參數(shù)的同名方法。
為了防止出現(xiàn)冤枉同事的事情發(fā)生。我本著盡職盡責(zé)的態(tài)度,仔細(xì)看了看RocketMQTemplate類的所有方法,這個(gè)類是rocketmq框架提供的。
意外發(fā)現(xiàn)了一些藕斷絲連的關(guān)系,偽代碼如下:
public void asyncSend(String destination, Message<?> message, SendCallback sendCallback, long timeout, int delayLevel) {
if (Objects.isNull(message) || Objects.isNull(message.getPayload())) {
log.error("asyncSend failed. destination:{}, message is null ", destination);
throw new IllegalArgumentException("`message` and `message.payload` cannot be null");
}
try {
org.apache.rocketmq.common.message.Message rocketMsg = RocketMQUtil.convertToRocketMessage(objectMapper,
charset, destination, message);
if (delayLevel > 0) {
rocketMsg.setDelayTimeLevel(delayLevel);
}
producer.send(rocketMsg, sendCallback, timeout);
} catch (Exception e) {
log.info("asyncSend failed. destination:{}, message:{} ", destination, message);
throw new MessagingException(e.getMessage(), e);
}
}
public void asyncSend(String destination, Message<?> message, SendCallback sendCallback, long timeout) {
asyncSend(destination,message,sendCallback,timeout,0);
}
public void asyncSend(String destination, Message<?> message, SendCallback sendCallback) {
asyncSend(destination, message, sendCallback, producer.getSendMsgTimeout());
}
public void asyncSend(String destination, Object payload, SendCallback sendCallback, long timeout) {
Message<?> message = this.doConvert(payload, null, null);
asyncSend(destination, message, sendCallback, timeout);
}
public void asyncSend(String destination, Object payload, SendCallback sendCallback) {
asyncSend(destination, payload, sendCallback, producer.getSendMsgTimeout());
}
這個(gè)背后隱藏著一個(gè)天大的秘密,這些同名的方法殊途同歸,竟然最終都會(huì)調(diào)用5個(gè)參數(shù)的asyncSend方法。
這樣看來,如果在子類中重寫了5個(gè)的asyncSend方法,相當(dāng)于重寫了所有的asyncSend方法。
再次證明他們沒錯(cuò)。
溫馨提醒一下,有些類的重載方法會(huì)相互調(diào)用,如果在子類中重新了最底層的那個(gè)重載方法,等于把所有的重載方法都重寫了。
頭疼,又要回到原點(diǎn)了。
5.第四次反轉(zhuǎn)
此時(shí),我有點(diǎn)迷茫了。
不過,有個(gè)好習(xí)慣是:遇到線上問題不知道怎辦時(shí),會(huì)多查一下日志。
本來不報(bào)啥希望的,但是沒想到通過再查日志。
出現(xiàn)了第四次反轉(zhuǎn)。
這次抱著試一下的心態(tài),根據(jù)messageID去查了mq生產(chǎn)者的日志,查到了一條消息的發(fā)送日志。
這次眼睛擦得雪亮,發(fā)現(xiàn)了一個(gè)小細(xì)節(jié):時(shí)間不對(duì)。
這條日志顯示的消息發(fā)送日期是2021-05-21,而實(shí)際上mq消費(fèi)者處理的日期是2021-05-28。
這條消息一個(gè)星期才消費(fèi)完?
顯然不是。
我有點(diǎn)肅然起敬了。再回去用那個(gè)messageID查了mq消費(fèi)者的日志,發(fā)現(xiàn)里面其實(shí)消費(fèi)了6次消息。前5次竟然是同一天,都在2021-05-21,而且都處理失敗了。另一次是2021-05-28,處理成功了。
為什么同一條消息,會(huì)在同一天消費(fèi)5次?
如果你對(duì)rocketmq比較熟悉的話,肯定知道它支持重試機(jī)制。
如果mq消費(fèi)者消息處理失敗了,可以在業(yè)務(wù)代碼中拋一個(gè)異常。然后框架層面捕獲該異常返回ConsumeConcurrentlyStatus.RECONSUME_LATER,rocketmq會(huì)自動(dòng)將該消息放到重試隊(duì)列。
流程圖如下:
這樣mq消費(fèi)者下次可以重新消費(fèi)那條消息,直到達(dá)到一定次數(shù)(這里我們配置的5次),rocketmq會(huì)將那條消息發(fā)送到死信隊(duì)列。
流程圖如下:
后面就不再消費(fèi)了。
最后為什么會(huì)多消費(fèi)一次?
最后的那條消息不可能是其他的mq生產(chǎn)者發(fā)出的,因?yàn)閙essageID是唯一的,其他的生產(chǎn)者不可能產(chǎn)生一樣的messageID。
那么接下來,只有一種可能,那就是人為發(fā)了條消息。
查線上日志時(shí),時(shí)間、messageID、traceID、記錄條數(shù) 這幾個(gè)維度至關(guān)重要。
6.真相
后來發(fā)現(xiàn)還真的是人為發(fā)的消息。
一周前,線上有個(gè)用戶,由于EOA頁面回調(diào)接口失敗(重試也失敗),導(dǎo)致審核狀態(tài)變更失敗。該審核單在EOA系統(tǒng)中審批通過了,但mq消費(fèi)者去處理該審核單的時(shí)候,發(fā)現(xiàn)狀態(tài)還是待審核,就直接返回了,沒有走完后續(xù)的流程,從而導(dǎo)致該審核單數(shù)據(jù)數(shù)據(jù)異常。
為了修復(fù)這個(gè)問題,我們當(dāng)時(shí)先修改了線上該審核單的狀態(tài)。接下來,手動(dòng)的在rocketmq后臺(tái)發(fā)了條消息。由于當(dāng)時(shí)在rocketmq后臺(tái)看不到header信息,所以發(fā)消息時(shí)沒有管header,直接往指定的topic中發(fā)消息了。
千萬注意,大家在手動(dòng)發(fā)mq消息時(shí),一定要注意header中是否也需要設(shè)置相關(guān)參數(shù),尤其是rocketmq,不然就可能會(huì)出問題。
mq消費(fèi)者消費(fèi)完那條消息之后,該審核單正常走完了流程,當(dāng)時(shí)找測(cè)試一起測(cè)試過,數(shù)據(jù)庫的狀態(tài)都是正常的。
大家都以為沒有問題了,但是所有人都忽略了一個(gè)小細(xì)節(jié):就是在正常業(yè)務(wù)邏輯處理完之后,會(huì)發(fā)websocket通知給指定用戶。但這個(gè)功能是已經(jīng)離職的那個(gè)同事加的新邏輯,其他人都不知道。站在手動(dòng)發(fā)消息的那個(gè)人的角度來說,他沒錯(cuò),因?yàn)樗静恢佬鹿δ艿拇嬖凇?/p>
由于這行代碼是最后一行代碼,并且跟之前的代碼不在同一個(gè)事物當(dāng)中,即使出了問題也不會(huì)影響正常的業(yè)務(wù)邏輯。
所以這個(gè)NPE問題影響范圍很小,只是那個(gè)商戶沒有收到某個(gè)通知而已。
有個(gè)好習(xí)慣,就是把跟核心業(yè)務(wù)邏輯無關(guān)的代碼,放在事務(wù)之外,防止出現(xiàn)問題時(shí),影響主流程。
說實(shí)話,有時(shí)候遇到線上問題,對(duì)于我們來說未必是一件壞事。通過這次線上問題定位,讓我熟悉了公司更多新功能,學(xué)習(xí)了其他同事的一些好的思想,總結(jié)了一些經(jīng)驗(yàn)和教訓(xùn),是一次難得的提升自己的好機(jī)會(huì)。
最后說一句(求關(guān)注,別白嫖我)
碼字不易,且行且珍惜。你的認(rèn)同是我堅(jiān)持的最大動(dòng)力,求一鍵三連:點(diǎn)贊、轉(zhuǎn)發(fā)、在看。
關(guān)注公眾號(hào):【蘇三說技術(shù)】,在公眾號(hào)中回復(fù):面試、代碼神器、開發(fā)手冊(cè)、時(shí)間管理有超贊的粉絲福利,另外回復(fù):加群,可以跟很多BAT大廠的前輩交流和學(xué)習(xí)。
