3、租户上下文:ThreadLocal与请求上下文、租户ID的传递机制(Header/Token/Claim)、上下文初始化与清理

在多租户SaaS架构中,租户上下文(Tenant Context)是贯穿整个请求生命周期的核心数据载体。它承载了当前请求所属的租户标识(Tenant ID),确保后续的数据库路由、缓存隔离、业务逻辑判断都能正确执行。本章深入剖析租户上下文的实现原理、传递机制与生命周期管理。

3.1 租户上下文的核心设计

租户上下文本质上是一个请求级别的全局变量,在Java生态中通常使用ThreadLocal实现。其设计目标是在不污染方法签名的前提下,让任意层级的代码都能获取当前租户信息。

设计要素 说明 典型实现
存储载体 线程绑定的局部变量 ThreadLocal<TenantContext>
数据内容 租户ID、租户类型、请求来源等 TenantContext { tenantId, tenantType, requestId }
生命周期 请求进入 → 处理完成 → 清理 Filter/Interceptor 中初始化与清理
传递方式 Header / Token / Claim 解析 从HTTP请求头或JWT中提取
设计原则:租户上下文必须做到“一次设置,随处可用”,同时严格防止内存泄漏和跨租户数据污染。

3.2 ThreadLocal与请求上下文的绑定机制

ThreadLocal是Java中实现线程局部变量的标准方式。在多租户场景下,每个HTTP请求由独立的线程(或线程池中的线程)处理,ThreadLocal天然适合存储请求级别的租户上下文。

public class TenantContextHolder {
    private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();

    public static void setContext(TenantContext context) {
        CONTEXT.set(context);
    }

    public static TenantContext getContext() {
        return CONTEXT.get();
    }

    public static String getTenantId() {
        TenantContext context = CONTEXT.get();
        return context != null ? context.getTenantId() : null;
    }

    public static void clear() {
        CONTEXT.remove();
    }
}
避坑指南:使用线程池时,务必在请求结束后调用clear(),否则线程复用会导致后续请求拿到错误的租户上下文,造成严重的数据泄露事故。

下图展示了ThreadLocal在请求生命周期中的绑定与释放过程:

ThreadLocal 租户上下文生命周期 HTTP 请求进入 Filter 拦截请求 解析 Header/Token ThreadLocal.set() 业务逻辑处理 ThreadLocal.get() ThreadLocal.remove() HTTP 响应返回 ⚠ 关键约束: 必须在 finally 块中调用 clear(),确保异常场景下也能释放 ThreadLocal,防止内存泄漏和租户污染。

3.3 租户ID的传递机制:Header / Token / Claim

租户ID必须从客户端传递到服务端,常见的传递方式有三种,各有适用场景:

传递方式 载体 安全性 适用场景
HTTP Header X-Tenant-ID 自定义头 低(明文传输) 内部服务间调用、开发调试
JWT Token Token 中的自定义字段 高(签名验证) 用户认证后的API请求
Claim 声明 OAuth2 / OpenID Connect 的 claims 高(授权服务器签发) 企业级SSO、多租户身份联邦

3.4 从Header解析租户ID

最简单的传递方式是在HTTP请求头中携带租户ID。服务端通过Filter拦截并提取:

@Component
public class TenantFilter 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");

        if (tenantId != null && !tenantId.isEmpty()) {
            TenantContext context = new TenantContext(tenantId);
            TenantContextHolder.setContext(context);
        }

        try {
            chain.doFilter(request, response);
        } finally {
            TenantContextHolder.clear(); // 确保清理
        }
    }
}
最佳实践:Header名称建议统一为X-Tenant-ID,并在API网关层做校验,拒绝缺少租户ID的请求(除非是公开接口)。

3.5 从JWT Token解析租户ID

在JWT认证模式下,租户ID通常放在Token的claims中,服务端解析Token后提取:

public class JwtTokenParser {
    public static TenantContext parseToken(String token, String secret) {
        Claims claims = Jwts.parser()
                .setSigningKey(secret.getBytes())
                .parseClaimsJws(token)
                .getBody();

        String tenantId = claims.get("tenant_id", String.class);
        String tenantType = claims.get("tenant_type", String.class);

        return new TenantContext(tenantId, tenantType);
    }
}

JWT方式的安全性更高,因为Token经过签名,客户端无法篡改租户ID。同时,Token中还可以携带租户类型、角色等附加信息,减少后续查询次数。

3.6 上下文初始化与清理的完整流程

租户上下文的初始化与清理必须形成完整的闭环,任何遗漏都可能导致严重问题。下图展示了从请求入口到响应出口的完整链路:

租户上下文初始化与清理完整流程 阶段1:请求入口 API Gateway / Filter 阶段2:租户解析 Header / Token / Claim 阶段3:上下文初始化 ThreadLocal.set() 阶段4:业务处理(Controller → Service → DAO) 通过 TenantContextHolder.getTenantId() 获取租户ID,用于数据源路由、缓存隔离、业务逻辑 阶段5:上下文清理 ThreadLocal.remove() 阶段6:响应返回 HTTP Response 异常处理分支 finally 块中清理 🔑 核心要点: 无论正常返回还是抛出异常,清理操作必须在 finally 块中执行。推荐使用 Spring 的 OncePerRequestFilter 或自定义注解 + AOP 实现。

3.7 异步场景下的上下文传递

在异步处理(如@Async、线程池、消息队列)中,ThreadLocal无法自动传递到子线程。需要手动处理上下文传播:

public class TenantContextTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        TenantContext context = TenantContextHolder.getContext();
        return () -> {
            try {
                TenantContextHolder.setContext(context);
                runnable.run();
            } finally {
                TenantContextHolder.clear();
            }
        };
    }
}

// 配置线程池时注入装饰器
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setTaskDecorator(new TenantContextTaskDecorator());
    // 其他配置...
    return executor;
}
异步传递原则:父线程在提交任务前将上下文快照传入子线程,子线程执行完毕后自行清理。使用TaskDecorator或CompletableFuture的thenApplyAsync配合自定义线程池实现。

3.8 上下文初始化与清理的检查清单

  • 入口处初始化:Filter / Interceptor / AOP 切面中解析租户ID并设置到ThreadLocal
  • 业务中获取:通过TenantContextHolder.getTenantId()获取,不依赖方法参数传递
  • 出口处清理:在finally块中调用TenantContextHolder.clear()
  • 异步传递:使用TaskDecorator或RequestContextHolder传递上下文
  • 异常兜底:全局异常处理器中再次清理,防止Filter未捕获的异常导致泄漏
  • 测试验证:编写单元测试模拟多租户并发请求,验证上下文隔离性
严重警告:ThreadLocal清理不当是SaaS生产事故的头号原因。一次未清理的上下文可能导致后续数十个请求使用错误的租户ID,造成数据交叉泄露。务必在代码审查中重点检查清理逻辑。