3、租户上下文设计:ThreadLocal与租户上下文、请求上下文传递、上下文清理机制
在多租户SaaS架构中,鉴权拦截链路的核心挑战在于:如何在一次请求的全生命周期内,准确、高效、安全地传递当前租户的身份与元数据。租户上下文(Tenant Context)正是解决这一问题的关键设计模式。它通过线程级隔离存储,确保每个请求在处理过程中都能访问到正确的租户信息,同时避免跨租户数据泄露。
3.1 ThreadLocal与租户上下文
ThreadLocal是Java中实现线程局部变量的核心机制。在多租户场景下,每个HTTP请求通常由独立的线程(或线程池中的工作线程)处理。利用ThreadLocal,我们可以将当前租户的标识(TenantId)、租户属性(如数据库路由键、配置版本号)绑定到当前线程,使得该线程上的所有组件(过滤器、拦截器、Service层、DAO层)都能无侵入地获取租户上下文。
核心设计原则:租户上下文对象应封装为不可变(Immutable)或只读对象,避免在业务逻辑中被意外修改。上下文对象通常包含:
tenantId(必选)、tenantDbRoute(可选)、tenantConfigVersion(可选)、requestId(用于链路追踪)。
| 组件 | 职责 | 生命周期 |
|---|---|---|
| TenantContextHolder | 封装ThreadLocal变量,提供set/get/clear静态方法 | 与请求线程绑定,请求结束即清理 |
| TenantContext | 租户上下文数据模型,包含tenantId等字段 | 单次请求内有效 |
| TenantContextFilter | 在请求入口处解析租户信息并注入上下文 | 每次请求执行一次 |
| TenantContextCleanupFilter | 在请求出口处清理ThreadLocal,防止内存泄漏 | 每次请求结束前执行 |
// TenantContext 数据模型
public class TenantContext {
private final String tenantId;
private final String tenantDbRoute;
private final String tenantConfigVersion;
private final String requestId;
// 私有构造器,通过Builder创建
private TenantContext(Builder builder) {
this.tenantId = builder.tenantId;
this.tenantDbRoute = builder.tenantDbRoute;
this.tenantConfigVersion = builder.tenantConfigVersion;
this.requestId = builder.requestId;
}
// getter 方法...
public static class Builder {
// Builder实现...
}
}
// TenantContextHolder 线程局部存储
public class TenantContextHolder {
private static final ThreadLocal<TenantContext> CONTEXT_HOLDER =
new ThreadLocal<>();
public static void setContext(TenantContext context) {
CONTEXT_HOLDER.set(context);
}
public static TenantContext getContext() {
return CONTEXT_HOLDER.get();
}
public static void clear() {
CONTEXT_HOLDER.remove();
}
}
最佳实践:使用
InheritableThreadLocal替代ThreadLocal,可以支持父线程向子线程传递租户上下文,适用于异步任务场景。但需注意,在线程池复用场景下,必须配合清理机制使用,否则会导致租户信息错乱。
3.2 请求上下文传递
租户上下文在请求链路中的传递,需要覆盖从网关到微服务、从同步调用到异步消息的全路径。下图展示了典型的请求上下文传递架构:
请求上下文传递的关键路径包括:
- 网关层解析:从JWT Token、请求头或Cookie中提取
tenantId,并校验租户有效性。 - 拦截器注入:在Spring MVC的
HandlerInterceptor或Servlet Filter中,调用TenantContextHolder.setContext()。 - 业务层读取:Service层通过
TenantContextHolder.getContext()获取租户信息,用于数据隔离路由。 - 跨服务传递:RPC调用(如Feign)通过自定义
RequestInterceptor将租户上下文写入请求头;异步消息(如RabbitMQ)在消息体中携带租户上下文。
// Feign 请求拦截器示例:自动传递租户上下文
@Component
public class TenantFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
TenantContext context = TenantContextHolder.getContext();
if (context != null) {
template.header("X-Tenant-Id", context.getTenantId());
template.header("X-Request-Id", context.getRequestId());
}
}
}
// 异步消息发送时携带租户上下文
public class TenantAwareMessage {
private String tenantId;
private String requestId;
private Object payload;
// getter/setter...
}
3.3 上下文清理机制
上下文清理是租户上下文设计中最容易忽视但后果最严重的环节。如果ThreadLocal未在请求结束时清理,在线程池复用场景下,下一个请求将读取到上一个请求的租户信息,导致跨租户数据泄露。清理机制必须覆盖正常流程和异常流程。
风险警示:未清理的ThreadLocal不仅导致租户错乱,还可能引发内存泄漏(尤其是Web应用服务器使用线程池时)。每次请求结束后,必须确保
TenantContextHolder.clear()被调用。
下表对比了三种常见的清理策略:
| 策略 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| Filter 清理 | 在Servlet Filter的doFilter()方法中,使用try-finally包裹,在finally中调用clear() |
简单可靠,覆盖所有请求路径 | 无法覆盖Filter链之外的异步线程 |
| Interceptor 清理 | 在Spring MVC的HandlerInterceptor的afterCompletion()中清理 |
与Spring生态集成好,可访问ModelAndView | 仅覆盖DispatcherServlet处理的请求 |
| 装饰器模式 + 线程池 | 自定义ThreadPoolExecutor,在任务执行前后自动清理和恢复上下文 |
覆盖异步任务场景,防止线程池污染 | 实现复杂度较高,需改造现有线程池 |
// Filter 清理模式(推荐作为第一道防线)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
try {
// 1. 解析租户信息(从请求头、Token等)
String tenantId = extractTenantId(request);
String requestId = UUID.randomUUID().toString();
// 2. 构建并注入上下文
TenantContext context = new TenantContext.Builder()
.tenantId(tenantId)
.requestId(requestId)
.build();
TenantContextHolder.setContext(context);
// 3. 执行后续Filter和业务逻辑
chain.doFilter(request, response);
} finally {
// 4. 强制清理ThreadLocal,防止内存泄漏和租户错乱
TenantContextHolder.clear();
}
}
private String extractTenantId(ServletRequest request) {
// 从Header或Token中提取tenantId
return ((HttpServletRequest) request).getHeader("X-Tenant-Id");
}
}
// 异步任务上下文传递与清理
public class TenantAwareRunnable implements Runnable {
private final TenantContext context;
private final Runnable delegate;
public TenantAwareRunnable(Runnable delegate) {
this.context = TenantContextHolder.getContext(); // 捕获父线程上下文
this.delegate = delegate;
}
@Override
public void run() {
try {
TenantContextHolder.setContext(context); // 恢复上下文
delegate.run();
} finally {
TenantContextHolder.clear(); // 清理子线程上下文
}
}
}
清理机制检查清单:
✅ 所有同步请求路径(Filter/Interceptor)必须有
✅ 异步任务(CompletableFuture、@Async、线程池)必须使用装饰器传递并清理上下文
✅ 单元测试中每个测试用例结束后调用
✅ 集成测试中模拟请求结束后验证ThreadLocal已被清空
✅ 生产环境监控:定期检查ThreadLocal大小,设置告警阈值
✅ 所有同步请求路径(Filter/Interceptor)必须有
finally清理块✅ 异步任务(CompletableFuture、@Async、线程池)必须使用装饰器传递并清理上下文
✅ 单元测试中每个测试用例结束后调用
TenantContextHolder.clear()✅ 集成测试中模拟请求结束后验证ThreadLocal已被清空
✅ 生产环境监控:定期检查ThreadLocal大小,设置告警阈值
下图展示了请求生命周期内上下文注入与清理的完整时序:
总结:租户上下文设计是SaaS鉴权拦截链路的数据中枢。通过ThreadLocal实现线程级隔离,通过Filter/Interceptor实现自动注入与清理,通过请求头/消息体实现跨服务传递,最终构建出一个安全、高效、无泄漏的租户上下文传递体系。下一章将深入探讨基于此上下文的动态数据源路由实现。