zhaojie.tech/me
项目实战复盘 2026-10-04T00:00:00.000Z

多租户系统的 ThreadLocal 上下文:写进去只是第一步

一个真实 bug 带来这篇文字:测试环境里,A 租户的运营反馈看到了 B 租户的门店数据。查了一圈,SQL 没写错,拦截器在位,最后定位到那条请求走的是 @Async——worker 线程是池化复用的,上一个任务(B 租户)归还线程时无人清理 ThreadLocal,这个任务读到的是 B 的残留租户号、被注进了 SQL,查出来的自然是 B 的门店数据。上下文为 null 则是另一种形态:插件把 tenant_id = NULL 渲染进 SQL,恒零行、查不出任何数据——那是上一篇《线上查不到数据,但没有任何报错》的故事。两种形态同根同源,本篇聚焦上下文的生命周期管理。写进去只是第一步,能保证它在”每一条会执行业务的线程”上、以正确的生命周期存在,才是多租户上下文的完整课题。

背景:上下文里装了什么

我的 ERP 是 Spring Boot 2.3.7 + MyBatis-Plus,行级租户隔离靠 TenantLineInnerInterceptor 自动往 SQL 注入 tenant_id,而注入的取值来自一个纯静态的 TenantContext:tenantId、userId、userName、storeId、storeScope(门店数据权限作用域)等一组 ThreadLocal。请求进来时由 Shiro 的 OAuth2Realm 在认证成功处写入:

TenantContext.setTenantId(user.getOrganizationId());
TenantContext.setUserId(user.getUserId());
TenantContext.setStoreId(user.getStoreId());
TenantContext.setStoreIdList(storeIdList);   // 多店管理员的门店集合

问题在于:Tomcat 线程是池化的,Shiro 只管写、没人管清。下一个请求如果走 anon 路径(登录、swagger),OAuth2Realm 根本不触发,线程上残留的还是上一个请求的租户。这是比”异步丢上下文”更隐蔽的串号来源。

第一层防线:请求结束必须清

我加了一个 TenantContextCleanupFilter extends OncePerRequestFilter,在 finally 里统一清理三套 ThreadLocal:

try {
    chain.doFilter(request, response);
} finally {
    TenantContext.clear();
    ThreadContext.remove();   // Shiro Subject + SecurityManager
    MDC.clear();
}

这里 remove 的首要目的不是防内存泄漏,而是防线程复用串租户——池化线程归还后,下一个请求不该继承任何上一个请求的残留。注册方式上我弃用了 @Component + @Order,改为 FilterRegistrationBean 显式 setOrder(HIGHEST_PRECEDENCE),因为 Spring Boot 各版本对 Filter 类上 @Order 的扫描行为有差异,排序这种 correctness 级别的事不交给框架版本的默认行为。

第二层防线:异步线程的快照-恢复-清理

@Async 切换线程后 ThreadLocal 全丢。解法是一个 TaskDecorator,注册到全局异步池、asyncTaskExecutor 和销售收款补偿池三处。核心是三段式:

// 1. decorate() 在 caller 线程执行:快照
final Long tenantId = TenantContext.getTenantId();
final Long userId = TenantContext.getUserId();
final List<Long> storeIdList = TenantContext.getStoreIdList();
Subject callerSubject = ThreadContext.getSubject();   // 纯读取,不自动创建匿名 Subject
final Map<String, String> mdcSnapshot = MDC.getCopyOfContextMap();

return () -> {
    // 2. worker 线程:恢复
    if (tenantId != null) TenantContext.setTenantId(tenantId);
    if (storeIdList != null) TenantContext.setStoreIdList(storeIdList);
    if (finalSubject != null) new SubjectThreadState(finalSubject).bind();
    if (mdcSnapshot != null) MDC.setContextMap(mdcSnapshot);
    try {
        runnable.run();
    } finally {
        // 3. finally:清理,防线程池复用串租户
        TenantContext.clear();
        MDC.clear();
        ThreadContext.remove();   // Subject 一并清理:兜底逻辑会回退读 Shiro,残留 Subject 同样串身份
    }
};

两个容易被忽略的细节。其一,worker 的 finally 清理覆盖正常与异常两条路径,且与请求级 Filter 同一逻辑——线程池的 worker 和 Tomcat 线程一样是复用的,不清就是下一个异步任务读到上一个租户。其二,CallerRunsPolicy:队列打满时任务会在 caller 线程内联执行,此时 worker == caller,任何 set 都是在覆盖自己、clear 更会把 caller 自己的上下文一锅端清掉。我用线程引用对比跳过整个 restore/clear 流程:

final boolean inlineRunOnCaller = (Thread.currentThread() == callerThread);
if (inlineRunOnCaller) { runnable.run(); return; }

这一处是真实事故修出来的:2026 年 6 月一次压测触发了内联执行,clear 把 caller 的租户上下文清空,后面的同步代码全部”静默查空”。另外我还埋了诊断:caller 快照里 tenantId/userId/storeId 全为空时,按任务类 60 秒限频打 warn——定时任务、MQ 消费者本就无请求上下文属合法场景,但不能让它们刷屏,也不能让真正的遗漏静默。

还有一个不起眼的取舍:MDC 在 worker 上用 clear 而不是”保存原值、退出再恢复”。乍看恢复更精细,但线程池线程会被几十上百个任务先后复用,save/restore 依赖”进入时的原值是干净的”,一旦某次任务漏清,上一个任务遗留的 MDC 就会被下一次 restore 固化成”原始状态”,错误顺着链条传染。MDC 在这套系统里只承载请求级业务键(traceId、租户标签),与 TenantContext 同生命周期,直接 clear 更安全——代价仅是 Logback 等组件在 worker 上预置的键一并被清,而那些键本来就不该跨任务存活。

为什么不用别的方案

InheritableThreadLocal 在线程池场景基本不可用:子线程是池子预先创建的,继承发生在创建时而不是任务提交时。TransmittableThreadLocal 能解,但要引入字节码增强并约束所有池的创建方式,对一个已有多套业务线程池的存量系统,统一在 TaskDecorator 这一层收口,改动面和心智负担都更小。

一句原则

ThreadLocal 上下文的契约是”谁写入,谁负责在宿主线程归还前清空”——写、传、清三件事只做前两件,串租户只是时间问题。

← 全部文章 赵杰 · me.zhaojie.tech