4. 租户上下文传递与请求路由

在多租户医疗 SaaS 中,一次请求从进入系统到落到具体租户的数据上,中间要经过“识别租户—绑定租户—传递租户—路由到租户”四个环节。任何一环丢失租户标识,都会导致两类严重问题:要么查不到数据(租户为空),要么更危险地串到别的租户数据上(租户错绑)。本节围绕租户标识解析、线程上下文传递、网关层路由与过滤,以及 Spring Cloud Gateway 的落地实现展开。

一、租户标识从哪里来:三种解析来源与优先级

租户标识(tenantId)通常有三个来源,工程上需要明确优先级,避免同一请求出现多个互相冲突的租户值。

  • 域名解析:如 hospital-a.medsaas.com,取子域名 hospital-a 映射为租户编码。适合面向医院、诊所的独立入口,用户无感知。
  • JWT 声明:登录后签发的令牌中携带 tenant_id 声明。这是最可信的来源,因为由认证中心签发、不可被客户端随意篡改。
  • 请求头:如 X-Tenant-Id: hospital-a。适合内部服务调用、开放 API 或灰度调试,但必须校验调用方是否有权代表该租户。

推荐的优先级是:JWT 声明 > 域名 > 请求头。当 JWT 中存在 tenant_id 时,以它为准;若 JWT 未携带(如未登录的公开接口),再回退到域名;请求头仅作为内部调用或显式授权的补充。若三者同时存在且不一致,应直接拒绝请求并返回 400,而不是“选一个用”,否则会掩盖越权风险。

安全红线:请求头中的 X-Tenant-Id 绝不能被外部客户端直接信任。网关必须校验“当前登录用户所属租户”与“请求头声明的租户”是否一致,不一致即拒绝,防止横向越权。

二、线程上下文传递:从入口到数据访问层

解析出 tenantId 后,需要让它贯穿整个调用链。Java 服务中常用 ThreadLocal 承载租户上下文,典型结构如下:

public final class TenantContext {
    private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();

    public static void set(String tenantId) { HOLDER.set(tenantId); }
    public static String get() { return HOLDER.get(); }
    public static void clear() { HOLDER.remove(); }
}

关键在于“谁设置、谁清理”。设置点通常在网关或 Web 拦截器的 preHandle;清理点必须在 afterCompletion 或 finally 中执行。因为 Tomcat 使用线程池复用线程,若不清理,下一个请求可能读到上一个租户的残留值,造成串租户——这是最隐蔽也最危险的一类缺陷。

当调用链涉及异步(@Async、线程池、CompletableFuture)时,普通 ThreadLocal 会失效,因为子线程拿不到父线程的值。此时应使用 TransmittableThreadLocal(阿里 TTL)或手动装饰线程池,把租户上下文复制到子线程。同理,跨服务调用(Feign、RestTemplate)需要在请求头中透传 X-Tenant-Id,可在拦截器中统一注入。

三、网关层路由与过滤:Spring Cloud Gateway 实战

网关是租户识别的第一道关口,也是统一注入租户上下文的最佳位置。Spring Cloud Gateway 基于 WebFlux,过滤器分为 GlobalFilter 和 GatewayFilter。租户处理适合放在 GlobalFilter 中,对所有路由生效。

下面是一个租户解析与透传过滤器的核心实现:

@Component
public class TenantFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String tenantId = resolveTenant(request);   // JWT > 域名 > 请求头
        if (tenantId == null) {
            exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
            return exchange.getResponse().setComplete();
        }
        ServerHttpRequest mutated = request.mutate()
                .header("X-Tenant-Id", tenantId)
                .build();
        return chain.filter(exchange.mutate().request(mutated).build());
    }

    private String resolveTenant(ServerHttpRequest request) {
        // 1. 优先从 JWT 解析
        String fromJwt = JwtUtil.extractTenant(request.getHeaders().getFirst("Authorization"));
        if (fromJwt != null) return fromJwt;
        // 2. 回退到域名
        String host = request.getHeaders().getFirst("Host");
        if (host != null && host.contains(".")) {
            return host.split("\\.")[0];
        }
        // 3. 最后看请求头
        return request.getHeaders().getFirst("X-Tenant-Id");
    }

    @Override
    public int getOrder() { return -100; }  // 尽早执行
}

路由配置可按租户维度做静态路由,也可结合服务发现做动态路由。例如按域名前缀路由到不同后端集群:

spring:
  cloud:
    gateway:
      routes:
        - id: tenant-a-route
          uri: lb://med-service-a
          predicates:
            - Host=hospital-a.medsaas.com
        - id: tenant-b-route
          uri: lb://med-service-b
          predicates:
            - Host=hospital-b.medsaas.com

更常见的做法是“共享服务、按租户隔离数据”,此时路由不区分租户,而是由过滤器统一注入 X-Tenant-Id,后端服务据此在数据访问层拼接租户条件或切换数据源。

四、数据访问层的租户落地

租户上下文最终要作用到 SQL 上。常见两种模式:

  • 共享库共享表:每张业务表带 tenant_id 列,MyBatis 拦截器在 SQL 中自动追加 WHERE tenant_id = ?。适合租户量大、单租户数据量小的场景。
  • 共享库独立 Schema:按租户切换 Schema,通过动态数据源路由。隔离性更好,适合合规要求高的医疗数据。

无论哪种模式,租户值都应从 TenantContext.get() 读取,而不是从 Controller 参数传入,避免遗漏和伪造。

租户上下文传递与请求路由链路 客户端请求 域名 / JWT / 请求头 Spring Cloud Gateway TenantFilter (GlobalFilter) 解析 → 校验 → 注入 X-Tenant-Id 透传 解析优先级 ① JWT tenant_id ② 域名子域 ③ X-Tenant-Id 头 不一致 → 400 拒绝 后端微服务 拦截器 set TenantContext finally 中 clear() ThreadLocal TenantContext 异步用 TTL 透传 数据访问层 MyBatis 拦截器 追加 tenant_id 条件 租户数据隔离 共享表 + tenant_id 或独立 Schema 关键约束:JWT 优先于域名与请求头;三者冲突时拒绝请求;线程池复用必须 clear();异步与跨服务调用需显式透传 tenantId 数据访问层统一从 TenantContext 读取,禁止从 Controller 参数传入租户值

五、常见故障与排查要点

租户上下文问题往往表现为“偶发串数据”或“随机查不到数据”,排查时可关注以下几点:

  • 串租户:检查 ThreadLocal 是否在 finally 中清理;异步任务是否用了 TTL;线程池是否被复用。
  • 租户为空:检查网关过滤器是否执行(order 是否被其他过滤器覆盖);JWT 解析是否因密钥不匹配失败;域名是否带端口导致 split 结果异常。
  • 跨服务丢失:检查 Feign/RestTemplate 拦截器是否注入 X-Tenant-Id;MQ 消息是否携带租户字段。
  • 越权:检查请求头租户与 JWT 租户是否做了一致性校验。
实践建议:在日志 MDC 中放入 tenantId,使每条日志都带租户标识,排查串租户问题时能快速定位是哪个租户的请求污染了上下文。

总结来看,租户上下文传递的核心是“入口可信解析、链路可靠传递、出口严格清理”。网关负责统一识别与注入,服务内用 ThreadLocal 承载并保证清理,异步与跨服务调用显式透传,数据访问层统一消费。把这四步做扎实,多租户医疗 SaaS 的数据隔离才有可靠基础。