3226 字
约 10 分钟
17
S12-A11 · 多租户的数据隔离:共享、独占与混合的账

一个大客户在合同评审里加了一条:数据必须物理隔离。这一条会把架构翻掉一半——共享表加租户列跑得好好的系统,要么改成独立库,要么临时造一套只服务他的部署。真正难的不是改,是判断哪一档隔离是这个客户真正在买的,以及长尾租户要不要跟着一起付这笔钱。


1. 三种隔离模型与各自的成本结构

模型 结构 一次性成本 持续成本 失效边界
共享表 + 租户列 一张表,tenant_id 区分 最低 查询规范靠纪律,越权靠检测 任何一处漏写条件即越权
共享库 + 独立 schema 一库多 schema 中(迁移与 DDL 分发) 连接路由、备份按 schema 拆 schema 上千后 DDL 变运维事故
独立库 / 实例 一租户一套资源 最高(资源与编排) 资源闲置、版本漂移、监控翻倍 租户数一多,单位成本压不下来

反直觉的是:隔离级别越高,运维复杂度增长越快于安全收益增长。共享表到独立 schema 解决误删与恢复粒度;再到独立实例,解决的是资源争抢与合规叙事,误写越权在前一档已基本消失。

2. "要求隔离"通常不是要求物理独立

客户/合规话术 工程含义 物理独立的必要性
"数据不能和别家混在一起" 逻辑隔离 + 访问审计 + 越权可检测 低,除非合同明文写死
"要能证明只有我们的人能访问" 身份链路 + 审计留存 + 权限评审记录 低
"删除时要能彻底删除" 删除的可验证性(含备份与副本) 中:备份策略决定难度
"我们的数据不能出境" 地域约束,落在存储与备份位置 高,但与租户隔离无关

判断方法很朴素:把客户原话翻译成「他要向谁证明什么」。要证明「访问被记录过」,建审计与检测;要证明「服务器上没有别人」,才是物理独立。

3. 共享模型的三个必要条件

条件 检查方式 不满足的后果
租户列不可遗漏 建表规约 + CI 静态检查 新表上线第一天就有越权面
查询必须带租户条件 执行层强制注入,不依赖开发者 一次手写 SQL 就泄露
越权可被检测 双租户对比探针 + 告警 泄露无声,等客户发现

三个条件里只有第二个能工程化强制。所以共享表的事故几乎都发生在新增代码:存量路径被反复验证过,新增路径没人写测试。

4. 行级安全的实现位置与绕过风险

位置 做法 绕过风险 适用
应用层手写 每条 SQL 自己加条件 最高:新代码最容易漏 租户数极少时
ORM / 拦截器 统一拦截 SQL 注入条件 中:原生 SQL、批处理、动态表名可绕过 多数业务系统
数据库策略 行级安全策略(RLS) 低:需连接级会话变量 有 DBA 且库能力支持
视图隔离 每租户一套视图 低但视图数量爆炸 租户数十级

最稳的是数据库策略加会话变量,代价是每个连接借出时都要设置租户上下文:

-- 行级安全策略示意,语法按所部署数据库版本核对
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::text);

坑在会话变量:每个连接借出时必须执行一次 SET LOCAL app.tenant_id,且要在同一事务内。用 SET 而不是 SET LOCAL 会把租户留在连接上,下一个请求借到同一条连接就继承了别人的身份。

5. "忘记加租户条件"的四道防线

// 四道防线,从便宜到贵,通常只需前两道 + 第三道抽样
─── 1) 静态检查 ─── CI 扫 SQL 与 Mapper,缺 tenant_id 条件即失败
─── 2) 执行期注入 ─── 拦截器统一补条件,开发者无法"忘记"
─── 3) 双租户探针 ─── 用 A 的身份查 B 的主键,期望 404 而非 200
─── 4) 数据侧对账 ─── 定时比对跨租户引用,发现脏数据

第 3 道最容易被写成"有测试但测不出问题":探针必须用另一租户的真实主键。用不存在的 ID 只能证明查不到,不能证明查不到别人的。

// 探针判定口径:404 或空结果算通过,200 且有内容算泄露
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $TOKEN_TENANT_A" \
  "https://APP_URL/api/orders/ORDER_ID_OF_TENANT_B"

6. 噪声邻居与配额设计

配额不是限流的美化说法,它是隔离级别在资源维度的替代品。

资源 隔离手段 代价 失效点
数据库连接 每租户连接上限 上限之和须小于池容量 上限设成池大小即失效
慢查询 语句超时 + 队列优先级 需能识别租户 批量任务仍会占满 IO
缓存 租户前缀 + 容量配额 key 数量上升 无淘汰策略时内存被单租户吃掉
异步任务 每租户并发槽位 调度复杂度上升 槽位泄漏
// 租户配额示例,数值按压测结果定,不要照抄
tenant_quota:
  db_connections: 8        // 所有租户之和 < 池 max_size 的 80%
  stmt_timeout_ms: 3000
  async_slots: 4
  burst: 1.5               // 允许短时超发,靠队列吸收

