一个大客户在合同评审里加了一条:数据必须物理隔离。这一条会把架构翻掉一半——共享表加租户列跑得好好的系统,要么改成独立库,要么临时造一套只服务他的部署。真正难的不是改,是判断哪一档隔离是这个客户真正在买的,以及长尾租户要不要跟着一起付这笔钱。
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. 自测
- 客户说"数据必须物理隔离",你先问哪一个问题来判定他真正在买什么?
- 共享表方案的三个必要条件里,哪一个无法靠工程强制?
- 为什么行级安全策略必须配
SET LOCAL而不是SET? - 双租户探针用不存在的 ID 测试,会漏掉哪一类问题?
- 从独立实例回退到共享表,最先被破坏的假设是什么?
- 删除租户时最容易被漏掉的派生数据有哪几类?