1. 程式人生 > >記一次愚蠢的操作--執行緒安全問題

記一次愚蠢的操作--執行緒安全問題

前言

只有光頭才能變強。

文字已收錄至我的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多篇原創技術文章、海量視訊資源、精美腦圖,關注即可獲取!

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