ThreadLocal引起的一次線上事故

煙花散盡13141發表於2021-05-14

> 線上使用者儲存資料後檢視提示無許可權

前言

  • 不知道什麼時候年輕的我曾一度認為Java沒啥難度,沒有我實現不了的需求,沒有我解不了的bug

  • 直到我遇到至今難忘的一個bug 。 線上使用者儲存資料後檢視提示無許可權

初次定位

  • 明明自己新增的資料,為什麼提示自己沒有許可權呢?我一開始自信的認為是我們的客戶操作有問題、或者是我們許可權配置有問題
  • 但是帶我自己親自驗證了一下之後發現這個問題時現時不現,屬於一個偶發的問題。這個在開發階段還真的不容易發現。

問題升級

  • 經過自己的測試後讓我更加懷疑人生了,你要麼就有問題要麼就沒問題。一會有一會沒有到底又是幾個意思呢?偶先的問題真的很難解決啊。問題定位到這裡我已經精疲力竭了。然後就放棄了定位

  • 但是問題還是得解決,第二天我又硬著頭皮開始研究了。可能第二天頭腦比較清醒我發現我們系統中在插入資料的時候會自動獲取到當前登入使用者並在資料庫中記錄次資料的建立者及最新的修改者。這更應該說明我們的問題離譜 。但是問題在我們獲取當前登入使用者的時候出現了問題

  • 對,我將問題追蹤了一下,終於將問題本質找到了。我們獲取當前登入使用者是通過ThreadLocal 來實現的。那麼問題就是``ThreadLocal` 獲取使用者有問題

  • 我們分散式開發系統。我們會在每個模組裡新增一個aop攔截器,通過請求頭的token再去user模組查詢使用者基本資訊。然後放到``ThreadLocal中。這樣我們的系統中隨處都可以通過ThreadLocal` 這個物件獲取我們的登陸使用者。

  • 別問我為什麼要在每個模組都這樣做?別問我為什麼用ThreadLocal?別問我為什麼是分散式還要這樣做? 因為今天我們重點是解決bug

    image-20210508165554760

開門見山

  • 問題就出現在getUser那塊邏輯裡。因為我們的設計就是在系統中隨處都可以獲取到User物件。當然我們這裡指的是任何請求裡。對於MQ、定時器這些模組裡肯定是沒有User的。因為這些沒法走AOP攔截

ThreadLocal獲取使用者資訊亂串,導致使用者新增資料許可權異常

最終定位

  • 我們的ThreadLocal 是個物件,我們系統中是通過一個工具類獲取這個物件的屬性的。在這個物件我們提供set、get方法。

image-20210508171309957

  • 上面的流程展示了在獲取到User使用者之後就會加入到工廠。如果工廠已經存在了就不會加入。否則就會加入我們的使用者
  • 這樣也是避免我們不斷加入重複使用者資訊。因為同一個執行緒對應的只可能是一個使用者。

思考

public static UserInfo getUser() {
    return userThreadLocal.get();
}
  • 上面是我們工具類的get方法。這就是將ThreadLocal物件儲存的內容返回出去。這一步應該不會出現問題。
  • 在getUser中很明顯沒有問題,我們利用排除法只剩下了setUser了。雖然排除了別人的嫌疑但是setUser我還是看不出有什麼問題。經過一陣debug斷點跟蹤後我發現我們setUser邏輯的確有問題
  • setUser是將使用者資訊儲存到``Threadlocal 物件中,但是前提是ThreadLocal`中沒有使用者。對就是這個問題,如果已經有了使用者呢?那麼我們真正的使用者就會無法新增進去
  • 到了這裡問題逐漸的明朗起來。使我們ThreadLocal物件管理的有問題。導致儲存了上次的使用者資訊從而導致使用者資訊亂串的現象

解決問題

  • 既然我們已經定位到ThreadLocal的管理問題,那麼我們就好辦了。

ThreadLocal簡單梳理

image-20210509151703058

  • ThreadLocal 將物件儲存線上程中。換句話說就是每個執行緒的資料會相互隔離。基於這個特性我們可以將使用者資訊儲存在這裡,這樣我們能保證我們的當前執行緒下執行分各種方法都能通過他獲取到使用者資訊
  • ThreadLocal內部是將已自己為key, 儲存物件為value儲存到當前執行緒中的map中。這個map會隨著執行緒的銷燬而被JVM回收。
  • 但是在我們實際開發中經常會使用執行緒池來避免執行緒的重複建立及銷燬。那麼執行緒往往是不會被銷燬的
  • 在Spring中整合的類似Tomcat、JBoss等web容器中都是預設使用的一定數量的執行緒數的。而我們在spring中使用的執行緒複用功能就導致了我們在獲取當前執行緒的使用者時因為此執行緒被別人使用過從未導致使用者資訊沒有被更新成功。從而引發我們上面提到的奇怪的問題
  • 那麼既然是沒有被更新,到這裡我們就很好解決了,要麼每次使用完成後都將ThreadLocal中的資料remove。因為他內部是弱引用在下次回收就會將物件回收這樣也不會造成記憶體洩漏的問題
  • 或者我們在我們的AOP中setUser之前先將使用者ThreadLocal清空。兩種方式都可以完美解決我們的問題

具體程式碼實現

/**
 * 請求生命週期最後一步銷燬是做的回撥事件
 * 用於銷燬線上使用者資訊,防止線上使用者資訊互相干擾(在多執行緒複用時)
 */
@WebListener
@Primary
public class SysServletRequestListener implements ServletRequestListener {
    @Override
    public void requestDestroyed(ServletRequestEvent requestEvent) {
        UserInfoUtil.clearUserInfo();
    }

    @Override
    public void requestInitialized(ServletRequestEvent sre) {

    }
}
  • 我們可以通過spring提供的監聽器,監聽一個請求的生命週期在這個請求完成之後將我們的ThreadLocal進行remove。 為什麼我推薦這種做法呢。因為請求結束就清空可以快速的讓出記憶體讓他去做更加有用的事情。
  • 如果是第二種方法那麼如果我們沒有人在登入,或者說在下一次登入之前這塊不需要的記憶體永遠被佔著

總結忠告

  • 這次問題出現的很是奇怪,一度讓我懷疑人生,但是永遠相信程式是不會無緣無故的出問題的。
  • 出問題的只能是我們的程式碼有問題,要善於解析問題,將問題細化,細化到我們程式碼層面而不是業務層面
  • 使用一個技術時最好能先了解他內部的一個原理。或者最起碼先了解他的大概邏輯
  • 別看這篇文章寥寥幾字就解決了我們的問題,但是實際上我在解決他的過程中吃了不少的苦。好幾個夜晚都是我在陪他戰鬥
  • 我在定位到時ThreadLocal後就花了一個小時學習了下他的邏輯並跟蹤了他的原始碼。最後結合我們的業務才發現了眉目
  • 總之有問題是好事情,有了問題我們才能成長。至少在這次的問題中我學習到了ThreadLocal。我的這次問題也是使用他的典型問題,另外還有一個記憶體洩漏的問題這是在學習他原始碼的過程領悟到的一點。關於記憶體洩漏我們有時間在看吧。問題解決。終於可以繼續happy了。

相關文章