SaaS多租户数据隔离+行级安全+中间件注入双保险实战指南

举报
数据库小学妹 发表于 2026/07/23 10:20:32 2026/07/23
【摘要】 一次租户数据串号事故引出三种隔离模式对比,深入讲解RLS行级安全、连接池session清理、中间件自动注入tenant_id双保险方案,附单租户迁移实战与避坑清单。

大家好,我是数据库小学妹 👋

前段时间接手了一个SaaS系统。客户越来越多,一百多个企业都在同一个库里跑。

有一天,一个客户发来截图,说他的数据看板里出现了别的公司的数据。我心里一紧,打开数据库查了查,发现确实是串了。原因是开发同学写报表查询的时候漏加了 WHERE tenant_id = ? 这个条件。没有租户过滤,查出来的就是全量数据,所有客户的信息一览无余。

还好发现得早,只泄露了一条。但这件事让我意识到一个问题:我们的租户隔离,完全靠开发自觉。SQL里写了tenant_id就能隔离,漏写了就串。这不是隔离,这是运气。

而且这种"运气"模式有个更可怕的特点:问题不会立刻暴露。漏写的SQL可能查了十次都恰好只返回当前租户的数据,第十一次才串。测试环境更难发现,因为测试数据量小,不同租户的数据混在一起看不出问题。

那天之后,我花了一周时间把多租户的隔离方案重新设计了一遍。今天把这些经验整理出来。

三种隔离模式:独立实例、独立库、共享库的取舍

多租户的数据隔离,有三种模式。

第一种是独立实例。每个租户一个独立的数据库实例。物理上完全隔离,A的数据和B的数据在不同的进程里,甚至不同的机器上。安全性最高,但成本也最高。一百个客户就要一百个实例,运维成本扛不住。

第二种是独立库。所有租户共用一个数据库实例,但每个租户一个独立的数据库(Schema)。逻辑上隔离,物理上共享。成本比独立实例低,但租户数量上千以后,Schema管理就成问题了。

第三种是共享库加tenant_id。所有租户共用同一张表,通过tenant_id字段区分。成本最低,扩展性最好,但安全性也最差。因为隔离完全依赖应用层的SQL,漏写条件就串。

我们原来用的就是第三种。成本是低了,但隔离性全靠人肉保障。

隔离模式 安全性 成本 扩展性 适用租户数
独立实例 最高 最高 <50
独立库 中等 中等 50-500
共享库+tenant_id 依赖应用 最低 500+

选哪种模式,取决于你的业务阶段和团队能力。初创期租户少,独立库方案最安全。到了几百上千租户,共享库方案的性价比最高,但必须在隔离机制上做足功课,不能只靠开发自觉。

Row-Level Security:让数据库帮你守门

我调研了一圈,发现数据库层面有一个现成的方案:行级安全(Row-Level Security,简称RLS)。RLS最早是PostgreSQL在9.0版本引入的,后来被很多数据库借鉴。它的核心思想是把访问控制的逻辑从应用层下沉到数据库引擎层。以前的权限控制只能精确到表级——这个用户能访问orders表,那个用户不能。RLS把精度提到了行级——你能访问orders表,但只能看到tenant_id等于你自己的那些行。

RLS的思路很直接:在数据库层面定义策略,自动给每条查询加上租户过滤条件。不管应用层的SQL怎么写,数据库都会自动过滤,只返回当前租户的数据。RLS的生效位置在查询执行器层,不是在SQL解析层。也就是说,它是在SQL已经解析完、生成执行计划之后,由执行器在真正读数据之前加上过滤条件。这意味着即使有人绕过应用层直接连数据库执行SQL,RLS照样生效。这是它比中间件拦截更可靠的地方。

-- 开启行级安全
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- 创建策略:当前会话的tenant_id只能看到自己的数据
CREATE POLICY tenant_isolation ON orders
    FOR ALL
    USING (tenant_id = current_setting('app.current_tenant_id')::BIGINT);

