一个WHERE漏写100个客户数据全暴露:我用双保险方案彻底解决多租户串号

简介: 一次租户数据串号事故引出三种隔离模式对比,深入讲解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条件而已,不会忘的"。但系统大了、人多了、时间紧了,总有疏忽的时候。

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

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

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

相关文章
|
1天前
|
人工智能 JSON 安全
|
1天前
|
云安全 人工智能 安全
|
3天前
|
人工智能
Qwen3.8抢先体验!正式版即将发布并开源!
千问Qwen3.8即将开源,参数达2.4T,进化速度以“天”计,实力媲美Fable 5。预览版Qwen3.8-Max已上线阿里Token Plan等平台,限时优惠:日间Credits低至1折,夜间更优,个人/团队版月付仅35元起!
558 20
|
3天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
Qwen3.8-Max-Preview是通义千问Qwen3系列旗舰MoE大模型,参数达2.4万亿,综合推理能力居行业第一梯队。支持思考/快速双模式,擅长大模型五大高难场景。现于阿里云百炼Token Plan、Qoder及QoderWork上线体验,个人版低至39元/月。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
455 1
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
|
2天前
|
人工智能 测试技术 语音技术
Qwen-Audio-3.0-TTS 正式发布!AI 语音从 “能说话” 升级到 “会带情绪表达”
阿里云发布Qwen-Audio-3.0-TTS语音合成大模型,支持细粒度标签控制(如[gasp][angry])、freestyle自由风格、16种语言及20种方言,声学鲁棒性强。含Flash(首包延时300ms)和Plus(全球榜单冠军)双版本,已在百炼平台开放调用。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
485 0
|
9天前
|
缓存 UED 开发者
Codex109天重置23次,明天还要再送一次
Codex近109天完成23次额度重置,7月14日将迎来第24次。Tibo高频响应用户反馈:优化GPT-5.6高消耗问题、补发失效福利、调整重置时间——形成“反馈→回应→修复→补偿”正向闭环,彰显以用户为中心的产品哲学。(239字)
837 12
|
2天前
|
人工智能 自然语言处理 数据挖掘
最新版通义千问(Qwen3.8-Max-Preview)功能介绍
2026年,通义千问正式推出全新旗舰级大模型 **Qwen3.8-Max-Preview 预览版**,作为首款突破万亿参数规格的新一代基座模型,该模型总参数量达到**2.4万亿**,采用全新迭代的MoE混合专家架构,综合推理性能、长文本处理、多模态理解、复杂任务规划能力全面超越前代Qwen3.7-Max版本,整体实力跻身全球第一梯队,可对标海外顶级旗舰模型,是当前面向复杂工程开发、多智能体协同、超长文档解析、专业办公自动化场景的最优国产基座模型。
584 0
|
12天前
|
存储 人工智能 JSON
Qwen 本地部署搭配 ComfyUI 生成 AI 漫剧完整实操指南(小白零基础可落地,零成本无限生成+角色一致性天花板)
2026全网最优本地漫剧流水线:零成本、离线运行、角色统一、低配(8G显卡)可跑。融合Qwen本地大模型+ComfyUI双引擎,实现剧本生成→分镜绘图→动态成片全自动,隐私安全、无审核限流,新手30分钟上手,日更无忧。(239字)

热门文章

最新文章