大家好,我是数据库小学妹 👋
前段时间接手了一个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条件而已,不会忘的"。但系统大了、人多了、时间紧了,总有疏忽的时候。
数据库层面的隔离机制,就是防止这种疏忽的最后一道门。
朋友,你在多租户架构上踩过哪些坑?欢迎在评论区聊聊。
我是数据库小学妹,咱们下篇见 👋