多租户 SaaS 架构:在你构建之前没人告诉你的事
Source: Dev.to
抱歉,我目前无法直接访问外部链接。请您把需要翻译的正文内容粘贴到这里,我会按照要求保留源链接、格式和技术术语,将其翻译成简体中文。
三种规范模式
| 模式 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| 1. 为每个租户单独数据库 | 每个租户拥有自己的数据库实例。 | • 完全的数据隔离 • 没有跨租户泄漏的风险 • 简单的下线流程 • 每租户的备份/恢复非常容易 | • 供应时间 ↑ • 连接池管理变得复杂 • 模式迁移必须在 N 个数据库上执行 • 成本随租户数量线性增长 |
| 2. 为每个租户单独模式,共享数据库 | 一个数据库服务器,每个租户拥有自己的 schema(PostgreSQL 原生)。 | • 在不增加独立实例开销的情况下实现逻辑分离 | • 连接池(如 PgBouncer)在连接层面工作,而不是在 schema 层面 • 必须为每个请求设置 search_path• 部分 ORM 能优雅处理,其他则不行 • 迁移仍需在所有租户之间协调 |
| 3. 共享 schema,共享数据库(行级租户) | 所有租户共享同一套表,每行都有 tenant_id 列。 | • 运营成本最低 • 迁移最简单 • 入驻速度最快 | • 如果遗漏 tenant_id 过滤,数据泄漏风险极高• 必须使用 Row‑Level Security (RLS) 作为硬防线 |
3️⃣ 共享‑Schema 方法:实施行级安全
如果你选择共享‑schema 模型,PostgreSQL RLS 不是可选的——它是最后一道防线。
-- Enable RLS on the projects table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Create a policy that restricts reads to the current tenant
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);为每个请求设置租户上下文
SET app.current_tenant_id = '{{tenant_uuid}}';即使应用代码忘记了 tenant_id 过滤,数据库仍会强制边界(深度防御)。
成本: current_setting() 会为每个查询增加一点开销——对大多数工作负载来说可以忽略不计,但如果你的查询率非常高,请进行基准测试。
您的应用程序如何识别租户?
| 方法 | 示例 | 含义 |
|---|---|---|
| 基于子域 | acme.yourapp.com → 将 acme 解析为租户 | 需要 DNS 通配符或每个租户的记录;增加路由复杂度 |
| 自定义域名 | app.acmecorp.com | 必须在边缘将任意域名映射到租户 ID |
| 基于路径 | yourapp.com/t/acme/dashboard | DNS 更简单,但需要解析 URL |
| 基于令牌 | 在 JWT 或会话令牌中编码租户 ID | 对 API 很友好;需要安全的令牌处理 |
大多数团队会混合使用:主 UI 使用子域,API 使用基于令牌的方式。
迁移策略
- 单租户应用: 迁移运行一次。
- 多租户(共享模式): 迁移运行一次。
- 多租户(每租户独立模式或每租户独立数据库): 迁移运行 N 次。
多租户迁移运行器的需求
- 按租户跟踪迁移状态
- 并行运行迁移(可配置并发数)
- 优雅地处理失败 – 避免部分发布(例如,某些租户在版本 7,其他租户在版本 8)
成熟模式
在管理数据库中维护 tenant_migrations 表:
| tenant_id | migration_version | last_run_at |
|---|
您的部署流水线:
- 查询
tenant_migrations,找出未达到当前版本的租户。 - 按批次运行迁移(并行或顺序,依据并发限制)。
- 成功后更新表,或记录失败以便重试。
新租户入驻
| 模型 | 入驻步骤 | 典型延迟 |
|---|---|---|
| 共享‑schema | 在 tenants 表中插入一行;使用生成的 ID 作为所有写入的 tenant_id。 | 几乎即时(原子) |
| Schema‑per‑tenant | 创建新 schema → 运行基线迁移。 | 小型 schema 几秒;大型 schema 几分钟(通常以异步方式,并显示“工作区正在准备中”界面) |
| Database‑per‑tenant | 配置新数据库实例 → 设置访问权限 → 运行迁移 → 更新路由表。 | 几分钟(后台任务) |
围绕供应模型设计用户体验;不要等发布后才发现不匹配。
关键要点
多租户是数据架构和应用设计层面的关注点,而非基础设施层面的关注点。
您可以在单台服务器上运行多租户应用,也可以跨数百台服务器运行;同样,您也可以在 Kubernetes 中使用 50 个副本运行单租户应用。隔离模型存在于数据层和应用逻辑中;扩展性、可用性和部署是独立(但同样重要)的决策。
进一步阅读
- Actinode guide on multi‑tenant SaaS architecture – a comprehensive decision matrix covering compliance, pattern trade‑offs, and migration strategies.
租户隔离选项
| Model | Scope | Cost | Migration Complexity | Best for |
|---|---|---|---|---|
| Separate databases | Full | High | High | Enterprise, regulated industries |
| Separate schemas | Logical | Medium | Medium | Mid‑market SaaS |
| Shared schema + RLS | Row‑level | Low | Low | High‑volume B2B, most startups |
注意: 您在此做出的选择将伴随多年。请慎重决定。
测试您的租户隔离
无论选择哪种模型,都要编写明确的测试来验证租户隔离是否有效。
- 不仅仅是查询逻辑的单元测试——集成测试,模拟跨租户访问尝试并验证它们在数据库层被阻止。
示例测试套件
1. Create two test tenants with separate datasets
2. Authenticate as tenant A
3. Attempt to read tenant B's records via your application's own API routes
4. Assert the response contains zero tenant B records- 该测试应在每次合并时于 CI 流水线中运行。
- 租户隔离失效是会引起媒体关注的严重漏洞,在 CI 中捕获的成本相对于在生产环境中发现的成本微乎其微。
RLS 策略验证
-- Connect with the RLS policy active
SET app.current_tenant_id = '<tenant_uuid>';
-- Attempt to SELECT tenant B's rows directly
SELECT * FROM sensitive_table WHERE tenant_id = '<other_tenant_uuid>';- 如果 RLS 正常工作,查询将返回零行。
- 如果返回任何行,则说明您的策略存在漏洞。
Bottom Line
隔离是一种 正确性属性,而不仅仅是设计偏好。
像对待正确性属性一样进行测试。