記一次ThreadLocal引發的線上故障,年終獎沒了,可能還面臨辭退

一燈架構 發表於 2022-07-01

記一次慘痛的線上ThreadLocal引發的故障,3個月的年終獎沒了,可能還會面臨辭退。

事情起因

耗子逗貓 —— 沒事找事

前幾天,在工作不太忙的時候,為了展示我在工作中積極主動,技術能力較強,並給領導留個好印象,我就去翻翻專案程式碼有沒有可優化的空間。

裝比.jpg

沒想到,我真讓我找著啦。

禍端就此埋下了!

有使用者反饋查詢訂單列表介面有點慢,我就去列印每一步的耗時資訊。發現查詢訂單之前,需要先根據使用者ID查詢使用者資訊,而查詢使用者資訊介面需要呼叫使用者團隊提供的服務,有時候網路較慢的時候,耗時達到200毫秒。

而查詢訂單介面層層呼叫的時候,呼叫了好幾次查詢使用者資訊介面。當然可以改成再最上層查詢一次,然後層層往下傳遞,這樣一來改的地方比較多,也很麻煩。

我琢磨著能不能加個本地快取,把使用者資訊快取起來,這樣就不用每次去呼叫使用者服務查詢了。剛好就想到了使用ThreadLocal,聽說高階程式設計師都用ThreadLocal,我也想用一下試試。

ThreadLocal是執行緒私有的,呼叫結束後,執行緒銷燬了,ThreadLocal裡面資料也跟著沒了。

聽著ThreadLocal是執行緒安全的,應該沒什麼問題。

沒問題.jpg

動手實踐

我先寫一個ThreadLocal的工具類,用來儲存和獲取使用者資訊:

/**
 * @author 一燈
 * @apiNote 本地快取使用者資訊
 **/
public class ThreadLocalUtil {

    // 使用ThreadLocal儲存使用者資訊
    private static final ThreadLocal<User> threadLocal = new ThreadLocal<>();

    /**
     * 獲取使用者資訊
     */
    public static User getUser() {
        // 如果ThreadLocal中沒有使用者資訊,就從request請求解析出來放進去
        if (threadLocal.get() == null) {
            threadLocal.set(UserUtil.parseUserFromRequest());
        }
        return threadLocal.get();
    }

}

然後在查詢訂單介面裡面,呼叫這個工具類的方法獲取使用者資訊,最後根據使用者資訊查詢訂單資訊,完美。

/**
 * 獲取訂單列表方法
 */
public List<Order> getOrderList() {
    // 1. 從ThreadLocal快取中獲取使用者資訊
    User user = ThreadLocalUtil.getUser();
    // 2. 根據使用者資訊,呼叫使用者服務獲取訂單列表
    return orderService.getOrderList(user);
}

自測、提測、驗收、上線,介面訪問速度“嗖”一下就上去了,一切看上去都是那麼完美。

我已經開始幻想,升職加薪,迎娶白富美,走上人生巔峰了。

走上巔峰.jpg

事與願違

上線一個小時後,值班群炸了。

陸續開始有使用者反饋自己剛下的訂單不見了,其他使用者也有反饋自己的訂單列表莫名其妙多了一些訂單。

我一臉懵逼,沒碰到過這種情況,逐漸反饋的使用者越來越多,我已經不知所措了。

領導當機立斷,小燈,你小子搞什麼飛機,趕緊回滾服務。

事與願違.jpg

半個小時後,回滾完畢,使用者的情緒逐漸平復下來。

故障覆盤

線上故障解決後,緊接著就開始排查問題產生的原因。

經過無數次打日誌、debug,終於定位到問題了。

ThreadLocal確實是執行緒私有的,並且會線上程銷燬後,ThreadLocal裡面的資料也會被清理掉。

但是問題就出在,無論我們服務端用的是Tomcat、Jetty、SpringBoot、Dubbo等,都不會來一個請求就建立一個執行緒,而是建立一個執行緒池,所有請求共享這這個執行緒池裡的執行緒。

一個執行緒處理完一個請求,並不會被銷燬。可能導致多個使用者請求共用一個執行緒,最後出現資料越權,看到了別的使用者的訂單。

image-20220508171253731.png

解決方案

解決辦法就是,在使用完ThreadLocal後,再呼叫remove方法清除ThreadLocal資料。

/**
 * @author 一燈
 * @apiNote 本地快取使用者資訊
 **/
public class ThreadLocalUtil {

    // 使用ThreadLocal儲存使用者資訊
    private static final ThreadLocal<User> threadLocal = new ThreadLocal<>();

    /**
     * 獲取使用者資訊
     */
    public static User getUser() {
        // 如果ThreadLocal中沒有使用者資訊,就從request請求解析出來放進去
        if (threadLocal.get() == null) {
            threadLocal.set(UserUtil.parseUserFromRequest());
        }
        return threadLocal.get();
    }

    /**
     * 刪除使用者資訊
     */
    public static void removeUser() {
        threadLocal.remove();
    }

}

使用try/catch包裹業務程式碼,然後在finally中清除ThreadLocal資料。

/**
 * 獲取訂單列表
 */
public List<Order> getOrderList() {
    // 1. 從ThreadLocal快取中獲取使用者資訊
    User user = ThreadLocalUtil.getUser();
    // 2. 根據使用者資訊,呼叫使用者服務獲取訂單列表
    try {
        return orderService.getOrderList(user);
    } catch (Exception e) {
        throw new RuntimeException(e.getMessage());
    } finally {
        // 3. 使用完ThreadLocal後,刪除使用者資訊
        ThreadLocalUtil.removeUser();
    }
    return null;
}

故障定級

影響使用者超過10w,或者錯誤資料超過10w,或者資損大於100w,故障定級為P1,全年績效C。

本來想優化程式效能,提高訪問速度,給領導一個好印象,好顯得自己技術能力強,工作積極主動。

這下好了,不但年終獎沒了,工作還可能保不住了。

睡覺沒蓋屁股——我是露大臉了!

哭一個月.jpg

事故總結

經過這次事故,我總結了以下幾點教訓:

  1. 沒事兒別瞎逞能。
  2. 沒有金剛鑽,別攬瓷器活。
  3. 不求有功,但求無過。
  4. 燈子,重構優化的水太深,你把握不住。
文章持續更新,可以微信搜一搜「 一燈架構 」第一時間閱讀更多技術乾貨。