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 图展示请求级租户传递的完整链路。

请求级租户传递链路 客户端 携带 Tenant-ID Header HTTP 请求 API 网关 解析 Header / JWT 转发 Header 全局租户解析器 1. 提取 Tenant-ID 2. 校验租户有效性 3. 注入 ThreadLocal 业务服务 读取上下文 异步消息队列 携带租户 ID 异步处理 下游服务 重建租户上下文 传递机制说明 1. HTTP Header 传递:客户端在请求头中携带 X-Tenant-ID,网关层解析并转发 2. JWT Token 传递:租户信息编码在 JWT 的 claims 中,服务端解码获取 3. 请求参数传递:适用于 WebSocket 或回调场景,通过参数显式传递 4. 异步传递:通过消息队列或事件总线传递租户 ID,下游服务重建上下文 5. RPC 调用传递:通过 Dubbo/RPC 的隐式参数或 gRPC 的 metadata 传递

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)进行适配,确保租户上下文在复杂的调用链路中始终正确传递。