开启之后,任何对orders表的查询,都会自动加上 WHERE tenant_id =当前租户ID 的过滤。开发不需要在每条SQL里手动加条件,数据库帮你守门。

但这里有一个关键前提:current_setting('app.current_tenant_id') 这个值必须在每次请求开始时正确设置。怎么设?在连接池获取连接之后,执行一条SET命令。

-- 应用在获取连接后立即设置
SET app.current_tenant_id = '1001';

这个SET是session级别的。也就是说,每个连接在使用前都要设置一次。

连接池的session清理:一个容易忽略的坑

说到连接池,这里有一个我踩过的坑。

连接池为了性能,会复用连接。一个连接被A请求用完之后,不会关闭,而是放回池里给下一个请求用。问题是,上一个请求设置的session变量还在。

如果A请求设了 app.current_tenant_id = 1001,这个连接被B请求拿到后,如果没有重新设置,B看到的还是1001的数据。如果B是1002号租户,就串了。

更隐蔽的情况是:A请求设了变量,B请求拿到了连接,B设了自己的变量。但C请求又拿到了同一个连接,C没有设变量。C就继承了B的tenant_id。解决方案是在连接池配置里加上连接归还时的清理逻辑。我试过两种解法:第一种是配置连接归还时的重置SQL,在连接还回池里之前执行;第二种是不信任连接状态,每次借出时强制重置。最终选了第二种,因为第一种依赖连接池框架支持,不是所有连接池都提供归还时的回调。而且即使配了归还回调,如果连接异常断开(比如超时被服务端踢掉),回调也执行不了。

-- HikariCP不支持归还时执行SQL
-- 所以在获取连接时重置
-- 应用层代码(Java示例)
Connection conn = dataSource.getConnection();
conn.createStatement().execute(
    "SET app.current_tenant_id = '" + currentTenantId + "'"
);

更稳妥的做法是每次获取连接都重置,不依赖上一次的状态。宁可多执行一条SET,也不要假设连接是干净的。

我在代码审查时专门加了一条规则:所有数据库操作,必须在获取连接之后立即设置tenant_id。用公共方法封装,不允许在业务代码里直接调getConnection。

中间件自动注入:让tenant_id无需手动编写

RLS解决了"漏写WHERE"的问题。但还有一个问题:每个请求都要手动设置tenant_id。有没有更优雅的方式?有的。在应用层的数据库访问中间件里自动注入。

以MyBatis为例,写一个拦截器,在每条SQL执行之前,自动注入tenant_id条件。

