4、租户上下文管理:ThreadLocal与租户ID传递、请求拦截器实现、AOP切面注入租户信息

好,咱们继续往下聊。上一章我们把多租户的数据隔离方案定了下来,但有个关键问题一直悬着——租户ID到底怎么传?

你想想看,用户登录后,系统怎么知道他是哪个租户的?每次请求都得带上这个信息。如果每个方法都手动传参,那代码就彻底废了。我个人习惯的做法,是用一套「上下文管理机制」来解决这个问题。

说白了,就是让租户ID像空气一样存在,你感知不到它,但它无处不在。

4.1 ThreadLocal:租户信息的「临时储物柜」

先聊聊核心工具——ThreadLocal。这东西在Java里是个老面孔了,但用对地方真能救命。

它的原理很简单:每个线程都有自己的专属存储空间。你把租户ID塞进去,在当前线程的任何地方都能取出来。等请求处理完了,再把它清掉。

我在项目中遇到过一个问题:有个同事把租户ID存在了静态变量里。结果呢?高并发下A租户的请求覆盖了B租户的数据,查出来的全是别人的账单。嗯,那场面,你懂的。

所以,ThreadLocal是必须的。来看一个简单的封装:

public class TenantContext {

    private static final ThreadLocal<String> TENANT_ID_HOLDER = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        TENANT_ID_HOLDER.set(tenantId);
    }

    public static String getTenantId() {
        return TENANT_ID_HOLDER.get();
    }

    public static void clear() {
        TENANT_ID_HOLDER.remove();
    }
}
⚠️ 重要提醒: 用完一定要调用 clear()!我曾经见过线上事故,就是因为线程池复用线程时,旧租户ID没清掉,导致新请求用了别人的数据。这属于「内存泄漏」的一种,查起来特别头疼。

4.2 请求拦截器:自动注入租户ID

有了储物柜,接下来得有人往里放东西。谁来干这活?请求拦截器

我个人习惯在Spring MVC的拦截器里做这件事。前端每次请求都会带上一个Header,比如 X-Tenant-Id。拦截器拿到这个值,塞进ThreadLocal里。

代码大概长这样:

public class TenantInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, 
                             Object handler) {
        String tenantId = request.getHeader("X-Tenant-Id");
        if (StringUtils.isBlank(tenantId)) {
            // 这里可以抛异常,也可以走默认租户
            throw new BusinessException("缺少租户信息");
        }
        TenantContext.setTenantId(tenantId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler, Exception ex) {
        // 请求结束,必须清理
        TenantContext.clear();
    }
}
💡 小技巧: 如果前端不方便传Header,也可以从JWT Token里解析租户ID。我有个项目就是这么干的——用户登录时把租户ID编码进Token,拦截器解析出来再塞进ThreadLocal。省了前端一件事。

4.3 AOP切面:无侵入的租户注入

拦截器解决了Web层的传递问题。但有些场景不是HTTP请求触发的,比如MQ消息消费、定时任务、批处理作业。这时候怎么办?

答案是AOP切面

我举个例子。假设有个定时任务,需要扫描所有租户的过期订单。你总不能手动给每个方法传租户ID吧?太累了。

用AOP切面,我们可以这样设计:

@Aspect
@Component
public class TenantAspect {

    @Around("@annotation(com.example.TenantInject)")
    public void injectTenant(ProceedingJoinPoint joinPoint) throws Throwable {
        // 从方法参数或配置中获取租户ID
        String tenantId = resolveTenantId(joinPoint);
        try {
            TenantContext.setTenantId(tenantId);
            joinPoint.proceed();
        } finally {
            TenantContext.clear();
        }
    }

    private String resolveTenantId(ProceedingJoinPoint joinPoint) {
        // 这里可以解析注解参数,或者从配置中心获取
        // 具体逻辑看业务场景
        return "default_tenant";
    }
}

然后,在需要租户上下文的方法上,加个注解就行:

@TenantInject(tenantId = "#tenantId")
public void processOrder(String tenantId, Order order) {
    // 方法内部直接使用 TenantContext.getTenantId()
    // 不用再手动传参了
}

核心思路总结:

  • ThreadLocal:存储租户ID,线程隔离
  • 拦截器:处理HTTP请求,自动注入
  • AOP切面:处理非HTTP场景,无侵入注入

三者配合,租户ID的传递就变得「润物细无声」了。

4.4 避坑指南:我踩过的几个坑

讲到这里,我得分享几个真实教训。这些坑我当年都踩过,你们别重蹈覆辙。

  • 坑一:异步线程丢失上下文
    我记得有一次,我用@Async异步处理任务,结果子线程里取不到租户ID。原因很简单——ThreadLocal是线程绑定的,新线程不会继承父线程的数据。解决方案是用InheritableThreadLocal,或者手动传递。
  • 坑二:线程池复用导致数据错乱
    这个前面提过。线程池里的线程是复用的,如果不清除ThreadLocal,下一个请求就会读到上一个请求的租户ID。我曾经因为这个被老板叫去喝茶……嗯,从那以后我写了个过滤器,强制在请求结束时清理。
  • 坑三:单元测试里忘了初始化
    写测试时,如果没手动设置租户ID,代码里一调用TenantContext.getTenantId()就返回null。我建议在每个测试方法前,用@Before初始化一个默认租户。

4.5 整体架构图(文字版)

最后,我用一个表格来总结整个流程。你一看就明白了:

环节 组件 职责 示例
Web请求 拦截器 从Header/Token提取租户ID,注入ThreadLocal TenantInterceptor
业务处理 ThreadLocal 线程内共享租户ID TenantContext
非Web场景 AOP切面 通过注解注入租户ID TenantAspect
数据访问 MyBatis插件 从ThreadLocal取租户ID,拼接到SQL 下一章会讲

你看,整个链路是通的。从请求进来,到数据查询,租户ID就像一条隐形的线,把所有环节串了起来。

下一章,我会讲怎么在MyBatis层面自动拼接租户条件。到时候你会发现,前面这些铺垫都是值得的。