导出 CSV 里的三行,来自另一个团队。
一、导出文件里多了三行
一位做项目管理的独立开发者,给 20 个团队开了工作区。某天一位客户在导出 CSV 里看到了另一个团队的项目名,虽然只有三行,但足够让他确认这不是显示错误。
出问题的是一条「最近项目」查询:新同事在仓储层写查询时漏了 where workspace_id = ?,本地测试只有一个租户的数据,所以怎么点都正常;上线后,这条查询把所有团队的项目按更新时间排了一遍。
导出接口复用了这条查询,于是把别家的项目名、负责人和截止日期一起写进了 CSV。客户没有恶意,也没有改任何参数,只是点了一次导出。
以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。
隔离不是「记得加 where」,而是让漏写 where 的查询跑不起来。 只要正确性依赖每个人的记性,早晚会有一次遗漏。
这类事故的共同点是「默认全量」:查询方法不要求租户参数,表结构里 workspace_id 可空,ORM 的默认查询不带作用域。每一处默认都在鼓励漏写。
修复不能停在补上那一个 where。要问的是:还有多少条查询用了同样的写法?有没有办法让下一条查询不可能漏?这两个问题分别对应测试与结构。
这一篇讲四道防线:应用层作用域、数据库行级安全策略、集成测试、导出与删除按租户。四道防线各自会失效,所以需要叠加,而不是选一道最强的。
二、三种隔离方案怎么选
三种主流隔离方案没有绝对优劣,只有与租户规模、运维能力和合规要求是否匹配。
| 方案 | 隔离强度 | 运维成本 | 迁移难度 | 适合几家租户 |
|---|---|---|---|---|
行级 workspace_id | 中:靠查询与策略保证 | 低:一套库、一套迁移 | 低:加列与索引即可 | 几十到几千家 |
| schema per tenant | 中高:连接切换与迁移隔离 | 中:N 套 schema、N 次迁移 | 中:跨 schema 变更需编排 | 几十到几百家 |
| database per tenant | 高:故障与备份物理隔离 | 高:N 套实例、监控与备份 | 高:跨库统计与迁移复杂 | 几家到几十家,客单价高 |
20 个团队选行级。 不是因为行级最安全,而是因为它在当前规模下,用一套迁移、一套监控、8 个额外索引,换到了足够好的隔离与可验证性。
隔离强度要按「最坏情况」评估。行级方案的最坏情况是一条查询漏了作用域;schema 方案的最坏情况是连接切错 schema;独立库方案的最坏情况是跨库任务连错实例。三种都会出错,区别在于错误的影响面与发现难度。
运维成本不只是服务器费用。schema 方案每次发布要跑 20 次迁移,任何一次失败都要单独回滚;独立库方案的备份、监控、连接池、慢查询都要乘以租户数。一个人的团队要先算人力,再算机器。
迁移难度决定你能不能改主意。行级方案从加一列开始,后续补索引与策略都是增量;schema 与独立库方案一旦租户数量上去,再合并回单库几乎是一次重写。
「适合几家租户」不是拍脑袋。行级适合几十到几千家;schema 适合需要按租户做结构差异、但租户数可控的场景;独立库适合客单价足够高、合同明确要求物理隔离的客户。
合规要求会改变答案。如果客户合同写明「数据不得与其他客户共存于同一数据库」,行级方案再省事也不能用;如果没有这条,行级方案的性价比明显更高。
三、四道防线与各自的失效条件
四道防线各自挡不同的失败,也各自有失效条件。
| 防线 | 挡什么 | 失效条件 |
|---|---|---|
| 应用层作用域 | 业务查询漏写租户条件 | 有人绕过仓储层直接写 SQL,或作用域参数可被请求覆盖 |
| 数据库行级安全策略(RLS) | 应用层漏过滤时的兜底 | 连接使用表所有者或超级用户,或策略写成 using (true) |
| 集成测试 | 回归时发现跨租户读写 | 测试数据只有一个租户,或只测详情不测列表与批量 |
| 导出与删除按租户 | 大批量操作带走或删掉别家数据 | 导出复用未加作用域的查询,删除按 id 列表逐条执行 |
四道防线里,RLS 是唯一不依赖应用代码正确性的那道。 但它只有在连接角色、策略与测试都正确时才成立,配置错一次就等于没有。
应用层作用域是第一道,也是最容易被绕过的一道。仓储层要求每个方法显式接收 workspace_id,并在拼查询时强制注入;业务代码不能自己拼 SQL,也不能从请求体读取租户 id。
RLS 是第二道。8 张业务表各启用行级安全,策略统一写成 workspace_id = current_setting('app.workspace_id')::uuid,连接时由应用设置该变量。策略不要按角色分支,角色判断留在应用层,数据库只负责租户边界。
RLS 的失效条件很具体:迁移脚本用表所有者执行、连接池复用了上一个请求的会话变量、策略被写成 using (true)、或者某些表忘了启用。任何一条都让第二道防线变成装饰。
集成测试是第三道。测试库至少要有两个租户,每个租户各有读写数据;用例覆盖详情、列表、导出、批量、删除。只有一个租户的测试库,永远测不出跨租户泄露。
导出与删除是第四道,也是影响面最大的操作。导出必须走带作用域的查询,并在文件生成后校验行数与租户;删除必须按租户条件执行,不能先查 id 再逐条删。
四道防线的顺序不是执行顺序,而是失效顺序。应用层先失效,RLS 兜底;RLS 配置错了,测试兜底;测试没覆盖到,导出与删除的租户校验兜底。每一道都要假设前一道已经失效。
四、复算:行级方案撑不撑得住
下面这组数字用来判断「行级方案是否撑得住」,不是性能基准。
业务表 = 8 张,每张 1 条 RLS 策略 → 8 条策略
越权用例 = 27 条(与 05 篇同口径:9 类场景 × 3 层)
单团队导出 = 3.2 万行;20 个团队合计 = 3.2 万 × 20 = 64 万行
行级方案额外索引 = 8 个(每张表一个 workspace_id 索引),写入成本约 +5%
schema 方案迁移 = 20 套 schema × 每次发布 = 20 次迁移
这些数字支持「行级够用」的结论,但不支持「行级永远够用」。 当单表行数、租户数或合规要求越过某个阈值,答案要重新算。
8 张表、8 条策略、8 个索引是一一对应的关系。策略数量少不代表简单,关键是每张表都要有,且每张表的策略条件一致;漏一张表,那张表就是缺口。
64 万行对一个 PostgreSQL 实例不算大。按每行 1 KB 估算约 640 MB,加上索引仍在单机舒适区;真正需要关注的是导出时的排序与内存,而不是存储。
写入成本约 +5% 是量级判断:每张表多一个 workspace_id 索引,插入与更新要多维护一个 B 树。对以读为主的项目管理类应用,这个代价可以接受;对高频写入的日志表,要单独评估。
schema 方案的 20 次迁移不是线性成本。每次发布要确认 20 套 schema 的版本一致,失败要单独回滚,漂移要单独检测;租户数翻倍,迁移次数也翻倍,而发布窗口不会翻倍。
27 条越权用例与 05 篇同口径,意味着两篇文章共用一套判定矩阵。05 篇验证「拦不拦得住」,本篇验证「即使拦漏了,数据层能不能兜住」;两套结果要能对上,不能各测各的。
复算的意义在于把「感觉够用」变成「知道边界在哪」。当团队数从 20 涨到 200,先重算导出行数与索引写入成本,再决定是继续行级还是拆库,而不是等事故发生。
五、行动清单与复算验收
清单按「先结构,再策略,最后验证」排序。
- 8 张业务表全部有
workspace_id列,且不允许为空 - 所有业务查询经仓储层注入
workspace_id,业务代码不手写租户条件 - 8 张表全部启用 RLS,策略条件统一,且没有
using (true) - 应用连接使用非表所有者角色,
app.workspace_id每个请求重设 - 测试库至少两个租户,27 条越权用例合并前全绿
- 导出按租户查询并校验行数,生成后抽查是否含其他租户数据
- 删除按租户条件执行,禁止「先查 id 再逐条删」
- 每季度复算一次表数、策略数、索引数与导出行数
验收标准:能证明一个团队永远查不到另一个团队的行:应用层、RLS、测试、导出删除四道防线各有一条可复现的证据;缺任何一道,都只能证明「目前没发现」。
使用边界与实操验收
本文讨论单库行级隔离的工程判据,不覆盖跨地域数据驻留、加密密钥隔离与合规审计。
隔离方案要跟客户合同一起读。 合同没写物理隔离,行级方案就够;合同写了,再省事的方案也不能用。
本文的 20 个团队、24 个工作区、3.2 万行导出都来自系列统一基线,不对应真实客户;四道防线的效果取决于你的测试数据与连接配置,不取决于本文的数字。
RLS 的语法与行为在不同数据库间差异很大。PostgreSQL 的策略、会话变量与绕过规则是本文的核对对象;MySQL、SQLite 或托管数据库的等价机制需要单独验证,不能直接照搬。
跨租户统计是行级方案的隐藏成本。单库聚合很便宜,但它也意味着任何一条分析查询都可能越过租户边界;分析账号应使用只读副本与脱敏视图,不要复用业务连接。
备份与恢复是隔离之外的另一条线。备份通常是整库的,恢复演练要验证「恢复后 RLS 仍然启用、workspace_id 数据仍在」,否则隔离在灾难恢复后可能悄悄失效。
如果只有几个高客单价客户、合同明确要求物理隔离,独立库方案反而更省心:故障、备份、删除都以库为单位,解释成本最低。方案选择要回到合同与人力,而不是技术偏好。
数据驻留与加密密钥隔离不在本文范围内;它们会进一步限制可选方案,尤其是当客户要求数据留在特定区域或自带密钥时,行级方案的「一套库」前提就不再成立。
关键参考与核验范围
核验日期:2026-10-25。以下链接用于核对行级安全策略的语法、越权分类与多租户存储方案,本文未逐条引用其内容。
- PostgreSQL Row Security Policies :用于核对策略启用、会话变量与表所有者的绕过规则。
- PostgreSQL CREATE POLICY
:用于核对
using与with check的语义差异。 - OWASP Top 10 A01:2021 Broken Access Control :用于核对越权与 IDOR 的分类和测试建议。
- AWS 多租户 SaaS 存储策略 :用于核对行级、schema 与独立库三种模型的取舍。
引用口径。 文中 20 个团队、24 个工作区、8 张业务表、3.2 万行导出与 64 万行合计均为本系列统一的教学基线,不对应任何真实客户;27 条用例与 20 次迁移是复算口径,不是压测结果。
核验范围。 机制层面核对到 RLS 的启用方式、连接角色与策略条件;未核对任何云厂商的托管数据库定价,也未做行级与 schema 方案的实测性能对比。
如何自行复核。 建两个租户、各 3 条项目数据,用租户 A 的连接执行一条故意不带 where 的查询:有 RLS 时应只返回 3 行,没有 RLS 时会返回 6 行。再把连接切成表所有者重跑一次,确认绕过是否如预期发生,并据此检查生产连接角色。
系列导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。