@Intercepts({
    @Signature(type = StatementHandler.class, 
               method = "prepare", 
               args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = handler.getBoundSql();
        String originalSql = boundSql.getSql();
        
        // 自动加上 tenant_id 条件
        String newSql = addTenantCondition(originalSql, getCurrentTenantId());
        // 反射替换SQL
        Field field = boundSql.getClass().getDeclaredField("sql");
        field.setAccessible(true);
        field.set(boundSql, newSql);
        
        return invocation.proceed();
    }
}

这种方式的好处是开发者不需要关心租户隔离,中间件自动处理。缺点是只能处理简单的SELECT和UPDATE,复杂的子查询、JOIN、UNION可能注入失败。我遇到过一个坑:一条包含子查询的SQL,拦截器在子查询的FROM子句里也注入了tenant_id条件,结果子查询本意是要查全量数据做参照,注入之后逻辑就错了。后来我在拦截器里加了白名单,哪些表需要注入、哪些不需要,都要显式配置。

我最后的做法是双保险:中间件自动注入加RLS兜底。中间件处理了95%的情况,剩下5%的复杂SQL靠RLS兜底。两层保护,任何一层漏了都不影响安全性。上线之后跑了半年,没有再出现过数据串号的问题。

单租户迁移实战:不停机给现有数据加上tenant_id

还有一种场景:系统一开始是单租户的,后来业务发展需要改成多租户。怎么在不停机的情况下,给现有数据加上tenant_id?

我的做法分三步。每一步都在预发环境跑过,确认没问题才上生产。

第一步,给所有核心表加上tenant_id字段。这一步不能用INSTANT算法,因为tenant_id需要设默认值。大表加字段要走在线DDL工具。

-- 用gh-ost在线加字段
ALTER TABLE orders ADD COLUMN tenant_id BIGINT NOT NULL DEFAULT 0;

第二步,按业务规则回填tenant_id。比如根据订单关联的用户表来确定每条订单属于哪个租户。这个过程要分批执行,不能一次更新全表。我用的是带游标的分批更新,每批一万条,批次之间sleep两百毫秒,避免把主库打满。回填期间业务正常运行,新写入的数据在应用层已经带了tenant_id,不会受影响。

-- 分批回填,每批一万条
UPDATE orders o
JOIN users u ON o.user_id = u.id
SET o.tenant_id = u.tenant_id
WHERE o.tenant_id = 0
LIMIT 10000;

第三步,加上联合索引和RLS策略。tenant_id必须是联合索引的第一列,否则查询还是会全表扫描。联合索引的列顺序很关键:tenant_id在最前面,后续的列按查询频率排序。我们最常见的查询是"某个租户最近的订单",所以索引建的是 (tenant_id, created_at)

ALTER TABLE orders ADD INDEX idx_tenant_order 
    (tenant_id, created_at);

整个迁移过程持续了一周。白天正常业务,晚上跑回填脚本。每一步都在预发环境验证过,确保不会影响线上服务。

租户级资源隔离:防止邻居噪音拖垮整个系统

数据隔离只是第一步,还要考虑资源隔离。如果某个租户跑了一条慢查询,把CPU打满了,其他租户的查询也跟着慢。这叫"邻居噪音"问题。MySQL本身没有原生的资源隔离机制,但可以通过连接池和限流来实现。

每个租户分配独立的连接池。连接池的大小根据租户的套餐等级来定。基础版10个连接,高级版50个连接。某个租户的连接池满了,只影响它自己,不影响别人。在中间件层面做SQL限流。每条SQL的执行时间超过阈值就kill掉。某个租户的慢查询不会拖垮整个系统。我们在中间件里加了两层防护:第一层是单条SQL超时kill,设的是30秒;第二层是租户级并发限制,某个租户同时在跑的SQL超过上限就排队等待。两层配合,基本杜绝了邻居噪音问题。

避坑清单:多租户隔离的四条底线

第一,不要只靠应用层的WHERE条件做租户隔离。人会犯错,代码会改,但数据库的RLS策略不会忘。用双保险:中间件自动注入加RLS兜底,任何一层漏了都不影响安全性。

第二,连接池的session变量必须每次重置。别假设连接是干净的。获取连接之后立即设置tenant_id,用公共方法封装。宁可多执行一条SET,也不要冒串数据的风险。

第三,tenant_id必须在联合索引的第一列。别的列再怎么加索引,没有tenant_id在前面,查询还是会全表扫描。这是性能的底线。

第四,迁移过程中分批执行,每批都有回滚方案。给现有数据加tenant_id是高风险操作。分批跑,每批一万条,出问题立即回滚。别想着一口气跑完。


那次客户数据串号的事故之后,我把整个系统的租户隔离重新设计了一遍。

现在回想起来,问题的根源不是技术不到位,是意识不到位。大家都觉得"加个WHERE条件而已,不会忘的"。但系统大了、人多了、时间紧了,总有疏忽的时候。

数据库层面的隔离机制,就是防止这种疏忽的最后一道门。

朋友,你在多租户架构上踩过哪些坑?欢迎在评论区聊聊。

我是数据库小学妹,咱们下篇见 👋

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。