3. 租户上下文(Tenant Context):如何设计线程级与请求级的租户上下文传递机制

在多租户 SaaS 系统中,鉴权拦截链路的核心目标之一是在请求处理的全生命周期中,准确、高效地识别当前请求所属的租户。租户上下文(Tenant Context)正是承载这一信息的载体。设计不当会导致租户数据泄露(A 租户看到 B 租户的数据)或上下文污染(线程池复用导致租户错乱)。本章深入剖析基于 ThreadLocal(线程级)与 请求头(请求级)的传递机制,并给出生产级实现。

3.1 租户上下文的生命周期与传递模型

一个典型的 SaaS 请求从网关到业务服务,租户上下文的传递路径如下:

  1. 客户端 在 HTTP 请求头中携带租户标识(如 X-Tenant-Id)。
  2. 网关/反向代理 解析请求头,可能进行初步校验,并透传至下游服务。
  3. 服务端过滤器/拦截器 从请求头中提取租户 ID,存入当前线程的 ThreadLocal。
  4. 业务逻辑层 从 ThreadLocal 获取租户上下文,用于数据源路由、数据隔离、审计日志等。
  5. 请求结束 在过滤器或拦截器的 finally 块中清除 ThreadLocal,防止内存泄漏。
核心原则: 租户上下文必须在请求入口处注入,在请求出口处清除,中间链路只读不写。
租户上下文传递流程 客户端请求 X-Tenant-Id: t001 网关/过滤器 解析 + 校验 拦截器 ThreadLocal.set() 业务逻辑 ThreadLocal.get() 请求结束 ThreadLocal.remove() ⚠️ 关键设计要点 1. 入口(过滤器/拦截器)负责 set(),出口(finally 块)负责 remove()。 2. 业务代码只读 ThreadLocal,禁止写入或修改租户 ID。 3. 异步任务需显式传递上下文(见 3.3 节)。

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());
    }
}
请求头在微服务间的传递 客户端 API 网关 服务 A 服务 B 服务 C X-Tenant-Id 透传 Feign 透传 异步消息 🔗 请求头传递规则 1. 网关层统一注入,下游服务只读不写。 2. 异步调用(MQ、Event Bus)需将请求头封装到消息体或消息头中。

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 来传递租户上下文。架构选型时需提前评估。