槽位泄漏是最常见的缺陷:任务异常退出没释放槽位,几天后该租户的异步能力就归零。兜底是给槽位设 TTL 并定期对账。

7. 迁移路径:从共享到独立容易,反过来很难

方向 主要动作 不可逆点 常见阻力
共享表 → 独立 schema 数据搬迁 + 连接路由 + DDL 分发 跨租户统计查询要重写 报表与运营后台
独立 schema → 独立实例 资源编排 + 备份拆分 单位成本上升,难以回退 财务与容量预算
独立 → 共享 合并数据并重建唯一性假设 主键冲突、时间戳重叠 没人愿承担合并风险
// 可回退设计:三个阶段,每阶段都能停
─── 1) 双写 ─── 新库先写,读仍走旧库;比对差异率
─── 2) 读切换 ─── 按租户白名单切读,保留回切开关
─── 3) 收尾 ─── 停双写,旧库只读保留一个结算周期

反向迁移难,因为合并会破坏独立时的隐含假设:自增主键、独立时区、各自的时间线。独立部署没预留全局 ID 方案,回退基本等于重建。

8. 混合模型的判定依据

判据 指向独立 指向共享
单租户数据量占全库比例 超过一成且增长可预测 始终个位数百分比
合同是否写明物理/地域约束 写明 只写"不得访问"
租户自身是否有审计义务 有,且要求提供证据 无
租户数量 少数大客户 + 长尾 全部长尾、单租户价值低
运维人力 能承担多套部署的版本对齐 只够维护一套

混合的形态几乎总是大客户独立、长尾共享、共用一套代码。关键是隔离级别必须是配置而不是分支——一旦为某客户拉分支,混合就退化成多套产品。

9. 成本分摊与隔离级别的耦合

隔离级别 成本可归因性 定价含义
共享表 只能按用量分摊 单价低,毛利靠规模
独立 schema 可按存储与连接归因 可打包成标准版
独立实例 几乎完全可归因 可单独报价,也最容易亏

隔离级别越高,成本越可归因,报价越好解释;但固定成本占比越高,长尾越容易亏。这是「独立部署」在销售口里是卖点、在财务表里是陷阱的原因。

10. 租户数据导出:退出权要能操作

合规退出权不是"我们支持导出",是"给定租户 ID,能在承诺时限内产出可校验的完整副本"。

// 导出骨架:范围、完整性、可校验三件事,缺一件都不算合规
─── 表清单从元数据生成(漏表=不合规);每表行数与源库对账;manifest 附 sha256

漏表是头号问题,因为清单靠人手维护。改成从「含 tenant_id 的表」自动生成,流程问题就变成一次性工程问题。

11. 度量

指标 口径 用途
越权检测数 探针与线上审计命中的越权次数 唯一能提前发现泄露的指标
租户间延迟差异 同一接口 P99 的最大/最小租户比值 噪声邻居的早期信号
单租户资源占比 单租户用量 / 全站用量 判断是否该升级隔离级别
迁移成本 单租户迁移工时 + 停机时长 决定混合模型的边界

12. 排错:隔离"做了"但仍然出事

现象 更可能的原因 处理
偶发看到别家数据 连接池复用了带租户变量的连接 改 SET LOCAL 或借出时重置
报表数据串租户 报表走另一条数据源,绕过拦截器 把报表纳入同一执行层
某租户突然全站变慢 他的批量任务占满连接或 IO 配额 + 任务优先级,而非加机器
独立库版本不一致 流水线没覆盖独立实例 版本对齐检查进发布门禁
删除租户后仍有残留 备份、缓存、日志、索引未清 删除清单化,逐项验证

删除的难点不是主库,是备份与派生数据。备份里必然还有这份数据,所以「彻底删除」要么改成「保留周期后自然消失」,要么接受备份也能定点清除。

13. 边界

场景 判断 理由
单租户系统 不引入租户模型 多一层条件只增加漏写风险
租户极少且互不敏感(内部部门) 共享表 + 审计即可 物理隔离收益低于运维成本
数据本身无隔离要求(公开数据) 不做租户过滤 过滤只带来性能开销
租户上千且单租户价值极低 共享 + 强配额,不升级隔离 独立部署成本无法摊薄
强监管行业且合同写明 独立实例,成本进报价 隔离本身是产品的一部分

14. 自测

  1. 客户说"数据必须物理隔离",你先问哪一个问题来判定他真正在买什么?
  2. 共享表方案的三个必要条件里,哪一个无法靠工程强制?
  3. 为什么行级安全策略必须配 SET LOCAL 而不是 SET?
  4. 双租户探针用不存在的 ID 测试,会漏掉哪一类问题?
  5. 从独立实例回退到共享表,最先被破坏的假设是什么?
  6. 删除租户时最容易被漏掉的派生数据有哪几类?
S12-A11 · 多租户的数据隔离:共享、独占与混合的账
https://www.clxhxhhr.top/posts/6281/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。