背景
項目是簡單的web項目,多用戶登陸的商家管理系統,使用ThreadLocal緩存登陸用戶的信息(duid,用戶唯一id)
bug描述
在測試環境多次登陸后,調用查詢接口查出的數據時有時無
排查過程
通過商戶id和用戶的duid給日志打上唯一標識(測試環境日志太多了),以便grep,排查后發現數據和日志還是時有時無,在排查中發現duid有時對有時錯,於是duid便成了突破口。順藤摸瓜,找到了攔截器緩存的duid數據,然而發現攔截器緩存的沒有問題。對比別的項目的攔截器后發現了問題,攔截器有個方法沒有重寫且本地線程的數據也沒有remove
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
DataUserHolder.clear();
super.afterCompletion(request, response, handler, ex);
}
這個加上了,bug就解決了。
思考
為什么threadlocal的數據會錯亂(被覆蓋)?
畫了一張簡圖來表示ThreadLocal的內部結構。
ThreadLocal內部實際使用了ThreadLocalMap來緩存數據。
一個entry即一個對象,可以理解為一個鍵值對。
ThreadLocalMap內部使用Entry[]
來存儲對象。
到目前為止,我們尚未分析源碼,但並不妨礙我們根據結果以及加粗文字推導問題原因。
如果我們簡單的把ThreadLocalMap理解為HashMap,是不是問題就顯而易見了?
以當前線程為key,以登陸用戶數據為value,在線程不變的情況下,用戶數據變了,有沒有這個可能?
有可能。
此處應有理論(個人):服務端只認請求線程,不認請求數據
為什么這么說呢?
比如在同一個瀏覽器上前后登陸兩個賬號,最后一定登陸的是后面的賬號,服務器認的是請求線程而不是賬號密碼。
代碼模擬bug過程
public class TestMain {
@Test
public void test() {
final ThreadLocal<UserCacheVO> local = new ThreadLocal<>();
final UserCacheVO vo1 = new UserCacheVO();
vo1.setDuid("12345");
vo1.setPhone("123434324123");
local.set(vo1);
UserCacheVO vo2 = new UserCacheVO();
vo2.setDuid("xxxx");
vo2.setPhone("yyygyjbjh");
local.set(vo2);
System.out.println(local.get());
}
}
UserCacheVO(phone=yyygyjbjh, duid=xxxx, userInfoMap=null)
Process finished with exit code 0
代碼流程:本來的業務需求是使用vo1的數據去db查詢結果,結果vo1的數據能正常查到結果,此時我用vo2的數據再次去查詢,就查不到了(數據已覆蓋)
對應頁面流程:頁面登錄,攔截器緩存數據,查詢結果,正常頁面展示;換賬號登錄后,攔截器緩存數據,覆蓋之前的請求線程的數據,導致數據的duid覆蓋,此時查詢的結果已不是我們想要的業務結果,在服務器里使用 merchantId+duid查詢數據就會發現沒這個日志,就出現莫名其妙的bug了。
修改bug后的代碼流程:頁面登錄,攔截器緩存數據,查詢結果,攔截器remove緩存,正常頁面展示。
注:登陸這個模塊是單獨的服務,且登陸服務由前端直接調用,正確登陸前端則獲取ticket做業務調用
源碼分析
private void set(ThreadLocal<?> key, Object value) {
// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 重點
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
如果ThreadLocal 相同,則Entry直接覆蓋。
總結
org.springframework.web.servlet.handler.HandlerInterceptorAdapter
共有四個方法,分別是
preHandle
進入controller接口前執行
postHandle
在 DispatcherServlet 呈現視圖(ModelAndView)之前調用,在前后端分離后好像就沒有視圖一說了,不甚了解
afterCompletion
請求處理完成后的回調,即渲染視圖后。執行完controller接口后執行,可以做資源清理。
afterConcurrentHandlingStarted
並發執行時調用,一般用不到
此bug重點在於本地線程的數據用完后沒有清理,即未調用afterCompletion
並DataUserHolder.clear()