記一次慘痛的線上ThreadLocal引發的故障,3個月的年終獎沒了,可能還會面臨辭退。
事情起因
耗子逗貓 —— 沒事找事
前幾天,在工作不太忙的時候,為了展示我在工作中積極主動,技術能力較強,並給領導留個好印象,我就去翻翻專案程式碼有沒有可優化的空間。
沒想到,我真讓我找著啦。
禍端就此埋下了!
有使用者反饋查詢訂單列表介面有點慢,我就去列印每一步的耗時資訊。發現查詢訂單之前,需要先根據使用者ID查詢使用者資訊,而查詢使用者資訊介面需要呼叫使用者團隊提供的服務,有時候網路較慢的時候,耗時達到200毫秒。
而查詢訂單介面層層呼叫的時候,呼叫了好幾次查詢使用者資訊介面。當然可以改成再最上層查詢一次,然後層層往下傳遞,這樣一來改的地方比較多,也很麻煩。
我琢磨著能不能加個本地快取,把使用者資訊快取起來,這樣就不用每次去呼叫使用者服務查詢了。剛好就想到了使用ThreadLocal,聽說高階程式設計師都用ThreadLocal,我也想用一下試試。
ThreadLocal是執行緒私有的,呼叫結束後,執行緒銷燬了,ThreadLocal裡面資料也跟著沒了。
聽著ThreadLocal是執行緒安全的,應該沒什麼問題。
動手實踐
我先寫一個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);
}
自測、提測、驗收、上線,介面訪問速度“嗖”一下就上去了,一切看上去都是那麼完美。
我已經開始幻想,升職加薪,迎娶白富美,走上人生巔峰了。
事與願違
上線一個小時後,值班群炸了。
陸續開始有使用者反饋自己剛下的訂單不見了,其他使用者也有反饋自己的訂單列表莫名其妙多了一些訂單。
我一臉懵逼,沒碰到過這種情況,逐漸反饋的使用者越來越多,我已經不知所措了。
領導當機立斷,小燈,你小子搞什麼飛機,趕緊回滾服務。
半個小時後,回滾完畢,使用者的情緒逐漸平復下來。
故障覆盤
線上故障解決後,緊接著就開始排查問題產生的原因。
經過無數次打日誌、debug,終於定位到問題了。
ThreadLocal確實是執行緒私有的,並且會線上程銷燬後,ThreadLocal裡面的資料也會被清理掉。
但是問題就出在,無論我們服務端用的是Tomcat、Jetty、SpringBoot、Dubbo等,都不會來一個請求就建立一個執行緒,而是建立一個執行緒池,所有請求共享這這個執行緒池裡的執行緒。
一個執行緒處理完一個請求,並不會被銷燬。可能導致多個使用者請求共用一個執行緒,最後出現資料越權,看到了別的使用者的訂單。
解決方案
解決辦法就是,在使用完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。
本來想優化程式效能,提高訪問速度,給領導一個好印象,好顯得自己技術能力強,工作積極主動。
這下好了,不但年終獎沒了,工作還可能保不住了。
睡覺沒蓋屁股——我是露大臉了!
事故總結
經過這次事故,我總結了以下幾點教訓:
- 沒事兒別瞎逞能。
- 沒有金剛鑽,別攬瓷器活。
- 不求有功,但求無過。
- 燈子,重構優化的水太深,你把握不住。
文章持續更新,可以微信搜一搜「 一燈架構 」第一時間閱讀更多技術乾貨。