3. 租户上下文(Tenant Context)
在多租户 SaaS 系统中,鉴权拦截链路的核心目标之一是在请求处理的全生命周期中,准确、高效地识别当前请求所属的租户。租户上下文(Tenant Context)正是承载这一信息的载体。设计不当会导致租户数据泄露(A 租户看到 B 租户的数据)或上下文污染(线程池复用导致租户错乱)。本章深入剖析基于 ThreadLocal(线程级)与 请求头(请求级)的传递机制,并给出生产级实现。
3.1 租户上下文的生命周期与传递模型
一个典型的 SaaS 请求从网关到业务服务,租户上下文的传递路径如下:
- 客户端 在 HTTP 请求头中携带租户标识(如
X-Tenant-Id)。 - 网关/反向代理 解析请求头,可能进行初步校验,并透传至下游服务。
- 服务端过滤器/拦截器 从请求头中提取租户 ID,存入当前线程的 ThreadLocal。
- 业务逻辑层 从 ThreadLocal 获取租户上下文,用于数据源路由、数据隔离、审计日志等。
- 请求结束 在过滤器或拦截器的
finally块中清除 ThreadLocal,防止内存泄漏。
核心原则: 租户上下文必须在请求入口处注入,在请求出口处清除,中间链路只读不写。
3.2 ThreadLocal 实现:线程级租户上下文
ThreadLocal 是 Java 中实现线程封闭的标准方式。每个线程拥有独立的变量副本,天然隔离,无需加锁。以下是生产级实现:
3.2.1 基础上下文类
public class TenantContext {
private static final ThreadLocal<TenantContext> CONTEXT_HOLDER = new ThreadLocal<>();
private String tenantId;
private String userId;
private String requestId;
private Map<String, Object> attributes = new HashMap<>();
// 私有构造,防止外部实例化
private TenantContext() {}
// 初始化上下文
public static void init(String tenantId, String userId, String requestId) {
TenantContext context = new TenantContext();
context.tenantId = tenantId;
context.userId = userId;
context.requestId = requestId;
CONTEXT_HOLDER.set(context);
}
// 获取当前上下文
public static TenantContext get() {
return CONTEXT_HOLDER.get();
}
// 获取租户 ID(快捷方法)
public static String getTenantId() {
TenantContext context = CONTEXT_HOLDER.get();
return context != null ? context.tenantId : null;
}
// 清除上下文(必须在 finally 中调用)
public static void clear() {
CONTEXT_HOLDER.remove();
}
// getter / setter 省略...
}
3.2.2 过滤器/拦截器中的注入与清除
@Component
public class TenantContextFilter 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");
String userId = httpRequest.getHeader("X-User-Id");
String requestId = UUID.randomUUID().toString();
try {
// 校验租户 ID 合法性(非空、格式、白名单)
if (tenantId == null || tenantId.isBlank()) {
throw new TenantValidationException("Missing X-Tenant-Id header");
}
TenantContext.init(tenantId, userId, requestId);
chain.doFilter(request, response);
} finally {
TenantContext.clear(); // 确保清除
}
}
}
⚠️ 常见陷阱: 在异步处理(如 @Async、CompletableFuture、消息队列消费者)中,子线程无法继承父线程的 ThreadLocal。必须使用 InheritableThreadLocal 或 TransmittableThreadLocal(阿里开源)显式传递上下文。
3.3 请求级传递:HTTP 请求头设计
请求头是租户上下文在微服务间传递的标准载体。设计良好的请求头规范是分布式链路追踪和多层鉴权的基础。
| 请求头名称 | 类型 | 是否必选 | 说明 |
|---|---|---|---|
X-Tenant-Id |
String | 是 | 租户唯一标识,由客户端或网关注入 |
X-User-Id |
String | 是 | 当前操作用户 ID,用于鉴权与审计 |
X-Request-Id |
String | 推荐 | 全链路追踪 ID,便于日志关联 |
X-Tenant-Env |
String | 否 | 租户环境标识(如 prod/staging),用于灰度发布 |
3.3.1 微服务间透传:Feign 拦截器示例
@Component
public class TenantFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
TenantContext context = TenantContext.get();
if (context != null) {
template.header("X-Tenant-Id", context.getTenantId());
template.header("X-User-Id", context.getUserId());
template.header("X-Request-Id", context.getRequestId());
}
}
}
3.3.2 网关层统一注入
在 API 网关(如 Spring Cloud Gateway、Kong)中,建议统一从 JWT Token 或认证结果中提取租户 ID,并注入请求头,避免上游客户端伪造:
@Component
public class TenantGatewayFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 从 JWT 或认证上下文获取租户 ID
String tenantId = extractTenantFromToken(request);
if (tenantId == null) {
return Mono.error(new UnauthorizedException("Tenant not found in token"));
}
ServerHttpRequest modifiedRequest = request.mutate()
.header("X-Tenant-Id", tenantId)
.build();
return chain.filter(exchange.mutate().request(modifiedRequest).build());
}
}
3.4 生产级最佳实践与避坑清单
| 实践 | 说明 | 优先级 |
|---|---|---|
| 统一入口注入 | 在网关或最外层 Filter 中注入租户上下文,避免每个微服务重复解析 | P0 |
| 强制 finally 清除 | ThreadLocal 必须在使用后 remove(),否则线程池复用导致上下文错乱 | P0 |
| 异步传递 | 使用 TransmittableThreadLocal 或手动传递上下文到子线程 | P1 |
| 请求头校验 | 对 X-Tenant-Id 做非空、格式、白名单校验,防止注入攻击 | P1 |
| 日志打印 | 在 MDC 中放入 tenantId,实现日志的租户级隔离 | P2 |
| 单元测试 | 测试用例中手动 init/clear,避免测试间上下文污染 | P2 |
💡 扩展思考: 在 Serverless 或响应式编程(WebFlux)场景中,ThreadLocal 不再适用。此时应使用 Reactor Context(
SubscriberContext)或请求作用域 Bean 来传递租户上下文。架构选型时需提前评估。