为每个 SaaS 客户提供他们自己的内容空间
为每个客户提供一个隔离的内容空间,隔离由 PostgreSQL 行级安全强制执行——而不是靠应用层的一条 if 语句。
在第二十个客户到第五十个客户之间的某个时刻,“我们只要加一列 tenant_id”就不再是一个方案,而变成了一项负债。
本文谈的是实现多租户内容的三种方式、其中一种为何明显更安全,以及当客户数量增长时,常见的 CMS 定价模式会对 SaaS 的利润率造成什么。
三种模式,一个真正的决策
| 模式 | 隔离 | 运维成本 | 它在哪里崩溃 |
|---|---|---|---|
| 每租户一个数据库 | 最强 | 最高——N 个数据库需要迁移、备份、监控 | 200 个租户时的迁移;连接池耗尽 |
| 每租户一个 schema | 强 | 高——N 个 schema,同样的迁移问题 | 数千个 schema 时 Postgres 会变慢 |
| 行级、共享表 | 完全取决于强制手段 | 最低——一次迁移,一次备份 | 一条忘了过滤条件的查询 |
几乎每个人最终都选择了行级方案,因为另外两种的运维成本是真实且不断叠加的。这就让最后一格成为整个问题的关键:是什么阻止了那条忘了过滤条件的查询?
过滤条件总会在某一天被遗忘
不是因为你的团队粗心,而是因为算术。
一个成熟的产品有数百条查询。有些在一年都没人打开过的报表任务里。有些在故障期间仓促写成的管理工具里。有些在某个外包人员添加、审查者只是略略扫过的模块里。它们每一条都必须记得写上 WHERE tenant_id = $1。
这种失败是无声且彻底的。缺失的过滤条件不会抛出异常。它会把一个客户的内容返回给另一个客户,而那个响应看起来完全正常,直到有人在一个下拉菜单里注意到了竞争对手尚未发布的定价页面。
PostgreSQL 行级安全把边界移到了错误之下。你给表附加一个策略,在会话中设置租户,数据库就会对每一条查询都应用这个谓词——包括那些没人审查过的:
现在,那条忘了过滤条件的查询返回的是零行,而不是其他所有人的数据。这个 bug 变成了一个可见的缺失,而不是一个不可见的泄漏——而这个差别就是整个论据。
corpusctl 在核心中实现了这一点,与认证和模块注册表并列。每一条应用查询仍然显式地限定了租户范围;RLS 是第二道防线,而不是第一道。第二道防线的意义就在于,当第一道失守的那一天,它就在那里。
表还按租户进行了哈希分区,这可以防止一个大客户拖累其他所有人的查询计划。
用户不是租户
这是日后代价最高的建模错误。
在一款真实的 SaaS 里,人们归属于多个客户。一个代理机构用户管理着你的三个客户。一个支持工程师需要对某个账户有一周的读取权限。一个外包人员在品牌二上工作,且绝不能看到品牌一。
如果你的模型是“用户归属于租户”,那么上面每一种情况都会变成一个重复账户,而重复账户会变成一个审计问题。
corpusctl 的模型: