3、租户上下文设计:ThreadLocal与租户上下文、请求上下文传递、上下文清理机制

在多租户SaaS架构中,鉴权拦截链路的核心挑战在于:如何在一次请求的全生命周期内,准确、高效、安全地传递当前租户的身份与元数据。租户上下文(Tenant Context)正是解决这一问题的关键设计模式。它通过线程级隔离存储,确保每个请求在处理过程中都能访问到正确的租户信息,同时避免跨租户数据泄露。

3.1 ThreadLocal与租户上下文

ThreadLocal是Java中实现线程局部变量的核心机制。在多租户场景下,每个HTTP请求通常由独立的线程(或线程池中的工作线程)处理。利用ThreadLocal,我们可以将当前租户的标识(TenantId)、租户属性(如数据库路由键、配置版本号)绑定到当前线程,使得该线程上的所有组件(过滤器、拦截器、Service层、DAO层)都能无侵入地获取租户上下文。

核心设计原则:租户上下文对象应封装为不可变(Immutable)或只读对象,避免在业务逻辑中被意外修改。上下文对象通常包含:tenantId(必选)、tenantDbRoute(可选)、tenantConfigVersion(可选)、requestId(用于链路追踪)。
组件 职责 生命周期
TenantContextHolder 封装ThreadLocal变量,提供set/get/clear静态方法 与请求线程绑定,请求结束即清理
TenantContext 租户上下文数据模型,包含tenantId等字段 单次请求内有效
TenantContextFilter 在请求入口处解析租户信息并注入上下文 每次请求执行一次
TenantContextCleanupFilter 在请求出口处清理ThreadLocal,防止内存泄漏 每次请求结束前执行
// TenantContext 数据模型
public class TenantContext {
    private final String tenantId;
    private final String tenantDbRoute;
    private final String tenantConfigVersion;
    private final String requestId;

    // 私有构造器,通过Builder创建
    private TenantContext(Builder builder) {
        this.tenantId = builder.tenantId;
        this.tenantDbRoute = builder.tenantDbRoute;
        this.tenantConfigVersion = builder.tenantConfigVersion;
        this.requestId = builder.requestId;
    }

    // getter 方法...
    public static class Builder {
        // Builder实现...
    }
}

// TenantContextHolder 线程局部存储
public class TenantContextHolder {
    private static final ThreadLocal<TenantContext> CONTEXT_HOLDER =
            new ThreadLocal<>();

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

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

    public static void clear() {
        CONTEXT_HOLDER.remove();
    }
}
最佳实践:使用InheritableThreadLocal替代ThreadLocal,可以支持父线程向子线程传递租户上下文,适用于异步任务场景。但需注意,在线程池复用场景下,必须配合清理机制使用,否则会导致租户信息错乱。

3.2 请求上下文传递

租户上下文在请求链路中的传递,需要覆盖从网关到微服务、从同步调用到异步消息的全路径。下图展示了典型的请求上下文传递架构:

请求上下文传递架构 API 网关 认证拦截器 租户解析器 TenantContextHolder.setContext(tenantContext) — 注入当前线程 Controller 层 Service 层 DAO / Repository 动态数据源路由:根据 TenantContext.tenantDbRoute 选择对应数据库 异步消息:MQ / 事件总线 RPC 调用:Feign / gRPC 说明:异步消息需显式传递租户上下文;RPC调用通过请求头透传 TenantId

请求上下文传递的关键路径包括:

  • 网关层解析:从JWT Token、请求头或Cookie中提取tenantId,并校验租户有效性。
  • 拦截器注入:在Spring MVC的HandlerInterceptor或Servlet Filter中,调用TenantContextHolder.setContext()。
  • 业务层读取:Service层通过TenantContextHolder.getContext()获取租户信息,用于数据隔离路由。
  • 跨服务传递:RPC调用(如Feign)通过自定义RequestInterceptor将租户上下文写入请求头;异步消息(如RabbitMQ)在消息体中携带租户上下文。
// Feign 请求拦截器示例:自动传递租户上下文
@Component
public class TenantFeignInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        TenantContext context = TenantContextHolder.getContext();
        if (context != null) {
            template.header("X-Tenant-Id", context.getTenantId());
            template.header("X-Request-Id", context.getRequestId());
        }
    }
}

// 异步消息发送时携带租户上下文
public class TenantAwareMessage {
    private String tenantId;
    private String requestId;
    private Object payload;
    // getter/setter...
}

3.3 上下文清理机制

上下文清理是租户上下文设计中最容易忽视但后果最严重的环节。如果ThreadLocal未在请求结束时清理,在线程池复用场景下,下一个请求将读取到上一个请求的租户信息,导致跨租户数据泄露。清理机制必须覆盖正常流程和异常流程。

风险警示:未清理的ThreadLocal不仅导致租户错乱,还可能引发内存泄漏(尤其是Web应用服务器使用线程池时)。每次请求结束后,必须确保TenantContextHolder.clear()被调用。

下表对比了三种常见的清理策略:

策略 实现方式 优点 缺点
Filter 清理 在Servlet Filter的doFilter()方法中,使用try-finally包裹,在finally中调用clear() 简单可靠,覆盖所有请求路径 无法覆盖Filter链之外的异步线程
Interceptor 清理 在Spring MVC的HandlerInterceptor的afterCompletion()中清理 与Spring生态集成好,可访问ModelAndView 仅覆盖DispatcherServlet处理的请求
装饰器模式 + 线程池 自定义ThreadPoolExecutor,在任务执行前后自动清理和恢复上下文 覆盖异步任务场景,防止线程池污染 实现复杂度较高,需改造现有线程池
// Filter 清理模式(推荐作为第一道防线)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantContextFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        try {
            // 1. 解析租户信息(从请求头、Token等)
            String tenantId = extractTenantId(request);
            String requestId = UUID.randomUUID().toString();

            // 2. 构建并注入上下文
            TenantContext context = new TenantContext.Builder()
                    .tenantId(tenantId)
                    .requestId(requestId)
                    .build();
            TenantContextHolder.setContext(context);

            // 3. 执行后续Filter和业务逻辑
            chain.doFilter(request, response);
        } finally {
            // 4. 强制清理ThreadLocal,防止内存泄漏和租户错乱
            TenantContextHolder.clear();
        }
    }

    private String extractTenantId(ServletRequest request) {
        // 从Header或Token中提取tenantId
        return ((HttpServletRequest) request).getHeader("X-Tenant-Id");
    }
}

// 异步任务上下文传递与清理
public class TenantAwareRunnable implements Runnable {
    private final TenantContext context;
    private final Runnable delegate;

    public TenantAwareRunnable(Runnable delegate) {
        this.context = TenantContextHolder.getContext(); // 捕获父线程上下文
        this.delegate = delegate;
    }

    @Override
    public void run() {
        try {
            TenantContextHolder.setContext(context); // 恢复上下文
            delegate.run();
        } finally {
            TenantContextHolder.clear(); // 清理子线程上下文
        }
    }
}
清理机制检查清单:
✅ 所有同步请求路径(Filter/Interceptor)必须有finally清理块
✅ 异步任务(CompletableFuture、@Async、线程池)必须使用装饰器传递并清理上下文
✅ 单元测试中每个测试用例结束后调用TenantContextHolder.clear()
✅ 集成测试中模拟请求结束后验证ThreadLocal已被清空
✅ 生产环境监控:定期检查ThreadLocal大小,设置告警阈值

下图展示了请求生命周期内上下文注入与清理的完整时序:

请求生命周期:上下文注入与清理时序 时间 请求到达 解析租户ID 注入上下文 业务处理 清理上下文 响应返回 异常抛出(Exception) finally 强制清理 错误响应 关键原则:无论正常返回还是异常抛出,清理上下文必须在 finally 块中执行

总结:租户上下文设计是SaaS鉴权拦截链路的数据中枢。通过ThreadLocal实现线程级隔离,通过Filter/Interceptor实现自动注入与清理,通过请求头/消息体实现跨服务传递,最终构建出一个安全、高效、无泄漏的租户上下文传递体系。下一章将深入探讨基于此上下文的动态数据源路由实现。