第 14 章 · 多租户与数据隔离
本章目标:理解 SaaS 多租户(Multi-Tenancy) 业务场景;在 svc-spring-demo 实现 共享库共享表 + tenant_id 行级隔离(教学主路径);了解 独立 Schema、独立数据库 两种强隔离方案;在 Gateway / Feign / JPA 全链路传递 X-Tenant-Id;掌握 租户上下文、越权防护与缓存 key 隔离;讨论微服务拆分下租户一致性。
学时建议:5~6 小时(含 2 小时 tenant 过滤器与 JPA 联调)
前置:spring-cloud-web ch01~ch12;spring-boot-web ch05 JPA;Gateway 过滤器(ch03)。
14.1 什么是多租户
租户(Tenant):使用同一套 svc-spring-demo 部署的不同客户组织,如「贤紫旗舰店」「虚构商户 B」。
svc-spring-demo(单部署)
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
Tenant A Tenant B Tenant C
tenant_id=1 tenant_id=2 tenant_id=3
商品/订单隔离 商品/订单隔离 商品/订单隔离
| 需求 | 说明 |
|---|---|
| 数据隔离 | A 不能看到 B 的订单 |
| 配置隔离 | 各租户 Logo、费率可不同(选修) |
| 计费 | 按租户统计 API 调用量 |
| 合规 | 部分行业要求物理隔离 |
14.2 三种隔离模型
| 模型 | 结构 | 隔离性 | 成本 | 适用 |
|---|---|---|---|---|
| 共享表 | 每表加 tenant_id | 逻辑隔离 | 低 | MVP、租户量大 |
| 独立 Schema | 同实例不同 schema | 中 | 中 | 中型 SaaS |
| 独立库 | 每租户一 DB | 高 | 高 | 金融、政企 |
本章主练 共享表;ch12 毕业项目可扩展 tenant_id 列。
共享表(本章):
product_db.products (id, tenant_id, name, ...)
独立 Schema:
product_db.tenant_1.products
product_db.tenant_2.products
独立库:
product_db_tenant_1 / product_db_tenant_2
14.3 租户识别与 Gateway 入口
租户 ID 来源(教学优先级):
- JWT Claim
tenantId(与 ch10 结合,推荐) - 子域名
tenant-a.api.example.com - Header
X-Tenant-Id(仅内网服务间,外部须经 Gateway 校验)
Gateway 过滤器:
@Component
public class TenantResolveFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String tenantId = resolveFromJwt(exchange); // 或 subdomain
if (tenantId == null) {
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
return exchange.getResponse().setComplete();
}
// mutate 生成的是新对象,必须用 mutate 后的 exchange 继续过滤器链,否则 Header 不生效
ServerWebExchange mutated = exchange.mutate()
.request(r -> r.header("X-Tenant-Id", tenantId))
.build();
return chain.filter(mutated);
}
@Override public int getOrder() { return -90; }
}
安全:禁止客户端随意伪造 X-Tenant-Id;必须以 auth-svc 签发的 tenantId 为准。
14.4 TenantContext 线程传递
public final class TenantContext {
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
public static void set(String tenantId) { TENANT.set(tenantId); }
public static String get() { return TENANT.get(); }
public static void clear() { TENANT.remove(); }
}
Servlet 过滤器:
@Component
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String tenantId = req.getHeader("X-Tenant-Id");
if (tenantId == null || tenantId.isBlank()) {
res.sendError(HttpStatus.BAD_REQUEST.value(), "Missing tenant");
return;
}
try {
TenantContext.set(tenantId);
chain.doFilter(req, res);
} finally {
TenantContext.clear();
}
}
}
Feign 拦截器继续透传 X-Tenant-Id;BFF 异步线程池需 TTL 包装(TransmittableThreadLocal 或 Micrometer Context)。
14.5 JPA 行级隔离
14.5.1 实体基类
@MappedSuperclass
@EntityListeners(TenantEntityListener.class)
public abstract class TenantAwareEntity {
@Column(name = "tenant_id", nullable = false, updatable = false)
private String tenantId;
@PrePersist
void fillTenant() {
if (tenantId == null) {
tenantId = TenantContext.get();
}
}
// getter/setter
}
@Entity
@Table(name = "products", indexes = @Index(columnList = "tenant_id"))
public class Product extends TenantAwareEntity {
@Id @GeneratedValue private Long id;
private String name;
// ...
}