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