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();
}
}
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层面自动拼接租户条件。到时候你会发现,前面这些铺垫都是值得的。