多租户系统上线前,最容易被拍脑袋决定、也最没法轻易返工的一件事,就是隔离做到哪一层。
做浅了,A 租户能读到 B 租户的数据------这是事故,不是 bug。做深了,每个租户一套库,运维和成本直接爆炸,三个月后你自己都不想碰。
这道题没有标准答案。但每一层"长什么样、由谁来保证、漏了会怎样",是可以说清楚的。下面从最底层的数据库,一直聊到最上层的 ORM(对象关系映射,Object-Relational Mapping,把数据库行映射成程序对象的那层)。
一、四种隔离层级:从最狠到最省
先把谱系摆出来。按"隔离强度从高到低"排:
| 层级 | 做法 | 一句话画像 |
|---|---|---|
| L1 独立数据库 | 每个租户一套独立库(甚至独立实例) | 物理隔离,最稳最贵 |
| L2 独立 Schema | 同一实例,每租户一个 Schema(数据库模式/命名空间) | 逻辑隔离,共享引擎 |
| L3 共享库共享表 + TenantId 列 | 同一张表,每行带 TenantId(租户标识列)区分归属 |
最省资源,全靠代码守 |
| L4 共享表 + 行级安全(RLS) | L3 基础上把过滤下沉到数据库 RLS(Row-Level Security,行级安全,由数据库按策略自动过滤行) | 代码少漏,库来兜底 |
关键认知:越往下越安全、越易证明"没串数据",但资源和维护成本越高;越往上越省,但"不串数据"这件事完全压在你的代码自觉上。
L1/L2 的隔离是数据库结构本身保证的------A 库根本看不到 B 库的数据,你不用写任何过滤逻辑。L3/L4 则反过来:数据全挤在一张表里,任何一次查询忘了带 TenantId,就可能把别家的行捞出来。
二、从连接池到 ORM:保证不串数据的三道关
选了 L3/L4(绝大多数中台、企业系统都是这条线,因为要省钱、要能动态开租户),"不串数据"就不是数据库替你兜底了,得你自己在上游三层逐个把住。
第 1 关:连接池(Connection Pool)。
连接池管的是"应用和数据库之间的那堆长连接"。这里最容易出的问题是连接上下文污染:
- 如果你的
TenantId是靠"设置会话变量"(比如 PostgreSQL 的SET app.current_tenant = ...,或 SQL Server 的SET CONTEXT_INFO)来传递的,而连接是从池里复用的------那么上一次请求设的租户,可能残留到下一次请求上。 - 后果:要么是串数据,要么是"明明是我租户的,却查不到"。
正确做法只有两个方向:要么每次从池取连接后立刻重置会话变量 (在拿到连接的第一时间 SET 当前请求的真实 TenantId);要么干脆不让租户走会话变量,而是把 TenantId 作为查询参数拼进每一条 SQL。后者最笨但最稳。
第 2 关:ORM 全局过滤(Global Filter)。
ORM(比如 EF Core、SqlSugar、MyBatis-Plus)普遍支持"全局过滤器":给某张表注册一条规则"永远只查 TenantId = 当前租户 的行",之后所有查询自动带上,不用你每次手写。
这是 L3 不串数据的主力防线,但它有三个坑:
- 原生 SQL / 存储过程绕过它 :你手写了一句
SELECT * FROM orders,没带TenantId,全局过滤管不到------老系统里这种裸 SQL 一大把。 - 批量操作(INSERT/UPDATE 大批量)有时不吃全局过滤,需要单独确认。
- 忘了在插入时也写
TenantId:查询过滤得再严,写入时没落列,这条数据就"无主"了,谁都查不到,或者谁都查到。
第 3 关:数据库行级安全(RLS,仅 L4)。
把过滤规则写成数据库策略(policy),任何连接、任何 SQL 走这张表都自动按当前会话的租户身份过滤,连裸 SQL 和存储过程都逃不掉 。这是把"不串数据"从"靠人记得写"升级成"靠库强制"。代价是:策略写错会全表不可见,排查也多一层;且不等于你就不用在应用层写 TenantId 了,RLS 和 ORM 过滤是互补,不是替代。
三、性能 vs 安全的决策表(可带走)
把这四层 + 三道关,压成一张你做架构评审时直接用的表:
| 维度 | L1 独立库 | L2 独立 Schema | L3 共享表+TenantId | L4 L3+RLS |
|---|---|---|---|---|
| 数据安全 | 最强(物理隔离) | 强(结构隔离) | 依赖代码 | 库强制兜底 |
| 资源成本 | 最高 | 高 | 最低 | 低(+策略开销) |
| 开新租户速度 | 慢(建库/建实例) | 中(建 Schema) | 最快(插一行) | 最快 |
| 跨租户统计/汇总 | 最麻烦(要跨库) | 麻烦 | 最易(一句 SQL) | 最易 |
| 不串数据靠谁 | 数据库结构 | 数据库结构 | 连接池+ORM+写代码 | 数据库 RLS + 应用 |
| 适合场景 | 大客户/强合规 | 中大型、要隔离又要省 | 海量小租户 SaaS | 海量小租户且怕代码漏 |
决策口诀(给你做技术评审时一句话带走):
合规和客单价高的,往 L1/L2 靠;要动态开海量小租户、又想汇总方便的,选 L3,但必须把 ORM 全局过滤 + 连接池会话重置 + 写入必带 TenantId 三件事做到位;如果你家代码裸 SQL 多、总有人漏写条件,就上 L4 让数据库兜底。
四、我的真实取舍:选了 L3 + RBAC 三级
上面是方法论。落到自己手上的系统,选择才是读者最重要的------因为"别人怎么权衡"比"理论有几层"有用十倍。
先给结论,再讲为什么。
我的系统落地的就是 L3:共享库、共享表、每行带 TenantId(租户标识列)区分归属;在这之上再叠一套 RBAC 三级权限(菜单级 / 按钮级 / 数据级)做细粒度控制。
这个选择和手上另一个企业系统项目完全一致------它也是「共享 DB + TenantId 列 + RBAC 三级权限」的路线。换句话说,两个项目、不同的业务域,最终都收敛到同一条取舍上 :不靠独立库去做物理隔离,而是靠代码层把 TenantId 守死,再用 RBAC 把"能看到哪些菜单、能点哪些按钮、能读哪些数据行"收口。
为什么收敛到 L3 而不是 L1/L2?两个项目都是要动态开海量租户、且经常要跨租户汇总 (运营后台看全平台、集团看各子公司),L1/L2 在"开租户速度"和"跨租户统计"上会立刻变疼。RBAC 三级则是 L3 的必选项------光有 TenantId 只解决"不串数据",解决不了"同一个租户里不同角色看不同东西",所以两级要一起上。
下面两样,是我从自己手上的实现里抽出来的真实片段(下面每一行都来自项目源码,只抹了业务表名和 IP)。
关键落地片段:TenantId 是怎么注进每一条查询的
我走的是ORM 全局过滤这条,不用连接池会话变量、也没上 RLS。原因是:全局过滤对业务代码零侵入,写查询的人根本不用记"要带租户条件"------框架替你带。
我的框架是 .NET10,核心就三块,我贴真实的:
① 租户上下文(请求级,缺失就抛错,绝不静默回落到宿主租户):
csharp
// CurrentTenant:scoped,一次请求一个实例
public sealed class CurrentTenant : ITenantContext
{
public Guid TenantId { get; private set; }
public bool HasTenant { get; private set; }
public void Set(Guid tenantId)
{
TenantId = tenantId;
HasTenant = true;
}
// 缺失时业务查询直接 400,不静默回落宿主租户
public Guid RequireTenant()
{
if (!HasTenant)
throw new AstraException(AstraErrors.TenantContextMissing);
return TenantId;
}
}
② 解析链(JWT claim → 子域名 → Header X-Tenant-Id → 宿主租户),放在 Web 中间件最前面,认证过的请求以 token 里的 tenant_id 为准,客户端头不能越权覆盖:
csharp
public async Task InvokeAsync(HttpContext context, CurrentTenant tenant)
{
// 1) 已认证请求:以 JWT 的 tenant_id claim 为准(最高优先级)
if (context.User.Identity?.IsAuthenticated == true)
{
var claim = context.User.FindFirst(AstraClaims.TenantId);
if (claim is not null && Guid.TryParse(claim.Value, out var fromToken))
{
tenant.Set(fromToken);
await next(context);
return;
}
}
// 2) Header X-Tenant-Id → 3) 子域名 {tenant}.app.example.com → 4) 宿主租户回落
// ......(省略降级分支,语义同上)
tenant.Set(tenantId);
await next(context);
}
③ 全局过滤器(关键!):在 DbContext 里给所有业务实体自动追加 tenant_id = 当前租户,写入和查询都走它:
csharp
private void ApplyGlobalConventions(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
// 平台表(共享 outbox、租户注册表自身)退出租户过滤
if (typeof(IIgnoreTenantFilter).IsAssignableFrom(entityType.ClrType))
continue;
if (typeof(AstraEntity).IsAssignableFrom(entityType.ClrType))
{
// 全局租户过滤器:引用上下文实例属性,而非局部常量
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, nameof(AstraEntity.TenantId));
var filterValue = Expression.Property(Expression.Constant(this), nameof(FilterTenantId));
var body = Expression.Equal(property, filterValue);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(
Expression.Lambda(body, parameter));
}
}
}
// 必须引用实例属性,不能把首次请求的租户烧进表达式------
// 否则模型只建一次,常量快照会让所有后续请求都过滤成空集(踩过坑)
public Guid FilterTenantId => _tenant.HasTenant ? _tenant.TenantId : Guid.Empty;
业务实体基类统一带这列:
csharp
public abstract class AstraEntity
{
public Guid Id { get; set; } // UUIDv7 主键
public Guid TenantId { get; set; } // 租户标识列,全局过滤就靠它
public DateTimeOffset CreatedAt { get; set; }
public Guid? CreatedBy { get; set; }
// ...... 乐观锁 version / 软删除 is_deleted 等
}
同一套契约,.NET 和 Java 两端一致。 我这套框架是双后端(.NET 10 + Java 25/Spring Boot 4),Java 侧用 MyBatis-Plus 的 TenantLineHandler 做同一件事------AstraTenantLineHandler.getTenantId() 返回 TenantContext.require(),在 MybatisPlusConfig 里把 TenantLineInnerInterceptor 注册成第一个 拦截器(保证分页 count 也被过滤),TenantContext 用 ThreadLocal 存、请求结束在 finally 里 clear() 防线程复用串租户。两端列名、错误码、解析链节奏全部对齐。
一个你写评审时能用上的细节:哪些表不隔离 。共享事件表(outbox)、审计日志、登录日志、租户注册表自身、Flyway 历史表要显式退出全局过滤------这批表要么由写入方自己携带 tenant_id,要么压根没有这列。新增豁免必须登记,禁止为了"绕过麻烦"豁免业务表。
当时被什么逼的:动态开海量租户 + 经常跨租户汇总
选型不是拍脑袋,是被两个真实诉求钉死的:
- 要动态开海量租户。 我们的场景是运营后台要随时给新客户/子公司开一个租户,量级是"海量小租户"。L3 开一个租户 = 在
sys_tenant插一行 + 配置 RBAC,秒级完成;如果是 L1/L2(独立库或独立 Schema),开一个租户就得建库/建 Schema、跑迁移、配连接,开租户速度立刻变疼。 - 经常要跨租户汇总。 集团要看各子公司、运营要看全平台,这种"跨租户统计"在 L3 是一张表
GROUP BY tenant_id就完事;L1/L2 数据散在 N 个库,汇总得跨库联邦查询或 ETL,又是一层疼。
我们当时定的原则很务实:便宜且不可逆的部分 P0 前置 ------所有表带 tenant_id + 租户上下文对象 + 全局查询过滤器,这道关先焊死;贵的部分(独立库/独立 Schema 的运行时切换)后置,接口预留。也就是说 L3 是现在就落地的实线,L1/L2 是将来真有客户要物理隔离时,从预留接口升级的虚线。不是不做隔离,是把"不可逆的隔离"先做,"贵的隔离"等真的有人要为它付钱时再做。
五、最容易踩的五个坑
不管你选哪档,这几条是 L3/L4 路线高频翻车点,你做评审时逐条对照:
- TenantId 漏注入:某条查询忘带条件,A 租户看到 B 租户数据。全局过滤能挡一部分,但裸 SQL 和批量操作挡不住。
- 写入不落 TenantId:查询过滤得再严,插入时没写列,数据变"无主"。
- 连接池会话残留:用会话变量传租户,连接复用导致串租户(见第二节第 1 关)。
- 批量/原生 SQL 绕过 ORM 过滤 :
BulkInsert、ExecuteSqlRaw这类不走全局过滤,需要单独加条件。 - 跨租户统计忘记隔离:运营后台跑"全平台汇总",SQL 没带租户条件,把别家数据并进去了------这类往往不报错,只是数错了。
六、可带走:《防串数据检查清单》
把上面五条 + 三道关,压成一张你每次发版前能过一遍的清单:
| 检查项 | 怎么做 | 漏了会怎样 |
|---|---|---|
| 读取是否自动带 TenantId | ORM 全局过滤 + 抽样看生成的 SQL | A 看到 B 的数据 |
| 原生 SQL / 存储过程 | 逐条人工确认带条件 | 过滤被绕过 |
| 批量写 / 批量读 | 确认不吃全局过滤,单独加条件 | 漏数据或串数据 |
| 写入必落 TenantId 列 | 入库前断言非空 | 数据无主 |
| 连接池会话变量 | 取连接即重置,或改走查询参数 | 租户串号 |
| 跨租户汇总 SQL | 显式隔离或单独授权账号 | 汇总数错 |
| RLS 策略(若用 L4) | 策略存在且覆盖所有访问路径 | 库兜底失效 |
这张表建议直接钉进你们的多租户开发规范里------它比"大家写代码注意点"管用一百倍,因为它能逐条勾。
多租户隔离这东西,难不在"会不会做",在"敢不敢在架构评审时把隔离层级拍死、并把三道关的责任分到具体代码位置"。
选浅了是生产事故,选深了是运维地狱。中间那条线画在哪,不靠教科书,靠你手上那个系统的客单价、合规要求和开租户的速度要求。