
1. 一場線上排查過后我重新理解了“線程上下文”有一次排查接口偶發(fā)超時日志里死活串不起一個完整請求。接口內(nèi)部明明打了入口日志、中間日志、出口日志可日志平臺上就是分不清哪些行屬于同一次調(diào)用。最初懷疑是日志框架配置問題后來看到MDCMapped Diagnostic Context里的traceId在某些線程里是空的才意識到真正的源頭是線程上下文丟了。那一趟排查看下來收獲比解決超時本身大得多。線程上下文Thread Context不是什么高深概念它指的是線程在執(zhí)行期間可以隨時讀取的一組環(huán)境信息典型的包括日志追蹤ID、用戶身份、租戶編碼、事務(wù)連接、語言區(qū)域、時區(qū)甚至是某個業(yè)務(wù)臨時開關(guān)。你可以把它理解成一個工位線程就是坐在這張工位上的工人工位抽屜里放著工具和資料只有這個工人自己能用別人動手去拿就是越權(quán)。很多并發(fā)問題表面上是線程安全、數(shù)據(jù)錯亂、資源泄漏剝開之后其實都是“上下文”在哪個環(huán)節(jié)被設(shè)置、被傳遞、被清理的問題。這篇文章我會從底層原理講到線程池場景再到日志、事務(wù)、身份驗證的真實落地最后給一套可以直接抄的上下文管理工具。適合所有寫并發(fā)代碼、做中間件、排查分布式問題的人讀。2. 上下文到底存在哪ThreadLocal底層工作原理2.1 Thread對象上的那張Map大多數(shù)語言都提供了線程私有變量的機(jī)制Java里就是ThreadLocal。很多讀者知道ThreadLocal能讓每個線程持有副本但底層怎么設(shè)計直接決定了你會不會踩內(nèi)存泄漏的坑。每個Thread實例內(nèi)部維護(hù)了一個ThreadLocalMap大致結(jié)構(gòu)是這樣的Thread.currentThread() - ThreadLocalMap線程自己的 - Entry[] table - 每個Entry的key是ThreadLocal對象value是你塞進(jìn)去的值也就是說往ThreadLocal里set一個值時數(shù)據(jù)并沒有“存到”ThreadLocal對象本身而是存到了當(dāng)前線程內(nèi)部的Map里。ThreadLocal在這里更像一把鑰匙每一個線程的抽屜上掛著同一把鎖只有拿鎖的人才能打開自己線程的抽屜。寫代碼時你可能見過這種用法private static final ThreadLocalLong TRACE_ID new ThreadLocal(); public static void setTraceId(Long traceId) { TRACE_ID.set(traceId); } public static Long getTraceId() { return TRACE_ID.get(); }看起來是靜態(tài)變量全局訪問實際上每個線程讀到的都是自己的副本。正因為ThreadLocal本身是static的可能被多個類加載器、多個線程共享所以Java對Entry的key使用了弱引用關(guān)于這個下面詳細(xì)講。但普通ThreadLocal只能做到“線程內(nèi)的私有變量”它并沒有解決“如何傳遞到其他線程”的問題。2.2 弱引用和大坑為什么有人用完不remove就內(nèi)存泄漏Entry是ThreadLocalMap里的桶結(jié)構(gòu)key是弱引用WeakReference value是強(qiáng)引用。這里要特別看清楚兩個引用的區(qū)別keyThreadLocal對象的弱引用。如果外部沒有強(qiáng)引用指向ThreadLocal變量GC時key就可能被回收。value你實際set進(jìn)去的對象普通強(qiáng)引用。問題來了如果key被回收但value還強(qiáng)掛在Entry里這時候entry就變成了“key為nullvalue有效”的臟數(shù)據(jù)。如果線程池的線程長期存活大多數(shù)生產(chǎn)環(huán)境線程池線程是常駐的這些臟value就會一直堆在ThreadLocalMap里永遠(yuǎn)沒有機(jī)會釋放。我曾經(jīng)在一個長時間運行的服務(wù)里見過ThreadLocalMap里積累了上萬個Entry里面全是過期請求的上下文對象內(nèi)存占用高得離譜。打開內(nèi)存分析工具一看全是ThreadLocalMap的entry堆棧全指向某框架里的ThreadLocal。問題就出在寫入后沒有及時remove。順手說一句set(null)并不能替代remove()。set(null)只是把value置空key還在remove()會直接刪除整個Entry包括key的映射。正確姿勢永遠(yuǎn)是在finally里調(diào)用removetry { TRACE_ID.set(traceId); // 業(yè)務(wù)邏輯 } finally { TRACE_ID.remove(); }2.3 為什么InheritableThreadLocal只對“新建線程”有效Java還提供了InheritableThreadLocal作用是在創(chuàng)建子線程時自動把父線程的值復(fù)制一份給子線程。原理不復(fù)雜Thread類的構(gòu)造方法里會掃描當(dāng)前創(chuàng)建線程的ThreadLocalMap把支持繼承的項拷貝到新線程上。private static final InheritableThreadLocalString USER new InheritableThreadLocal(); // 在主線程里設(shè)置 USER.set(user-1001); // 在這里創(chuàng)建子線程子線程能get到user-1001 Thread child new Thread(() - System.out.println(USER.get()));這個機(jī)制有個非常重要的邊界條件它只發(fā)生在“線程創(chuàng)建”那一刻。如果你用的是線程池線程在池里早就創(chuàng)建好了后續(xù)提交的任務(wù)只是復(fù)用這條線程根本不會觸發(fā)新建線程的拷貝邏輯。更糟糕的是線程池復(fù)用時上一個任務(wù)塞到線程上下文里的值下一個任務(wù)照樣能讀到。我見過一個很經(jīng)典的翻車場景兩個請求A和B先后提交到同一個線程池。A請求設(shè)置了請求ID等于REQ-AB請求沒有設(shè)置。結(jié)果B的日志里打印出了REQ-A兩個請求的日志徹底混在一起。排查的時候光看業(yè)務(wù)代碼發(fā)現(xiàn)不了問題得把執(zhí)行鏈路上的線程切換點全部梳理一遍。3. 線程池場景下的上下文傳播比想象中復(fù)雜得多3.1 線程池為什么丟上下文三個環(huán)節(jié)拆開看線程池場景想保住上下文必須在三個環(huán)節(jié)都做對提交任務(wù)時把當(dāng)前線程的上下文快照保存下來。執(zhí)行任務(wù)前把快照放回執(zhí)行線程的ThreadLocalMap。執(zhí)行任務(wù)后清理執(zhí)行線程上的上下文避免污染下一個任務(wù)。public class ContextPropagator { private final MapThreadLocal?, Object snapshot; private ContextPropagator(MapThreadLocal?, Object snapshot) { this.snapshot snapshot; } public static ContextPropagator capture() { MapThreadLocal?, Object map new HashMap(); map.put(TRACE_ID, TRACE_ID.get()); map.put(USER_ID, USER_ID.get()); map.putIfAbsent(LOCALE, LOCALE.get()); return new ContextPropagator(map); } public Runnable wrap(Runnable task) { return () - { MapThreadLocal?, Object backup backupAndSet(snapshot); try { task.run(); } finally { restore(backup); } }; } private MapThreadLocal?, Object backupAndSet(MapThreadLocal?, Object snapshot) { MapThreadLocal?, Object backup new HashMap(); snapshot.forEach((key, value) - { backup.put(key, key.get()); key.set(value); }); return backup; } private void restore(MapThreadLocal?, Object backup) { backup.forEach(ThreadLocal::set); } }這里每個細(xì)節(jié)都有講究。為什么要backup再set而不是直接set因為執(zhí)行線程上可能有自己原來的上下文直接覆蓋會丟失原有數(shù)據(jù)。為什么需要restore因為線程池線程是共享的不還原的話上一個任務(wù)留下的痕跡會串到下一個任務(wù)。這個包裝邏輯適用于ExecutorService、CompletableFuture、Async等各種異步執(zhí)行入口。3.2 最常見的坑一個請求的traceId傳染給所有請求日志鏈路追不到的問題幾乎都是這么發(fā)生的業(yè)務(wù)代碼用了CompletableFuture.supplyAsync()或者框架的異步注解底層走的是公共線程池。提交任務(wù)時線程上下文還在主線程上任務(wù)真正運行時換了個工作線程traceId沒有跟著過去。如果運氣好目標(biāo)線程上沒有舊數(shù)據(jù)traceId就是null日志還能通過時間戳勉強(qiáng)推斷。如果運氣不好正好復(fù)用了工作線程traceId就變成了上一個任務(wù)的日志直接串臺。排查這種問題有個很笨但有效的辦法在入口、提交點、執(zhí)行點分別打印當(dāng)前線程ID和traceId對比一下就能看出在哪一步發(fā)生了線程切換。再往深看就是看有沒有執(zhí)行包裝器。一個有經(jīng)驗的團(tuán)隊會在封裝的異步執(zhí)行器里統(tǒng)一做上下文透傳而不是讓每個業(yè)務(wù)開發(fā)自己去處理。否則每加一個異步調(diào)用就多一個漏配點。3.3 先理解原理再決定要不要引入專門的透傳工具現(xiàn)在業(yè)界已經(jīng)有專門處理上下文傳播的類庫比如TransmittableThreadLocal通常簡稱TTL它解決了InheritableThreadLocal在線程池復(fù)用下的失效問題。它的核心思想是在提交任務(wù)時對上下文做快照、在執(zhí)行前重放、執(zhí)行后復(fù)原同時還能處理線程池里任務(wù)并發(fā)、嵌套異步的情況。但我不建議一上來就無腦引入。先把手動包裝Runnable/Callable的邏輯用熟明白capture - replay - restore這個閉環(huán)再看TTL源碼會輕松很多。而且不是所有上下文都值得透傳像traceId這種必要字段應(yīng)該無條件傳像數(shù)據(jù)庫連接、Spring事務(wù)這類跟資源強(qiáng)綁定的上下文絕不能靠著ThreadLocal硬傳否則跨線程事務(wù)會出大亂子。這個區(qū)別后面細(xì)講。4. 三大生產(chǎn)場景日志追蹤、事務(wù)綁定、身份上下文4.1 日志鏈路MDC是如何借ThreadLocal工作的日志框架的MDC本質(zhì)上就是一個基于ThreadLocal的Map實現(xiàn)。在你的logback或log4j2配置里加上%X{traceId}或%X{userId}日志框架在每次打印時去當(dāng)前線程的MDC里取值。想接入traceId標(biāo)準(zhǔn)做法是寫一個Servlet過濾器或網(wǎng)關(guān)過濾器Component public class TraceIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String traceId extractFromHeaderOrGenerate((HttpServletRequest) request); MDC.put(traceId, traceId); try { chain.doFilter(request, response); } finally { MDC.remove(traceId); } } }注意finally里的remove不能省。如果請求線程被復(fù)用下一條請求不清理日志就會把多個請求的traceId混到一起。這個坑就像反復(fù)揉皺的紙表面看是日志問題實際是上下文生命周期管理問題。4.2 事務(wù)和連接綁定的根源Spring的事務(wù)管理器為什么能在一個方法里讓多個數(shù)據(jù)源操作共享同一個數(shù)據(jù)庫連接就是通過ThreadLocal把連接對象和當(dāng)前線程綁定在一起。事務(wù)開啟時連接被丟進(jìn)ThreadLocal同一線程中的后續(xù)數(shù)據(jù)庫操作從ThreadLocal里取出同一個連接參與事務(wù)事務(wù)結(jié)束提交或回滾后再把連接清掉。這里特別容易踩雷的是“跨線程事務(wù)”。比如你在一個事務(wù)方法里用了new Thread()或線程池去執(zhí)行SQL子線程從ThreadLocal里拿不到父線程綁定的連接自然就參與不了同一個事務(wù)。這跟很多人誤以為“只要方法加了Transactional就全鏈路事務(wù)”完全不是一回事。一個反過來的危險場景是某個線程池線程在執(zhí)行完帶事務(wù)的任務(wù)后ThreadLocal里的連接沒有清理干凈下一個任務(wù)莫名拿到上上個任務(wù)的連接執(zhí)行了不該有的提交或回滾。這種問題錯誤信息往往很迷惑一會兒報連接關(guān)閉一會兒報事務(wù)狀態(tài)異常。4.3 用戶身份、權(quán)限、租戶的隔離多租戶系統(tǒng)里請求進(jìn)來后通常先識別租戶編碼然后把這個編碼放到ThreadLocal里后續(xù)所有數(shù)據(jù)源路由、緩存Key組裝、業(yè)務(wù)規(guī)則判斷都從ThreadLocal讀取。這個模式有個致命前提同一個請求的整個過程必須盡量待在同一個線程里。只要內(nèi)部出現(xiàn)線程切換租戶編碼就要顯式傳遞。否則就會發(fā)生“用戶A打開A租戶頁面異步任務(wù)卻用了B租戶的數(shù)據(jù)源”這就不只是日志錯亂了是數(shù)據(jù)錯亂屬于線上事故級別。我見過一次真實故障就是因為一個異步上報任務(wù)沒傳租戶ID所有租戶的數(shù)據(jù)路由到了默認(rèn)租戶好在發(fā)現(xiàn)得早沒有真正污染數(shù)據(jù)庫但排查過程非常痛苦。5. 一次上下文串臺的完整排查鏈路這個案例值得沉淀下來?,F(xiàn)象是一個報表任務(wù)在某個時段內(nèi)偶發(fā)性出現(xiàn)數(shù)據(jù)歸屬錯誤十分鐘前是A租戶十分鐘后同一個任務(wù)輸出變成了B租戶。沒崩潰、沒報錯但數(shù)據(jù)就是錯了。我的排查過程是這樣走的先看日志。任務(wù)入口和出口都打印了租戶ID對比發(fā)現(xiàn)出口的租戶ID有時候和入口不一致。這就說明租戶ID在任務(wù)執(zhí)行過程中被改寫而不是一開始就錯。給任務(wù)加了一段臨時日志打印每個關(guān)鍵步驟的Thread.currentThread().getId()。結(jié)果發(fā)現(xiàn)任務(wù)被執(zhí)行線程池切了過去同一個任務(wù)內(nèi)部的線程ID在變化。再跟蹤是誰設(shè)置了租戶上下文。看到業(yè)務(wù)代碼里有這樣一個調(diào)用某個工具類的靜態(tài)方法里直接TENANT_HOLDER.set(tempTenantId);執(zhí)行完后沒有及時清理。于是形成了完整解釋第一次任務(wù)運行時在當(dāng)前線程池線程上寫入了某個租戶ID沒清掉。后續(xù)其他任務(wù)復(fù)用這個線程時沒有設(shè)置自己的租戶ID或者設(shè)置時被異常中斷導(dǎo)致下一個任務(wù)讀到殘留值。修復(fù)方案是三層業(yè)務(wù)代碼里所有設(shè)置上下文的入口統(tǒng)一收斂到工具類set之后必須finally remove。異步提交統(tǒng)一走封裝好的執(zhí)行器提交時做上下文快照執(zhí)行完成后清理。線程池調(diào)度入口做兜底任務(wù)開始前強(qiáng)制覆蓋目標(biāo)租戶ID任務(wù)結(jié)束后強(qiáng)制清理。排查這類問題不能只盯著業(yè)務(wù)代碼要看整個調(diào)用鏈上的線程切換點和ThreadLocal生命周期。擺放一張自查表檢查點判斷方式是否存在線程切換入口與執(zhí)行點分別打印Thread ID上下文設(shè)置后有沒有清理搜代碼里的set和remove是否成對出現(xiàn)線程池任務(wù)是否共用統(tǒng)計重復(fù)線程ID執(zhí)行不同任務(wù)次數(shù)父線程上下文是否需傳遞看任務(wù)內(nèi)是否依賴入口線程的變量殘留值會不會被誤讀在每個任務(wù)開頭強(qiáng)制set默認(rèn)值6. 自建一套可用的上下文管理工具如果不想依賴第三方庫完全可以自己維護(hù)一個輕量上下文工具。它的職責(zé)很單一提供線程內(nèi)的存取接口并提供異步任務(wù)包裝方法保證關(guān)鍵字段跨線程不丟。6.1 定義上下文模型public final class RuntimeContext { private static final ThreadLocalContextSnapshot HOLDER new ThreadLocal(); private RuntimeContext() { } public static void set(ContextSnapshot snapshot) { HOLDER.set(snapshot); } public static ContextSnapshot get() { return HOLDER.get(); } public static ContextSnapshot capture() { ContextSnapshot current HOLDER.get(); return current ! null ? current.copy() : ContextSnapshot.empty(); } public static void clear() { HOLDER.remove(); } public static Runnable wrap(Runnable task) { ContextSnapshot snapshot capture(); return () - { ContextSnapshot backup HOLDER.get(); HOLDER.set(snapshot); try { task.run(); } finally { if (backup ! null) { HOLDER.set(backup); } else { HOLDER.remove(); } } }; } }ContextSnapshot可以是一個普通POJO里面放traceId、userId、tenantId甚至是一張MapString, Stringpublic final class ContextSnapshot { private String traceId; private String userId; private String tenantId; public ContextSnapshot copy() { ContextSnapshot snapshot new ContextSnapshot(); snapshot.traceId this.traceId; snapshot.userId this.userId; snapshot.tenantId this.tenantId; return snapshot; } public static ContextSnapshot empty() { return new ContextSnapshot(); } // getter / setter 略 }6.2 和Spring集成一個任務(wù)提交入口實際項目里我更建議把任務(wù)提交入口統(tǒng)一封裝。比如提供一個AsyncTaskExecutorComponent public class AsyncTaskExecutor { private final ExecutorService executor Executors.newFixedThreadPool(16); public void execute(Runnable task) { executor.execute(RuntimeContext.wrap(task)); } public T FutureT submit(CallableT task) { ContextSnapshot snapshot RuntimeContext.capture(); return executor.submit(() - { ContextSnapshot backup RuntimeContext.get(); RuntimeContext.set(snapshot); try { return task.call(); } finally { if (backup ! null) { RuntimeContext.set(backup); } else { RuntimeContext.clear(); } } }); } }這套設(shè)計的核心思想是業(yè)務(wù)代碼永遠(yuǎn)不直接new線程、不直接提交裸任務(wù)而是統(tǒng)一走這個封裝過的執(zhí)行器。這樣上下文傳遞的邏輯只在一個地方維護(hù)未來更換底層線程池策略或者引入TTL時只需要改這一處。6.3 使用方式和效果在接口入口處設(shè)置上下文然后異步任務(wù)就能正常讀取ContextSnapshot snapshot new ContextSnapshot(); snapshot.setTraceId(traceId); snapshot.setTenantId(tenantId); RuntimeContext.set(snapshot); try { asyncTaskExecutor.execute(() - processReport()); } finally { RuntimeContext.clear(); }我在某個模擬項目里把這套方案接進(jìn)日志鏈路和多租戶路由后traceId正確率恢復(fù)100%租戶串?dāng)?shù)據(jù)的問題徹底消失。需要注意這個方案維護(hù)的是“關(guān)鍵業(yè)務(wù)字段”它不是萬能透傳器。線程池里那些不屬于業(yè)務(wù)上下文的系統(tǒng)級變量不該通過這個通道傳遞。怎么說呢線程上下文這門技術(shù)單看ThreadLocal很好理解難的是把它放進(jìn)真實的高并發(fā)、多線程池、跨異步鏈路的場景里。踩過坑、畫過線程切換圖、親手封裝過傳播工具之后你才真正理解它在整個并發(fā)體系里扮演的角色。希望這篇梳理能幫你少踩幾個我踩過的坑。