3. 租户上下文设计:ThreadLocal 租户上下文、请求级租户传递、全局租户解析器
在多租户 SaaS 架构中,租户上下文(Tenant Context)是贯穿整个请求生命周期的核心数据载体。它承载了当前请求所属的租户标识(Tenant ID)、租户类型、隔离级别等信息。本章将深入讲解如何通过 ThreadLocal 实现线程级租户上下文、如何在请求链路中传递租户信息,以及如何构建全局租户解析器来统一解析和注入租户上下文。
3.1 租户上下文的核心设计原则
租户上下文的设计需要满足以下关键原则:
| 原则 | 说明 | 实现要点 |
|---|---|---|
| 线程隔离 | 每个请求线程拥有独立的租户上下文,互不干扰 | 使用 ThreadLocal 存储,避免线程间数据污染 |
| 请求级生命周期 | 租户上下文随请求创建,随请求结束销毁 | 在 Filter 或 Interceptor 中初始化,在 finally 块中清理 |
| 不可变性 | 租户上下文在请求处理过程中不应被修改 | 提供只读访问接口,禁止 setter 方法 |
| 可传递性 | 异步处理或跨服务调用时能正确传递租户信息 | 使用 InheritableThreadLocal 或显式传递 |
| 全局可访问 | 业务代码无需显式传递参数即可获取当前租户 | 通过静态方法获取 ThreadLocal 中的上下文 |
设计提示:租户上下文应保持轻量,只包含必要的租户标识和少量元数据。避免将数据库连接、缓存对象等重量级资源放入上下文中,防止内存泄漏。
3.2 ThreadLocal 租户上下文实现
ThreadLocal 是 Java 中实现线程级数据隔离的标准方案。在多租户场景中,我们使用 ThreadLocal 存储当前线程的租户上下文,确保每个请求线程拥有独立的租户信息副本。
3.2.1 租户上下文实体类
public class TenantContext {
private final String tenantId; // 租户唯一标识
private final String tenantName; // 租户名称
private final String tenantType; // 租户类型:企业/个人/试用
private final String isolationLevel; // 隔离级别:DATABASE/SCHEMA/TABLE
private final String region; // 区域标识
private final Map<String, Object> attributes; // 扩展属性
private TenantContext(Builder builder) {
this.tenantId = builder.tenantId;
this.tenantName = builder.tenantName;
this.tenantType = builder.tenantType;
this.isolationLevel = builder.isolationLevel;
this.region = builder.region;
this.attributes = Collections.unmodifiableMap(builder.attributes);
}
// 只提供 getter 方法,不提供 setter
public String getTenantId() { return tenantId; }
public String getTenantName() { return tenantName; }
public String getTenantType() { return tenantType; }
public String getIsolationLevel() { return isolationLevel; }
public String getRegion() { return region; }
public Object getAttribute(String key) { return attributes.get(key); }
// Builder 模式构建不可变对象
public static class Builder {
// ... builder 实现
}
}
3.2.2 ThreadLocal 持有者
public class TenantContextHolder {
private static final ThreadLocal<TenantContext> CONTEXT_HOLDER =
new InheritableThreadLocal<>();
// 设置租户上下文
public static void setContext(TenantContext context) {
CONTEXT_HOLDER.set(context);
}
// 获取租户上下文
public static TenantContext getContext() {
return CONTEXT_HOLDER.get();
}
// 获取租户 ID(快捷方法)
public static String getTenantId() {
TenantContext context = CONTEXT_HOLDER.get();
return context != null ? context.getTenantId() : null;
}
// 清理租户上下文(必须在请求结束时调用)
public static void clear() {
CONTEXT_HOLDER.remove();
}
// 判断当前是否有租户上下文
public static boolean hasContext() {
return CONTEXT_HOLDER.get() != null;
}
}
重要提醒:使用 InheritableThreadLocal 可以支持子线程继承父线程的租户上下文,适用于线程池场景。但必须注意在线程池任务执行完毕后清理上下文,否则会导致内存泄漏和租户信息错乱。
3.3 请求级租户传递机制
租户信息在请求链路中的传递是保证多租户隔离的基础。常见的传递方式包括 HTTP Header、JWT Token、请求参数等。下面通过 SVG 图展示请求级租户传递的完整链路。
3.4 全局租户解析器实现
全局租户解析器(TenantResolver)是租户上下文注入的核心组件。它负责从请求中提取租户信息,校验租户有效性,并将解析结果注入到 ThreadLocal 中。
3.4.1 解析器接口设计
public interface TenantResolver {
/**
* 从请求中解析租户上下文
* @param request 当前 HTTP 请求
* @return 解析后的租户上下文,如果无法解析返回 null
*/
TenantContext resolve(HttpServletRequest request);
/**
* 校验租户是否有效
* @param tenantContext 解析后的租户上下文
* @return true 表示有效,false 表示无效
*/
boolean validate(TenantContext tenantContext);
/**
* 解析器的优先级,数值越小优先级越高
*/
int getOrder();
}
3.4.2 多种解析器实现
| 解析器类型 | 解析来源 | 适用场景 | 优先级 |
|---|---|---|---|
| HeaderTenantResolver | HTTP Header: X-Tenant-ID | 标准 REST API 调用 | 1(最高) |
| JwtTenantResolver | JWT Token 中的 claims | 已认证的用户请求 | 2 |
| CookieTenantResolver | Cookie 中的租户信息 | 浏览器端 Web 应用 | 3 |
| ParameterTenantResolver | 请求参数: tenantId | 回调接口、WebSocket | 4 |
| DefaultTenantResolver | 默认租户(兜底) | 公共资源访问 | 5(最低) |
3.4.3 解析器链与过滤器集成
@Component
public class TenantContextFilter implements Filter {
@Autowired
private List<TenantResolver> resolvers; // 注入所有解析器
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
TenantContext context = null;
try {
// 按优先级依次尝试解析
for (TenantResolver resolver : resolvers) {
context = resolver.resolve(httpRequest);
if (context != null) {
// 校验租户有效性
if (!resolver.validate(context)) {
throw new InvalidTenantException("Invalid tenant: "
+ context.getTenantId());
}
break;
}
}
if (context == null) {
throw new TenantNotFoundException("No tenant context found");
}
// 注入 ThreadLocal
TenantContextHolder.setContext(context);
// 继续执行过滤器链
chain.doFilter(request, response);
} finally {
// 确保清理 ThreadLocal,防止内存泄漏
TenantContextHolder.clear();
}
}
}
最佳实践:解析器链采用责任链模式,按优先级依次尝试。高优先级的解析器先执行,如果成功解析则跳过后续解析器。这种设计支持多种租户传递方式并存,且易于扩展新的解析器。
3.5 异步场景下的租户上下文传递
在异步处理场景中,ThreadLocal 的租户上下文无法自动传递到新线程。需要采用特殊策略来保证租户信息的正确传递。
3.5.1 线程池包装器
public class TenantAwareThreadPoolExecutor extends ThreadPoolExecutor {
public TenantAwareThreadPoolExecutor(int corePoolSize, int maxPoolSize,
long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maxPoolSize, keepAliveTime, unit, workQueue);
}
@Override
public void execute(Runnable command) {
// 捕获当前线程的租户上下文
TenantContext context = TenantContextHolder.getContext();
super.execute(() -> {
try {
// 在新线程中恢复租户上下文
TenantContextHolder.setContext(context);
command.run();
} finally {
TenantContextHolder.clear();
}
});
}
}
3.5.2 消息队列传递
// 发送消息时携带租户 ID
public void sendMessage(String topic, Object payload) {
TenantContext context = TenantContextHolder.getContext();
Map<String, Object> headers = new HashMap<>();
headers.put("X-Tenant-ID", context.getTenantId());
headers.put("X-Tenant-Type", context.getTenantType());
Message<Object> message = MessageBuilder
.withPayload(payload)
.copyHeaders(headers)
.build();
messageTemplate.send(topic, message);
}
// 消费消息时重建租户上下文
@KafkaListener(topics = "order-events")
public void handleOrderEvent(ConsumerRecord<String, String> record) {
String tenantId = record.headers()
.lastHeader("X-Tenant-ID")
.map(h -> new String(h.value()))
.orElseThrow(() -> new TenantNotFoundException("Missing tenant ID"));
TenantContext context = new TenantContext.Builder()
.tenantId(tenantId)
.build();
try {
TenantContextHolder.setContext(context);
// 处理业务逻辑
processOrder(record.value());
} finally {
TenantContextHolder.clear();
}
}
3.6 租户上下文的安全考量
租户上下文作为敏感数据载体,必须考虑以下安全风险:
- 租户篡改风险:防止客户端伪造或篡改租户 ID,必须对租户信息进行签名或加密
- 上下文泄露:避免在日志、异常信息中打印完整的租户上下文,只输出脱敏后的租户 ID
- 线程安全问题:使用 ThreadLocal 时注意线程池复用导致的上下文残留,务必在 finally 中清理
- 越权访问:租户解析后必须校验当前用户是否有权访问该租户的资源
安全警告:永远不要信任客户端传递的租户 ID。即使通过 Header 或 JWT 获取了租户 ID,服务端也必须校验该租户是否存在、是否有效、当前用户是否属于该租户。建议在网关层或认证中心完成租户校验,业务服务只信任已经校验过的租户上下文。
3.7 本章小结
租户上下文设计是多租户 SaaS 架构的基石。通过 ThreadLocal 实现线程级隔离,通过请求级传递机制保证链路完整性,通过全局租户解析器统一管理租户注入,三者共同构成了健壮的租户上下文体系。在实际项目中,还需要结合具体的异步框架(如 Spring Async、Reactor、Kafka)进行适配,确保租户上下文在复杂的调用链路中始终正确传递。