前言
只有光頭才能變強。
文本已收錄至我的GitHub倉庫,歡迎Star:https://github.com/ZhongFuCheng3y/3y
記一次在工作中愚蠢的操作,本文關鍵字:線程安全
(我怎么天天在寫Bug啊)
一、交代背景
我這邊有一個系統,提供一個RPC接口去發送各種信息(比如短信、郵件、微信)等等渠道。我這邊的系統架構是這樣的:
概括:service系統提供一個RPC接口,別人調用我提供的接口,我在service系統中對這個消息進行判斷、拼接等等業務邏輯,最后會將這個消息放到消息隊列里邊。sender系統會消費消息隊列里邊的數據,然后發送消息
例子:小王調用我們的RPC接口,想要發送郵件。我對郵件的參數進行判斷和拼裝成一個我這邊定義好的Task,將這個Task丟到消息隊列里邊。sender系統消費這個Task,調用java.mail
的API完成發送郵件的功能。
小王調用我們這個RPC接口,只要service系統把這個task丟到消息隊列里邊去,我們就返回response給小王。
- 只要這個task放到了消息隊列里邊,我們就返回success。所以有的時候,小王會問:“我這明明返回是success啊,怎么我的郵件沒發出去呢” ------(異步)
每發送一封郵件,我們都會將這封郵件的信息入庫(保存在MySQL中),在MySQL中我們可以得知這封郵件的發送時間,發送狀態等等。
而小王的這些郵件又十分在意是否成功發送出去了,如果發送失敗了他那邊需要重發。於是,他監聽我們DB的binlog,根據binlog的信息來判斷是否需要重發。
由於種種的原因,小王希望調用我們RPC接口的時候就能拿到一個唯一的標識好讓他去判斷這封郵件是成功還是失敗
- 顯然,入庫的Email ID是不可能的(因為他調我們RPC接口,我們將Task放到消息隊列就返回了。此時sender系統還沒消費呢)
於是,我們這邊打算在service系統生成一個messageId,然后返回給他,將這個messageId綁定到Task里邊,一直到入庫。
二、上鈎
上面確定好需求和思路之后,我這邊就去看返回給小王的response對象,一看,發現已經有msgId字段了
public class SendResponse {
// 錯誤碼
private int errCode;
// 錯誤信息
private String errInfo;
// messageId
private long msgId;
}
我搜了一下這個字段的信息ctrl + shift + f
,發現這msgId沒有被用到啊。一想,這剛好,我來用了。我看了一下用法,發現這邊不是直接使用SendResponse的,而是在外面包了一個枚舉類,代碼大概如下:
public enum Response {
SUCCESS(1, "success"),
PARAM_MISSING(2, "param is missing"),
INVALID_xxxx(3, "xxxx is invalid"),
INVALID_xxxx(4, "xxxx is invalid"),
private SendResponse sendResponse;
private Response(int errCode, String errInfo) {
sendResponse = new SendResponse();
sendResponse.setMsgId(0);
sendResponse.setErrCode(errCode);
sendResponse.setErrInfo(errInfo);
}
public SendResponse getSendResponse() {
return sendResponse;
}
}
有了枚舉使用起來就很簡單了,比如我發現小王某個參數傳進來有問題,我反手就是:
Response.PARAM_ERROR
service系統主要做了兩件事
- 判斷參數/類型,各種業務邏輯有沒有問題,將小王帶過來的參數封裝成Task對象
- 將Task對象放到消息隊列里邊
要明確的是:等到整一個調用鏈結束(將Task對象放到消息隊列中),才會將sendResponse對象返回出去。而又因為可能要判斷的地方有點多,所以我們這邊是這樣設計了一個Map來存儲數據,這個Map貫穿整條鏈路:
// 首先將sendResponse默認設置為success,也就是代碼如下:
map.put("sendResponse",Response.SUCCESS);
// 如果中途某個地方可能有問題了,那我們將Map中sendResponse進行修改
map.put("sendResponse",Response.ERROR);
// 等整條鏈路完成,從Map拿出sendResponse返回
return map.get("sendResponse");
於是我要做的就是:在將SendResponse返回之前,我生成一個唯一的msgId,並插入到SendResponse對象里邊就好了。
Response.getSendResponse().setMsgid(uuid);
這個需求完成得非常快,簡單測試了一下也沒毛病,就果斷上線了。小王用了一陣子也沒說有什么問題,於是這個需求就交付了。
三、出現問題
昨天,小王告訴我:“我這邊郵件發送失敗啦,有msgId,看下是什么原因造成的“
於是我就去撈線上的日志,發現根據他給出的msgId,我這邊打出的日志都不是發送郵件的(而是其他Task的日志)。我這就慌了,難道我們這個系統出問題了?
- 心理活動:msgId能夠唯一標識這條Task,而小王發給我的msgId,卻是別的Task的內容。是不是出大問題啦(錯亂消費?數據全亂了?),驚慌失措
然后,他那邊繼續補充:
之后發現郵件是發送成功的,但是他拿到部分的msgId是別的Task的,不是郵件的。於是只能先比對剩下的郵件是否有問題,再看看MsgId是什么原因。
四、尋找問題
現有的條件是:
- 那批郵箱發送是成功的
- 小王拿到了別的Task的msgId
所以,判斷系統是沒問題的,只是msgId在並發的過程中出了問題(拿到其他Task的msgId了)
於是我就去找原因啦,在查代碼的時候發現前同事還在Service系統中的某個類留了一個注解@NotThreadSafe
。我就覺得肯定是中途哪個地方我沒注意到,導致小王拿到了其他Task的msgId。
人肉Debug了一個午休的時間還是沒找出來:每個線程都獨有一份的操作對象,對象的屬性都沒有逸出(都在方法內部操作),跟着整塊鏈路一直傳遞,直至鏈路結束。
后來,一想,我應該只看msgId生成的地方就好了呀。才發現,項目里邊用的是枚舉啊!
// 首先將sendResponse默認設置為success,也就是代碼如下:
map.put("sendResponse",Response.SUCCESS);
// 如果中途某個地方可能有問題了,那我們將Map中sendResponse進行修改
map.put("sendResponse",Response.ERROR);
// 把response的msgId的值設置為當前Task綁定的值
map.get("sendResponse").setMsgid(uuid);
// 等整條鏈路完成,從Map拿出sendResponse返回
return map.get("sendResponse");
醒悟:
- 現在我有50個線程,每個線程在處理數據的時候都會有一個默認的sendResponse對象,這個對象是用枚舉來標識
Response.SUCCESS
。所以,這50個線程都共享着這個sendResponse對象 - 50個線程共享着這個sendResponse對象,每個線程都可以修改sendResponse里邊的msgId屬性,這就自然是線程不安全的。
- 所以小王能拿到其他Task的msgId(小王的線程設置完msgId之后,還沒返回,三歪的線程又更改了一次msgId,導致小王拿到三歪的msgId了)
總結:
- 終於知道為啥當初前同事在代碼上保留了msgId屬性,但是沒有使用這個屬性。
- 使用枚舉就不應該帶 有狀態的屬性(能修改、可變的屬性)
最后
樂於輸出干貨的Java技術公眾號:Java3y。公眾號內有200多篇原創技術文章、海量視頻資源、精美腦圖,關注即可獲取!
覺得我的文章寫得不錯,點贊!