作者 | 因為熱愛所以堅持ing 責編 | 歐陽姝黎前言 公司為了保證系統的穩定性,加了很多監控,比如:介面響應時間、cpu使用率、記憶體使用率、錯誤日誌等等。如果系統出現異
2021-06-29 06:42:48
公司為了保證系統的穩定性,加了很多監控,比如:介面響應時間、cpu使用率、記憶體使用率、錯誤日誌等等。如果系統出現異常情況,會郵件通知相關人員,以便於大家能在第一時間解決隱藏的系統問題。此外,我們這邊有個不成文的規定,就是線上問題最好能夠當日解決,除非遇到那種非常棘手的問題。 有個週一的早上,我去公司上班,檢視郵件,收到我們老大轉發的一封郵件,讓我追查線上的一個NPE(NullPointException)問題。 郵件是通過sentry發出來的,我們通過點選郵件中的相關連結,可以直接跳轉到sentry的詳情頁面。在這個頁面中,展示了很多關鍵資訊,比如:操作時間、請求的介面、出錯的程式碼位置、報錯資訊、請求經過了哪些鏈路等等。真是居家旅行,查bug的良藥,有了這些,小case一眼就能查到原因。 我當時沒費吹灰之力,就訪問到了NPE的sentry報錯頁面(其實只用滑鼠雙擊一下就搞定)。果然上面有很多關鍵資訊,我一眼就看到了NPE的具體程式碼位置: 劇情發展得如此順利,我都有點不好意思了。 根據類名和程式碼行號,我在idea中很快找到那行程式碼,不像是我寫的,這下可以放心不用背鍋了。於是接下來看了看那行的程式碼修改記錄,最後修改人是XXX。 什麼?是他? 他在一個月前已經離職了,看來這個無頭公案已經無從問起,只能自己查原因。 我當時內心的OS是:程式碼沒做相容處理。 為什麼這麼說呢? 這行程式碼其實很簡單,就是從當前使用者上下文中獲取使用者名稱,然後設定到notify實體的inUserName欄位上,最終notify的資料會儲存到資料庫。 該欄位表示那條推送通知的新增人,正常情況下沒啥卵用,主要是為了出現線上問題扯皮時,有個地方可以溯源。如果出現冤案,可以還你清白。 順便提一嘴,這裡說的推送通知跟mq中的訊息是兩回事,前者指的是websocket長連線推送的實時通知,我們這邊很多業務場景,在頁面功能操作完之後,會實時推送通知給指定使用者,以便使用者能夠及時處理相關單據,比如:您有一個審批單需要審批,請及時處理等。 CurrentUser內部包含了一個ThreadLocal物件,它負責儲存當前執行緒的使用者上下文資訊。當然為了保證線上程池中,也能從使用者上下文中獲取到正確的使用者資訊,這裡用了阿里的TransmittableThreadLocal。虛擬碼如下: 這裡為什麼用了阿里的TransmittableThreadLocal,而不是普通的ThreadLocal呢?線上程池中,由於執行緒會被多次複用,導致從普通的ThreadLocal中無法獲取正確的使用者資訊。父執行緒中的參數,沒法傳遞給子執行緒,而TransmittableThreadLocal很好解決了這個問題。 然後在項目中定義一個全局的spring mvc攔截器,專門設定使用者上下文到ThreadLocal中。虛擬碼如下: 使用者在請求我們介面時,會先觸發該攔截器,它會根據使用者cookie中的token,呼叫呼叫介面獲取redis中的使用者資訊。如果能獲取到,說明使用者已經登入,則把使用者資訊設定到CurrentUser類的ThreadLocal中。 接下來,在api服務的下層,即business層的方法中,就能輕鬆通過CurrentUser.getCurrent();方法獲取到想要的使用者上下文資訊了。 這套使用者體系的想法是很good的,但深入使用後,發現了一個小插曲: api服務和mq消費者服務都引用了business層,business層中的方法兩個服務都能直接呼叫。 我們都知道在api服務中使用者是需要登入的,而mq消費者服務則不需要登入。 如果business中的某個方法剛開始是給api開發的,在方法深處使用了CurrentUser.getCurrent();獲取使用者上下文。但後來,某位新來的帥哥在mq消費者中也呼叫了那個方法,並未發覺這個小機關,就會中招,出現找不到使用者上下文的問題。 所以我當時的第一個想法是:程式碼沒做相容處理,因為之前這類問題偶爾會發生一次。 想要解決這個問題,其實也很簡單。只需先判斷一下能否從CurrentUser中獲取使用者資訊,如果不能,則取配置的系統使用者資訊。虛擬碼如下: 這種簡單無公害的程式碼,如果只是在一兩個地方加還OK。 但如果有多個地方都在獲取使用者資訊,難道在每個地方都需要把相同的判斷邏輯寫一遍?對於有追求的程式設計師來說,這種簡單的重複是寫程式碼的大忌,如何更優雅的解決問題呢? 答案將會在文章後面揭曉。 這個NPE問題表面上,已經有答案了。根據以往的經驗,由於在程式碼中沒有做相容處理,在mq消費者服務中獲取到的使用者資訊為空,對一個空物件,呼叫它的方法,就會出現NPE。 但這個答案顯得有點草率,會不會還有什麼機關? 於是我在項目工程中全局搜尋CurrentUser.set關鍵字,竟然真的找到了一個機關。 劇情出現第一次反轉。 有個地方寫了一個rocketmq的AOP攔截器,虛擬碼如下: 它會攔截所有mq消費者中的onMessage方法,在該方法執行之前,從userProperty中獲取使用者資訊,並且創建使用者物件,設定到使用者上下文中。 溫馨提醒一下,免得有些朋友依葫蘆畫瓢踩坑。上面的虛擬碼只給出了設定使用者上下文的關鍵程式碼,用完後,刪除使用者上下文的程式碼沒有給出,感興趣的朋友可以找我私聊。 既然有獲取使用者資訊的地方,我猜測必定有設定的地方。這時候突然發現自己有點當偵探的潛力,因為後面還真找到了。 意不意外,驚不驚喜? 另外一個同事自己自定義了一個RocketMQTemplate。虛擬碼如下: 這段程式碼的主要作用是在mq生產者在傳送非同步訊息之前,先將當前使用者上下文資訊設定到mq訊息的header中,這樣在mq消費者中就能通過userProperty獲取到,它的本質也是從header中獲取到的。 此時線索一下子斷了,沒有任何進展。 我再去查了一下伺服器的日誌。確認了那條有問題的mq訊息,它的header資訊中確實沒有userId和userName欄位。 莫非是mq生產者沒有往header中塞使用者資訊?這是需要重點懷疑的地方。 因為mq生產者是另外一個團隊寫的程式碼,在EOA(簽報系統)回撥他們系統時,會給我們發mq訊息,通知我們簽報狀態。 而EOA是第三方的系統,使用者體系沒有跟我們打通。所以在另外一個團隊的回撥介面中,沒法獲取當前登入的使用者資訊,AOP的攔截器就沒法自動往header中塞使用者資訊,這樣在mq的消費者中自然就獲取不到了。 這樣想來還真的是順理成章。 但真的是這樣的嗎? 我們抱著很大的希望,給他們發了一封郵件,讓他們幫忙查一下問題。 很快,他們回郵件了。 但他們說:已經本地測試過,功能正常。 就這樣劇情第二次反轉了。 我此時有點好奇,他們是怎麼往header中塞使用者資訊的。帶著「學習的心態」,於是找他們一起查看了相關程式碼。 他們在傳送mq訊息之前,會呼叫一個UserUtil工具注入使用者。該工具類的虛擬碼如下: 好吧,不得不承認,這樣做確實可以解決header傳入使用者資訊的問題,比之前需要手動判斷使用者資訊是否為空要優雅得多,因為注入之後的使用者資訊肯定是不為空的。 折騰了半天,NPE問題還是沒有著落。 我回頭再仔細看了那個自定義的RocketMQTemplate類,發現裡面重寫的方法:asyncSend,它包含了5個參數。而他們在給我們推訊息時,呼叫的asyncSend卻只傳了3個參數。 一下子,問題又有了新的進展,有沒有可能是他們調錯介面了? 原本應該呼叫5個參數的方法,但實際上他們呼叫了3個參數的方法。 這樣就能解釋通了。 終於有點思路,我帶著一份喜悅,準備開始證明剛剛的猜測。 但事實證明,我真的高興的太早了,馬上被啪啪打臉。 這次是反轉最快的一次。 怎麼回事呢? 原本我以為是另外一個團隊的人,在發mq訊息時調錯方法了,應該呼叫5個參數的asyncSend方法,但他們的程式碼中實際上呼叫的是3個參數的同名方法。 為了防止出現冤枉同事的事情發生。我本著盡職盡責的態度,仔細看了看RocketMQTemplate類的所有方法,這個類是rocketmq框架提供的。 意外發現了一些藕斷絲連的關係,虛擬碼如下: 這個背後隱藏著一個天大的祕密,這些同名的方法殊途同歸,竟然最終都會呼叫5個參數的asyncSend方法。 這樣看來,如果在子類中重寫了5個的asyncSend方法,相當於重寫了所有的asyncSend方法。再次證明他們沒錯。 溫馨提醒一下,有些類的過載方法會相互呼叫,如果在子類中重新了最底層的那個過載方法,等於把所有的過載方法都重寫了。 頭疼,又要回到原點了。 此時,我有點迷茫了。 不過,有個好習慣是:遇到線上問題不知道怎辦時,會多查一下日誌。 本來不報啥希望的,但是沒想到通過再查日誌。 出現了第四次反轉。 這次抱著試一下的心態,根據messageID去查了mq生產者的日誌,查到了一條訊息的傳送日誌。 這次眼睛擦得雪亮,發現了一個小細節:時間不對。 這條日誌顯示的訊息傳送日期是2021-05-21,而實際上mq消費者處理的日期是2021-05-28。 這條訊息一個星期才消費完? 顯然不是。 我有點肅然起敬了。再回去用那個messageID查了mq消費者的日誌,發現裡面其實消費了6次訊息。前5次竟然是同一天,都在2021-05-21,而且都處理失敗了。另一次是2021-05-28,處理成功了。 為什麼同一條訊息,會在同一天消費5次? 如果你對rocketmq比較熟悉的話,肯定知道它支援重試機制。 如果mq消費者訊息處理失敗了,可以在業務程式碼中拋一個異常。然後框架層面捕獲該異常返回ConsumeConcurrentlyStatus.RECONSUME_LATER,rocketmq會自動將該訊息放到重試佇列。流程圖如下:這樣mq消費者下次可以重新消費那條訊息,直到達到一定次數(這裡我們配置的5次),rocketmq會將那條訊息傳送到死信佇列。流程圖如下:後面就不再消費了。 最後為什麼會多消費一次? 最後的那條訊息不可能是其他的mq生產者發出的,因為messageID是唯一的,其他的生產者不可能產生一樣的messageID。 那麼接下來,只有一種可能,那就是人為發了條訊息。 查線上日誌時,時間、messageID、traceID、記錄條數 這幾個維度至關重要。 後來發現還真的是人為發的訊息。 一週前,線上有個使用者,由於EOA頁面回撥介面失敗(重試也失敗),導致稽核狀態變更失敗。該稽核單在EOA系統中審批通過了,但mq消費者去處理該稽核單的時候,發現狀態還是待稽核,就直接返回了,沒有走完後續的流程,從而導致該稽核單資料資料異常。 為了修復這個問題,我們當時先修改了線上該稽核單的狀態。接下來,手動的在rocketmq後臺發了條訊息。由於當時在rocketmq後臺看不到header資訊,所以發訊息時沒有管header,直接往指定的topic中發訊息了。 千萬注意,大家在手動發mq訊息時,一定要注意header中是否也需要設定相關參數,尤其是rocketmq,不然就可能會出問題。 mq消費者消費完那條訊息之後,該稽核單正常走完了流程,當時找測試一起測試過,資料庫的狀態都是正常的。 大家都以為沒有問題了,但是所有人都忽略了一個小細節:就是在正常業務邏輯處理完之後,會發websocket通知給指定使用者。但這個功能是已經離職的那個同事加的新邏輯,其他人都不知道。站在手動發訊息的那個人的角度來說,他沒錯,因為他根本不知道新功能的存在。 由於這行程式碼是最後一行程式碼,並且跟之前的程式碼不在同一個事物當中,即使出了問題也不會影響正常的業務邏輯。 所以這個NPE問題影響範圍很小,只是那個商戶沒有收到某個通知而已。 有個好習慣,就是把跟核心業務邏輯無關的程式碼,放在事務之外,防止出現問題時,影響主流程。 說實話,有時候遇到線上問題,對於我們來說未必是一件壞事。通過這次線上問題定位,讓我熟悉了公司更多新功能,學習了其他同事的一些好的思想,總結了一些經驗和教訓,是一次難得的提升自己的好機會。前言
起因
notify.setName(CurrentUser.getCurrent().getUserName());
@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();
}
}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);
}
}
}@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());
}第一次反轉
@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);
}
}
...
}
}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);
}
}第二次反轉
@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);
}
}第三次反轉
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());
}第四次反轉
真相
相關文章
作者 | 因為熱愛所以堅持ing 責編 | 歐陽姝黎前言 公司為了保證系統的穩定性,加了很多監控,比如:介面響應時間、cpu使用率、記憶體使用率、錯誤日誌等等。如果系統出現異
2021-06-29 06:42:48
如果說目前市面上體驗最好的降噪真無線耳機是什麼,可能不少小夥伴的回答可能是 AirPods Pro 。誠然,AirPods Pro 在蘋果的生態下,絕對是體驗極佳的智慧穿戴產品,包括我本人也是
2021-06-29 06:20:52
終於,索尼這一次憋的大招,幾乎讓它的所有競品都感到汗顏。相較上一代產品,WF-1000XM4從頭到腳來了個脫胎換骨:外觀設計、降噪晶片、音質、續航、連線穩定性全面升級,甚至連包裝盒
2021-06-29 06:18:03
榮耀50Pro搭載高通驍龍778G處理器,8GB/256GB版售價為3699元,一加9R搭載高通驍龍870處理器,8GB/256GB版售價為3299元,驍龍870處理器的效能要優於驍龍778G處理器,但榮耀50Pro的價格
2021-06-29 04:52:19
RedmiK40遊戲增強版開啟了降價活動,最高閃降200元,其中,RedmiK40遊戲增強版12GB+128GB版閃降100元,到手價為2299元,12GB+256GB版閃降200元,到手價為2499元。已是非常值得入手了,因R
2021-06-29 04:30:22
整理 | 鄭麗媛上週,作為微軟時隔 6 年推出、號稱是「最重要更新之一」的 Windows 11,一經官宣就吸引了諸多人的眼球。 有人興奮道「能運行 apk 確實是一大殺招」;有人感慨「遊
2021-06-29 03:04:09