記一次愚蠢的操作--線程安全問題

前言

只有光頭才能變強。

文本已收錄至我的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);

在返回sendResponse之前插入msgId就好了

這個需求完成得非常快,簡單測試了一下也沒毛病,就果斷上線了。小王用了一陣子也沒說有什麼問題,於是這個需求就交付了。

三、出現問題

昨天,小王告訴我:“我這邊郵件發送失敗啦,有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多篇原創技術文章、海量視頻資源、精美腦圖,關注即可獲取!

轉發到朋友圈是對我最大的支持!

覺得我的文章寫得不錯,點

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