多租户隔离该做到哪一层:从连接池到 ORM 的取舍

多租户系统上线前,最容易被拍脑袋决定、也最没法轻易返工的一件事,就是隔离做到哪一层

做浅了,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 不串数据的主力防线,但它有三个坑:

  1. 原生 SQL / 存储过程绕过它 :你手写了一句 SELECT * FROM orders,没带 TenantId,全局过滤管不到------老系统里这种裸 SQL 一大把。
  2. 批量操作(INSERT/UPDATE 大批量)有时不吃全局过滤,需要单独确认。
  3. 忘了在插入时也写 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 也被过滤),TenantContextThreadLocal 存、请求结束在 finallyclear() 防线程复用串租户。两端列名、错误码、解析链节奏全部对齐。

一个你写评审时能用上的细节:哪些表不隔离 。共享事件表(outbox)、审计日志、登录日志、租户注册表自身、Flyway 历史表要显式退出全局过滤------这批表要么由写入方自己携带 tenant_id,要么压根没有这列。新增豁免必须登记,禁止为了"绕过麻烦"豁免业务表。

当时被什么逼的:动态开海量租户 + 经常跨租户汇总

选型不是拍脑袋,是被两个真实诉求钉死的:

  1. 要动态开海量租户。 我们的场景是运营后台要随时给新客户/子公司开一个租户,量级是"海量小租户"。L3 开一个租户 = 在 sys_tenant 插一行 + 配置 RBAC,秒级完成;如果是 L1/L2(独立库或独立 Schema),开一个租户就得建库/建 Schema、跑迁移、配连接,开租户速度立刻变疼
  2. 经常要跨租户汇总。 集团要看各子公司、运营要看全平台,这种"跨租户统计"在 L3 是一张表 GROUP BY tenant_id 就完事;L1/L2 数据散在 N 个库,汇总得跨库联邦查询或 ETL,又是一层疼。

我们当时定的原则很务实:便宜且不可逆的部分 P0 前置 ------所有表带 tenant_id + 租户上下文对象 + 全局查询过滤器,这道关先焊死;贵的部分(独立库/独立 Schema 的运行时切换)后置,接口预留。也就是说 L3 是现在就落地的实线,L1/L2 是将来真有客户要物理隔离时,从预留接口升级的虚线。不是不做隔离,是把"不可逆的隔离"先做,"贵的隔离"等真的有人要为它付钱时再做。


五、最容易踩的五个坑

不管你选哪档,这几条是 L3/L4 路线高频翻车点,你做评审时逐条对照:

  1. TenantId 漏注入:某条查询忘带条件,A 租户看到 B 租户数据。全局过滤能挡一部分,但裸 SQL 和批量操作挡不住。
  2. 写入不落 TenantId:查询过滤得再严,插入时没写列,数据变"无主"。
  3. 连接池会话残留:用会话变量传租户,连接复用导致串租户(见第二节第 1 关)。
  4. 批量/原生 SQL 绕过 ORM 过滤BulkInsertExecuteSqlRaw 这类不走全局过滤,需要单独加条件。
  5. 跨租户统计忘记隔离:运营后台跑"全平台汇总",SQL 没带租户条件,把别家数据并进去了------这类往往不报错,只是数错了。

六、可带走:《防串数据检查清单》

把上面五条 + 三道关,压成一张你每次发版前能过一遍的清单:

检查项 怎么做 漏了会怎样
读取是否自动带 TenantId ORM 全局过滤 + 抽样看生成的 SQL A 看到 B 的数据
原生 SQL / 存储过程 逐条人工确认带条件 过滤被绕过
批量写 / 批量读 确认不吃全局过滤,单独加条件 漏数据或串数据
写入必落 TenantId 列 入库前断言非空 数据无主
连接池会话变量 取连接即重置,或改走查询参数 租户串号
跨租户汇总 SQL 显式隔离或单独授权账号 汇总数错
RLS 策略(若用 L4) 策略存在且覆盖所有访问路径 库兜底失效

这张表建议直接钉进你们的多租户开发规范里------它比"大家写代码注意点"管用一百倍,因为它能逐条勾。


多租户隔离这东西,难不在"会不会做",在"敢不敢在架构评审时把隔离层级拍死、并把三道关的责任分到具体代码位置"。

选浅了是生产事故,选深了是运维地狱。中间那条线画在哪,不靠教科书,靠你手上那个系统的客单价、合规要求和开租户的速度要求。

相关推荐
名字还没想好☜1 小时前
Python 用 tempfile 安全创建临时文件:NamedTemporaryFile、TemporaryDirectory 与别自己拼 /tmp 的坑
开发语言·后端·python·安全·编程语言
lvshuocool1 小时前
golang 多版本管理工具 -- g
开发语言·后端·golang
代码调试师2 小时前
【毕设分享】springboot攀枝花生鲜电商平台58990
java·vue.js·spring boot·后端·架构·eclipse·课程设计
洛阳泰山3 小时前
别卷 Python 了:我用 Java 21 + Spring Boot 3 打造了一个企业级 RAG + 智能体工作流引擎(附架构与源码解析)
java·spring boot·后端
海盗12344 小时前
DSH 接入 OpenCode Go 报 400 MissingSessionID:从 LLM 语义层退到 fetch 传输层的完整排障
开发语言·后端·golang
一木 之林4 小时前
第 3 节 在 AI 行业中,C/C++ 编程中的动态库和静态库
c语言·c++·后端
skywalkerch4 小时前
Spring 循环依赖与三级缓存
后端
萧瑟余晖5 小时前
Hibernate 实体映射与关联关系详解
后端·hibernate
stars3695 小时前
实验四 JSP内置对象的应用
后端