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 参数传入,避免遗漏和伪造。
五、常见故障与排查要点
租户上下文问题往往表现为“偶发串数据”或“随机查不到数据”,排查时可关注以下几点:
- 串租户:检查 ThreadLocal 是否在 finally 中清理;异步任务是否用了 TTL;线程池是否被复用。
- 租户为空:检查网关过滤器是否执行(order 是否被其他过滤器覆盖);JWT 解析是否因密钥不匹配失败;域名是否带端口导致 split 结果异常。
- 跨服务丢失:检查 Feign/RestTemplate 拦截器是否注入
X-Tenant-Id;MQ 消息是否携带租户字段。 - 越权:检查请求头租户与 JWT 租户是否做了一致性校验。
总结来看,租户上下文传递的核心是“入口可信解析、链路可靠传递、出口严格清理”。网关负责统一识别与注入,服务内用 ThreadLocal 承载并保证清理,异步与跨服务调用显式透传,数据访问层统一消费。把这四步做扎实,多租户医疗 SaaS 的数据隔离才有可靠基础。