3、租户上下文:ThreadLocal与请求上下文、租户ID的传递机制(Header/Token/Claim)、上下文初始化与清理
在多租户SaaS架构中,租户上下文(Tenant Context)是贯穿整个请求生命周期的核心数据载体。它承载了当前请求所属的租户标识(Tenant ID),确保后续的数据库路由、缓存隔离、业务逻辑判断都能正确执行。本章深入剖析租户上下文的实现原理、传递机制与生命周期管理。
3.1 租户上下文的核心设计
租户上下文本质上是一个请求级别的全局变量,在Java生态中通常使用ThreadLocal实现。其设计目标是在不污染方法签名的前提下,让任意层级的代码都能获取当前租户信息。
| 设计要素 | 说明 | 典型实现 |
|---|---|---|
| 存储载体 | 线程绑定的局部变量 | ThreadLocal<TenantContext> |
| 数据内容 | 租户ID、租户类型、请求来源等 | TenantContext { tenantId, tenantType, requestId } |
| 生命周期 | 请求进入 → 处理完成 → 清理 | Filter/Interceptor 中初始化与清理 |
| 传递方式 | Header / Token / Claim 解析 | 从HTTP请求头或JWT中提取 |
设计原则:租户上下文必须做到“一次设置,随处可用”,同时严格防止内存泄漏和跨租户数据污染。
3.2 ThreadLocal与请求上下文的绑定机制
ThreadLocal是Java中实现线程局部变量的标准方式。在多租户场景下,每个HTTP请求由独立的线程(或线程池中的线程)处理,ThreadLocal天然适合存储请求级别的租户上下文。
public class TenantContextHolder {
private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();
public static void setContext(TenantContext context) {
CONTEXT.set(context);
}
public static TenantContext getContext() {
return CONTEXT.get();
}
public static String getTenantId() {
TenantContext context = CONTEXT.get();
return context != null ? context.getTenantId() : null;
}
public static void clear() {
CONTEXT.remove();
}
}
避坑指南:使用线程池时,务必在请求结束后调用
clear(),否则线程复用会导致后续请求拿到错误的租户上下文,造成严重的数据泄露事故。
下图展示了ThreadLocal在请求生命周期中的绑定与释放过程:
3.3 租户ID的传递机制:Header / Token / Claim
租户ID必须从客户端传递到服务端,常见的传递方式有三种,各有适用场景:
| 传递方式 | 载体 | 安全性 | 适用场景 |
|---|---|---|---|
| HTTP Header | X-Tenant-ID 自定义头 |
低(明文传输) | 内部服务间调用、开发调试 |
| JWT Token | Token 中的自定义字段 | 高(签名验证) | 用户认证后的API请求 |
| Claim 声明 | OAuth2 / OpenID Connect 的 claims | 高(授权服务器签发) | 企业级SSO、多租户身份联邦 |
3.4 从Header解析租户ID
最简单的传递方式是在HTTP请求头中携带租户ID。服务端通过Filter拦截并提取:
@Component
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String tenantId = httpRequest.getHeader("X-Tenant-ID");
if (tenantId != null && !tenantId.isEmpty()) {
TenantContext context = new TenantContext(tenantId);
TenantContextHolder.setContext(context);
}
try {
chain.doFilter(request, response);
} finally {
TenantContextHolder.clear(); // 确保清理
}
}
}
最佳实践:Header名称建议统一为
X-Tenant-ID,并在API网关层做校验,拒绝缺少租户ID的请求(除非是公开接口)。
3.5 从JWT Token解析租户ID
在JWT认证模式下,租户ID通常放在Token的claims中,服务端解析Token后提取:
public class JwtTokenParser {
public static TenantContext parseToken(String token, String secret) {
Claims claims = Jwts.parser()
.setSigningKey(secret.getBytes())
.parseClaimsJws(token)
.getBody();
String tenantId = claims.get("tenant_id", String.class);
String tenantType = claims.get("tenant_type", String.class);
return new TenantContext(tenantId, tenantType);
}
}
JWT方式的安全性更高,因为Token经过签名,客户端无法篡改租户ID。同时,Token中还可以携带租户类型、角色等附加信息,减少后续查询次数。
3.6 上下文初始化与清理的完整流程
租户上下文的初始化与清理必须形成完整的闭环,任何遗漏都可能导致严重问题。下图展示了从请求入口到响应出口的完整链路:
3.7 异步场景下的上下文传递
在异步处理(如@Async、线程池、消息队列)中,ThreadLocal无法自动传递到子线程。需要手动处理上下文传播:
public class TenantContextTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
TenantContext context = TenantContextHolder.getContext();
return () -> {
try {
TenantContextHolder.setContext(context);
runnable.run();
} finally {
TenantContextHolder.clear();
}
};
}
}
// 配置线程池时注入装饰器
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(new TenantContextTaskDecorator());
// 其他配置...
return executor;
}
异步传递原则:父线程在提交任务前将上下文快照传入子线程,子线程执行完毕后自行清理。使用
TaskDecorator或CompletableFuture的thenApplyAsync配合自定义线程池实现。
3.8 上下文初始化与清理的检查清单
- 入口处初始化:Filter / Interceptor / AOP 切面中解析租户ID并设置到ThreadLocal
- 业务中获取:通过
TenantContextHolder.getTenantId()获取,不依赖方法参数传递 - 出口处清理:在
finally块中调用TenantContextHolder.clear() - 异步传递:使用
TaskDecorator或RequestContextHolder传递上下文 - 异常兜底:全局异常处理器中再次清理,防止Filter未捕获的异常导致泄漏
- 测试验证:编写单元测试模拟多租户并发请求,验证上下文隔离性
严重警告:ThreadLocal清理不当是SaaS生产事故的头号原因。一次未清理的上下文可能导致后续数十个请求使用错误的租户ID,造成数据交叉泄露。务必在代码审查中重点检查清理逻辑。