隔离模型的决定因素是成本与合规
多租户的第一道选择题不是技术,而是“每个租户的数据放在哪里”。三种主流模型各有明确的代价:
| 模型 | 隔离强度 | 成本 | 适用 |
|---|---|---|---|
| 每租户独立库 | 最强,故障与备份完全隔离 | 最高,几百个租户就是几百套库 | 大客户、金融、有合规要求 |
| 每租户独立 schema | 较强,共享实例 | 中等,连接数与迁移管理变复杂 | 中等规模 SaaS |
共享表 + tenant_id |
最弱,靠代码保证 | 最低,扩容简单 | 大量中小租户 |
真实系统通常是混合的:头部客户独立库,长尾客户共享表。混合不是妥协,而是把隔离成本花在真正在意隔离的人身上。
选共享表就意味着一件事:任何一条 SQL 漏掉 tenant_id,都是跨租户数据泄露。所以后面的所有设计都围绕“如何让漏写在编译或测试阶段暴露”。
租户上下文从哪来,到哪去
tenant_id 绝对不能直接信任前端传的请求头。正确链路是:前端只传用户令牌,网关从令牌声明里取出租户,剥离客户端自带的 X-Tenant-Id,再重新注入。如果用户可以在多个租户间切换,切换时还要校验这个用户确实属于目标租户。
服务内部用线程上下文保存当前租户,用完必须清理,否则线程池复用会把上一个请求的租户带到下一个请求——这是多租户系统里最隐蔽、后果最严重的一类 Bug。
public final class TenantContext {
private static final ThreadLocal<Long> HOLDER = new ThreadLocal<>();
private TenantContext() {
}
public static void set(Long tenantId) {
HOLDER.set(Objects.requireNonNull(tenantId, "tenantId 不能为空"));
}
public static Long get() {
return HOLDER.get();
}
public static Long require() {
Long tenantId = HOLDER.get();
if (tenantId == null) {
throw new IllegalStateException("当前线程没有租户上下文");
}
return tenantId;
}
public static void clear() {
HOLDER.remove();
}
}
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("X-Tenant-Id");
if (header == null || header.isBlank()) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "缺少租户标识");
return;
}
TenantContext.set(Long.valueOf(header));
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}
finally 里的 clear() 不是可选项。Filter 是最后一道保险,业务代码里再怎么写都可能漏,这一层不能漏。
用拦截器把租户条件写进 SQL
手写 where tenant_id = ? 的结局一定是漏。可行的做法是让持久层框架自动加条件,MyBatis-Plus 的 TenantLineInnerInterceptor 就是干这个的:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
return new LongValue(TenantContext.require());
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
return GLOBAL_TABLES.contains(tableName);
}
}));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
全局表(字典、国家、币种、系统配置)必须列进白名单,否则会被加上不存在的 tenant_id 列而报错。白名单要显式维护,不要用表名前缀模糊匹配,那等于给未来埋雷。
缓存、异步与消息的租户归属
数据库加了条件,缓存没加,等于白做。所有缓存 key 都必须带租户前缀,tenant:{id}:order:{orderId} 这种格式比 order:{orderId} 安全一个数量级。
异步和消息是第二大漏洞来源:线程池里的任务拿不到请求线程的 ThreadLocal,定时任务默认没有租户概念,MQ 消费端如果消息体没带 tenantId,消费时只能猜。线程池要加 TaskDecorator 传递上下文:
public class TenantTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Long tenantId = TenantContext.get();
return () -> {
if (tenantId != null) {
TenantContext.set(tenantId);
}
try {
runnable.run();
} finally {
TenantContext.clear();
}
};
}
}
MQ 消息体里带 tenantId 是硬要求,消费端必须在处理前显式设置上下文,处理完清掉。定时任务则要遍历租户列表,逐个设置上下文执行,并在每个租户之间清理,避免串用。
开通与退出租户
租户的开通要有幂等:同一个租户重复提交不能建出两套库或两份初始数据。退出租户比开通更难:数据要保留一段时间(合规要求),期间允许恢复;到期后做逻辑删除,再异步清理物理数据,并且清理任务本身要能续跑和审计。
租户级的限流和配额也要提前设计。一个租户的批量导入把共享数据库打满,会拖垮所有租户,这是共享模型最大的风险,靠“事后道歉”解决不了。
常见坑
信任客户端传的租户头。 改一下请求头就能看到别家数据,这是多租户系统最严重也最常见的漏洞。
唯一索引不带 tenant_id。 两个租户用同一个订单号或商品编码,第二个租户直接插入失败,或者更糟——查到了别人的记录。
异步任务丢上下文。 日志里突然出现空租户或者别的租户 ID,多半是线程池复用没清理。
迁移脚本漏表。 新增一张表忘记加 tenant_id,拦截器照样加条件,SQL 直接报错;如果恰好被加进白名单,就变成跨租户可见。
备份与恢复按库做。 共享表模型下,恢复单个租户的数据需要按条件导出,不能直接回滚整个库,这个能力要在设计阶段就准备好。
评论