一个 CRM 团队在共享 PostgreSQL 里用
tenant_id
隔离客户数据,上线六个月后某大客户跑了一次全表导出,把连接池占满,小客户的登录请求开始超时——这不是
SQL
注入,也不是配置错误,而是多租户架构(Multi-Tenant
Architecture)在资源维度上的经典失败模式:吵闹邻居(Noisy
Neighbor)。同一套代码服务成千上万个互不信任的租户,隔离要在数据、计算、连接、配额四条线上同时成立;任何一条线上的「软隔离」都会在峰值或误操作时变成跨租户事故。
Salesforce 在 2009 年的 ICWS 论文 The Multi-Tenant Data Architecture of Salesforce.com(Weiss & Bobrowski)把 SaaS 多租户定义成:单一应用实例 + 单一数据库模式服务多个组织(org),通过元数据层实现 per-tenant 定制。二十年过去,这条主线没有变——变的是硬件密度、边缘计算、分布式 SQL 与合规边界。本文沿 数据密集型架构 之后的路径,把多租户当作 SaaS 的核心设计难题系统展开:隔离放在哪一层、数据模型如何选、吵闹邻居怎么防、大客户如何分层、跨租户分析如何不与在线路径打架。下一篇 架构的不变量 将从更抽象的视角收束本系列。
系列导航: 上一篇:数据密集型架构 · 下一篇:架构的不变量
相关专题: Shopify 架构与 Pod · Cloudflare 与 V8 Isolate · 连接池设计 · 限流与过载保护 · 优雅降级 · 数据库扩展 · 可观测性多租户
| 章节 | 核心问题 | 主要依据 |
|---|---|---|
| 一、Silo / Bridge / Pool | 隔离谱系与选型锚点 | Weiss & Bobrowski (2009);AWS/Azure SaaS 架构模式 |
| 二、隔离层 | 进程 / 命名空间 / 逻辑边界 | Linux namespace;Shopify Pod;Cloudflare Isolate |
| 三、共享 schema | tenant_id 与漏过滤风险 |
PostgreSQL 文档;生产事故模式 |
| 四、RLS 安全网 | 数据库最后一道闸 | PostgreSQL RLS 官方文档 |
| 五、Schema-per-tenant | 目录膨胀与迁移 | PostgreSQL catalog;运维实践 |
| 六、DB-per-tenant | 运维成本与合规收益 | Citus 多租户指南 |
| 七、吵闹邻居 | 限流、配额、连接池、计算隔离 | 站内限流/连接池文;Citus noisy neighbor |
| 八、混合分层 | 大客户独占资源 | Shopify Pod / Shop Mover |
| 九、跨租户分析 | 数仓与在线路径分离 | DDIA;Shopify 官方表述 |
| 十、可观测性 | 存储多租户轻触 | Grafana Mimir/Loki 多租户 |
| 十一、开放问题 | 尚未统一的工程答案 | CIDR/SIGMOD 方向性讨论 |
| 十二、小结 | 决策清单 | 全文归纳 |
一、Silo、Bridge、Pool:隔离谱系
多租户架构文献与云厂商白皮书里,常用 Silo / Bridge / Pool 三档描述租户在资源栈上的独占程度。这不是三个互斥产品,而是一条连续谱:从左到右,隔离强度下降、资源利用率上升、单位租户运维成本下降;从右到左相反。
1.1 三档定义
| 模式 | 应用层 | 数据层 | 典型场景 | 主要代价 |
|---|---|---|---|---|
| Silo(独立栈) | 每租户独立部署/进程/集群 | 每租户独立 DB 或独立实例 | 金融、医疗、政府、超大 enterprise | 运维套数 × N;版本升级慢 |
| Bridge(桥接) | 通常共享应用 | 每租户独立 DB,或共享 DB 内独立 schema | 中型 SaaS、合规「逻辑分库」 | Schema/DB 数量膨胀;连接管理复杂 |
| Pool(资源池) | 共享应用与运行时 | 共享表 + tenant_id |
标准 SMB SaaS、开发者平台 | 吵闹邻居;漏过滤即数据泄露 |
Pool 是 Weiss & Bobrowski 描述的 Salesforce 早期形态抽象:所有 org 的行落在同一物理表,通过 org id 区分。Bridge 常见于「共享代码、分库」——Heroku 式每 app 一 Postgres,或 PostgreSQL 的 schema-per-tenant。Silo 则是 Shopify 超大商户独占 Pod 或 enterprise 合同要求的 dedicated stack。
graph LR
subgraph Silo["Silo: dedicated stack"]
A1[App instance T1]
D1[(DB T1)]
A1 --> D1
end
subgraph Bridge["Bridge: shared app, split data"]
A2[Shared App]
D2[(DB T2)]
D3[(DB T3)]
A2 --> D2
A2 --> D3
end
subgraph Pool["Pool: shared app + shared tables"]
A3[Shared App]
D4[(Shared DB<br/>tenant_id column)]
A3 --> D4
end
图 1:Silo / Bridge / Pool 在应用与数据层的典型映射。实际系统常在同一产品内混用三档(混合分层,见第八节)。
1.2 为何不是「选一个用到底」
工程上几乎不存在纯 Pool 或纯 Silo 的大厂 SaaS:
- Pool 支撑长尾小客户的单位经济模型——若每个免费试用用户都 Silo,销售成本尚未收回,基础设施已破产。
- Silo 支撑 SLA、合规与「这客户大到会拖垮邻居」的确定性隔离。
- Bridge 常作为从 Pool 向 Silo 迁移的中间态:先把大客户迁到独立 schema 或独立 DB,应用层仍是一套二进制。
争论点不在于「哪档更好」,而在于租户生命周期中何时升级档位、升级是否可逆、路由层能否无感切换。Shopify
的 Shop Mover + Sorting Hat 回答的是「运行时如何把 shop 从
Pool 式共 Pod 迁到独占 Pod」;Citus 文档中的 Dealing
with Big Tenants 回答的是「大 company_id
如何单独放到 worker 节点」。两者都是同谱系上的动态
reposition,而非推翻 Pool 改 Silo。
1.3 学术谱系:从 Salesforce 到分布式 SQL
Weiss & Bobrowski (ICWS 2009) 把 Salesforce 的多租户数据架构概括为三层:
- 内核(Core):所有 org 共享的表结构与索引;
- 元数据(Metadata):描述每个 org 的 UI、工作流、字段级定制——存在 shared catalog,运行时解释执行;
- 数据(Data):业务行带 org id,与内核表 JOIN。
这一「统一 schema + 元数据驱动差异」直接映射到现代 Pool 模型:JSONB 扩展字段、feature flag、tenant 级配置表取代了专有 metadata 引擎,但「一次 migration、全员生效」的经济学不变。Jacobs & Gilmore 对 Salesforce 数据库层的早期描述(常被与上述论文一并引用)强调:共享数据库进程是 SaaS 毛利率的前提——独立 DB per org 在 Salesforce 目标客户规模下不可想象。
分叉发生在数据量与租户体量方差超过单机 PostgreSQL 之后:
- 水平分片(Citus、Vitess、Spanner
中间件)保留 Pool 逻辑,把
tenant_id映射到物理节点; - 单元化架构(Shopify Pod、Uber 域)在 L4 重建 Silo 语义,应用仍可能是一套 monolith;
- 边缘多租户(Cloudflare Workers)把隔离推到 L3,数据仍回源中心 Pool。
争论点:元数据驱动 vs 代码分支。Salesforce 式 metadata 允许「无代码配置新字段」;多数中小 SaaS 选择「代码 + migration + JSONB」——配置灵活性 vs 类型安全与查询性能之间的 trade-off 无统一最优,取决于产品是否允许租户 arbitrary schema。
1.4 与 数据库扩展 的关系
分库分表、读写分离、分布式 SQL 解决的是单租户或全局数据量的扩展;多租户解决的是租户间隔离与公平性。二者正交:
- 可以对 Pool 模型做 horizontal sharding(按
tenant_id哈希)——这是 Citus 的主线。 - 可以对 Silo 模型再分片——每个大客户 DB 内部仍可能过大。
- 分片本身不消除吵闹邻居:同一 shard 上两个 tenant 仍共享 CPU、连接与 IOPS。
选型时应先画隔离谱系,再画扩展维度;把「我们用了 Kubernetes」误当成多租户隔离,是常见误区——容器编排解决部署,不自动解决行级泄露或连接池公平性。
二、隔离层:进程、命名空间与逻辑边界
「多租户」在不同语境下指不同层级的隔离。至少应区分四层,并明确你的 SLA 承诺对应哪一层。
2.1 四层隔离模型
| 层级 | 机制 | 隔离对象 | 典型实现 | 失败时后果 |
|---|---|---|---|---|
| L1 逻辑 | tenant_id、RBAC、API 网关 |
数据行、API 可见性 | ORM scope、JWT claim | 跨租户数据泄露 |
| L2 连接与配额 | 连接池分区、rate limit、quota | DB 连接、QPS、存储 | PgBouncer pool、Redis 限流 | 吵闹邻居、池耗尽 |
| L3 进程 / 命名空间 | cgroup、namespace、容器、Wasm isolate | CPU、内存、部分内核对象 | Docker、Firecracker、V8 Isolate | 侧信道、资源争抢 |
| L4 物理 / 网络 | 独立 VM、独立 VPC、独立 Pod 数据面 | 磁盘、网络、故障域 | Shopify Pod | 成本最高;隔离最强 |
上层不能替代下层:仅有 L1 而无
L2,一次漏写 WHERE tenant_id = ?
就是事故;仅有 L2 而无 L1,应用 bug
仍可能读到别家行。L3 在 Cloudflare
Workers 场景是核心——数十万租户脚本跑在同一 OS 进程,靠
V8 Isolate 而非容器;Spectre 类漏洞说明 L3 在 CPU
微架构层面仍有边界。L4 是 Shopify 对 Pod
的定义:MySQL/Redis/memcached 物理成套,共享无状态 Worker
但禁止跨 Pod I/O。
flowchart TB
REQ[Inbound request] --> L1[L1 Logical<br/>tenant context]
L1 --> L2[L2 Quota / pool]
L2 --> L3[L3 Compute sandbox]
L3 --> L4[L4 Data plane cell]
L4 --> STORE[(Storage)]
图 2:请求路径上四层隔离的叠加顺序。实际产品可能省略某层,但必须在设计文档中写明省略带来的风险接受。
2.2 逻辑租户上下文:从边缘到 ORM
无论 Pool 还是 Bridge,在线路径都需要不可伪造的 tenant context:
- 边缘注入:Shopify Sorting Hat 在
OpenResty 层查 domain →
pod_id/ shop;Cloudflare 在 Workers 调度层绑定 script id。 - 服务内传播:Go 的
context.Context、Java 的 ThreadLocal、Rails 的Currentattributes——必须在 middleware 入口设置,在 outbound call 与 DB 访问前断言存在。 - 禁止客户端自选 tenant:与 可观测性多租户
中「禁止客户端自选
X-Scope-OrgID」同构——tenant id 来自认证/session,不是来自 query string。
# 示意:FastAPI 中间件强制 tenant context(非任何项目源码)
from contextvars import ContextVar
from fastapi import FastAPI, Request, HTTPException
tenant_ctx: ContextVar[str] = ContextVar("tenant_id")
app = FastAPI()
@app.middleware("http")
async def bind_tenant(request: Request, call_next):
# tenant 来自已验证 session,而非 ?tenant_id=
tenant_id = request.state.session.get("tenant_id")
if not tenant_id:
raise HTTPException(status_code=401, detail="unauthenticated")
token = tenant_ctx.set(tenant_id)
try:
return await call_next(request)
finally:
tenant_ctx.reset(token)
def current_tenant() -> str:
return tenant_ctx.get()ORM 层应默认 global scope 过滤,且对 raw
SQL 路径审计。Rails 的 default_scope、Django 的
custom manager、TypeORM 的 subscriber
都是同一模式——便利性与「绕过 scope 的 admin
脚本」之间的张力是 Pool 模型持续的风险源。
2.3 进程隔离与 Cloudflare Isolate 经济学
Cloudflare Workers 选择 V8 Isolate 而非 per-tenant 容器,核心是密度与冷启动:Isolate 内存开销远低于完整容器,切换成本低,适合边缘百万级轻量脚本。代价是:
- 隔离语义弱于 VM/容器——需配合进程级沙箱、内存清零、Spectre 缓解。
- 有状态长连接与边缘抢占式调度不天然匹配(Discord 语音迁移案例已说明边界)。
对自建 SaaS:L3 是否必要取决于租户能否上传代码(PaaS/插件市场)。若只跑自有二进制,L2 + 良好 L1 往往足够;若运行租户 JavaScript/Wasm 插件,必须上 L3 或独立 Silo。
2.4 Shopify Pod 作为 L4 数据面单元
Shopify Pod 不是 Kubernetes Pod,而是一组 shop 共享的隔离数据存储集合(MySQL shard + Redis + memcached)。App Server 无 Pod 本地状态,通过 Sorting Hat header 选择连接池。关键约束:
- 共享 Worker 任意时刻只连一个 Pod;
- Web request 与 background job 绑定单一 Pod;
- 禁止
with_each_shard式跨 Pod 在线 fan-out。
这把 L4 故障域从「全平台」收成「单 Pod 内 shop」。Pod 内仍可能存在吵闹邻居——第六节与第八节继续讨论。
三、Pool
模型:共享 schema 与 tenant_id
Pool 是多数 SaaS 的起点:一张表、一列 tenant 键、一套迁移。Weiss & Bobrowski 强调元数据驱动定制;PostgreSQL 时代等价物是 JSONB 扩展列 + 每租户配置表。
3.1 模式示例
CREATE TABLE orders (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
customer_id bigint NOT NULL,
total_cents bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_tenant_created
ON orders (tenant_id, created_at DESC);
CREATE UNIQUE INDEX idx_orders_tenant_idempotency
ON orders (tenant_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;索引设计原则:
- Leading column 必须是
tenant_id(或与 Citus 的 distribution column 一致),否则单租户查询退化为全表扫描。 - 唯一约束必须 tenant-scoped,否则不同租户无法复用同一业务键空间。
- 外键若跨表,子表也必须带
tenant_id,否则 JOIN 时无法裁剪分区。
Citus 官方多租户指南(v11.2)要求:分布式环境下
主键与外键必须包含 distribution column(如
company_id),否则约束检查需跨节点,代价极高。这是
Pool 模型上 sharding 的硬约束,不是可选优化。
3.2 三类高频失败模式
(1)漏过滤(Missing Predicate)
-- 危险:开发者假设 ORM 已加 scope,raw 查询未带 tenant
SELECT * FROM orders WHERE status = 'pending';一次漏过滤在 Pool 模型下等价于批量数据泄露。代码审查与 CI 中的 SQL 静态分析(禁止无 tenant 条件的 SELECT/UPDATE/DELETE)是 L1 防线;RLS 是 L1 的数据库侧备份(第四节)。
(2)Tenant 键类型不一致
JWT 里是 string slug,数据库是 uuid;或 staging 用 integer、prod 用 uuid。集成测试覆盖不足时,错误 cast 导致查空或查错 tenant 的 bug 极难排查。
(3)Background job 丢 context
队列 worker 从 message 体读取 tenant_id,若
producer 未写入或 consumer 未校验,job 可能在错误 tenant
下写数据。Shopify 要求 job 携带 pod/shop
上下文,与此同源。
3.3 Pool 的优势与隐性成本
优势(有文献与工程共识支撑):
- 单一 schema migration 服务全部租户——Weiss & Bobrowski 强调的「一次升级、全员受益」。
- 连接池、缓存、全文索引等共享基础设施,长尾租户单位成本低。
- 跨租户运营查询(「过去 24 小时 signup 数」)在单库上简单——但应限制在离线/数仓路径(第九节),不在热路径扫全表。
隐性成本:
- 任意租户的大查询、大导出、缺失索引的报表,影响同库全部租户——除非 L2/L4 隔离。
- 合规审计常问:「如何证明 A 租户无法读 B 租户?」仅「我们在代码里加了 filter」在严格场景下不够,需 RLS 或 Silo。
- 租户删除需 hard delete 或 anonymize 全表扫描;GDPR 删除请求在亿级 Pool 表上可能触发长事务与 vacuum 压力。
3.4 与 ORM Global Scope 的争论
社区对 default_scope 类机制有两派:
- 支持派:强制默认过滤,减少漏写概率。
- 反对派(Rails 社区部分声音):magic
scope 使 admin/debug 查询不可预测,跨 tenant 合法运维需
unscoped,反而制造 bypass 点。
工程判断:至少要有单一入口函数(如
for_tenant(id).orders)而非散落 WHERE;是否用
global scope 取决于语言生态与测试覆盖。无论哪派,RLS
或集成测试中的 tenant
交叉断言应作为发布门禁——这不是风格问题,是安全网。
四、PostgreSQL RLS:逻辑隔离的安全网
Row Level Security(RLS,行级安全)让 PostgreSQL
在执行计划层强制 tenant 谓词,即使应用漏写
WHERE tenant_id = ?,策略仍可能拦截——注意「可能」而非「必然」,策略写错同样会泄露。
4.1 最小可用 RLS 配置
依据 PostgreSQL 官方文档 5.8. Row Security
Policies(当前版本文档路径:postgresql.org/docs/current/ddl-rowsecurity.html):
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- 应用在每个 transaction 或 connection 开始时:
-- SET LOCAL app.current_tenant = '<uuid>';要点:
FORCE ROW LEVEL SECURITY:表 owner 也受策略约束,防止 DBA 误操作绕过。SET LOCAL:限制在当前事务,避免连接池复用时 tenant 泄漏到下一请求——这与 连接池 文讨论的 session 级状态在池化下的污染 直接相关。- 超级用户 / BYPASSRLS 角色仍可读全表——生产应用 DB 角色不应具备 BYPASSRLS。
sequenceDiagram
participant App as Application
participant Pool as Connection pool
participant PG as PostgreSQL
App->>Pool: checkout connection
App->>PG: BEGIN
App->>PG: SET LOCAL app.current_tenant = T1
App->>PG: SELECT * FROM orders
Note over PG: RLS adds AND tenant_id = T1
PG-->>App: rows for T1 only
App->>PG: COMMIT
App->>Pool: return connection
图 3:池化环境下 RLS 与 SET LOCAL
的配合。若使用 PgBouncer transaction pooling,session 级 SET
行为不同,需改用 SET LOCAL 于事务内或选用
session pooling。
4.2 RLS 的性能代价
RLS 在每条 qualifying row 上附加策略表达式。对于已带 tenant 前缀索引的查询,优化器通常仍走 Index Scan;对于复杂 JOIN,可能出现:
- 策略重复应用;
- 与 partition pruning 交互不佳;
- prepared plan cache 与
current_setting的组合导致 plan 抖动。
PostgreSQL 社区与 Citus 文档均建议:distribution column / tenant 列上必须有索引;RLS 是安全网,不是替代索引设计。性能敏感路径仍应在应用层带 tenant 谓词——RLS 防的是遗漏,不是替代查询优化。
4.3 RLS 不能单独支撑的合规叙事
RLS 无法防御:
- 存储层未加密的磁盘被拖库(需 TDE 或 Silo)。
- 备份/副本被恢复到错误环境后全表可见(备份权限与 Silo 同样重要)。
- 逻辑复制订阅方无 RLS 或不同策略。
- Side channel(同机其他租户)——需 L3/L4。
因此 RLS 在审计话术中的准确表述是:在假设数据库仅通过受控应用角色访问的前提下,为 Pool 模型提供深度防御(defense in depth)的最后一道闸,而非「启用 RLS 即等于 Silo」。
4.4 INSERT / UPDATE 策略与漏写防御
只读策略不够——写路径必须限制 tenant 不可伪造:
CREATE POLICY tenant_insert ON orders
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
CREATE POLICY tenant_update ON orders
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);WITH CHECK 阻止应用插入
tenant_id = 他人 的行;UPDATE
双谓词阻止把行 改嫁 到另一 tenant。DELETE
策略同理。集成测试应包含:
- 设置
app.current_tenant = A后尝试INSERT ... tenant_id = B→ 必须失败; - 设置 A 后
UPDATE ... SET tenant_id = B→ 必须失败; - 池化连接 未
SET LOCAL时访问 → 应拒绝或零行(取决于 default deny)。
4.5 PgBouncer 与 RLS 的池模式陷阱
连接池
一文讨论 HikariCP 与 PgBouncer。Transaction
pooling 下,连接在事务结束后归还,session
级 SET app.current_tenant
会泄漏到下一租户——必须用 SET LOCAL
于每个事务内,或强制 session
pooling 并对 pool size 做容量规划。多租户 +
PgBouncer 是生产事故高发组合;架构评审应 explicit 标注 pool
mode 与 tenant SET 策略。
4.6 与 Citus 的组合
Citus 将 company_id 路由到单一 worker
节点,使单租户查询本地化。RLS 仍可在
coordinator 上启用,但分布式计划需保证 policy 与
distribution 一致。Citus 多租户文档建议 tenant
列进入所有 PK/FK——与 RLS 的
USING (tenant_id = ...)
是同一租户键的两面:一个为 sharding,一个为
authorization。
五、Bridge 变体:Schema-per-tenant
PostgreSQL 的 schema 允许在同一 database
内多套命名空间:tenant_42.orders、tenant_99.orders。这是
Bridge 的常见实现:共享 DB 实例、逻辑分
schema。
5.1 动机
- 比 Pool 强一点的隔离感:误连
tenant_42schema 不会读到tenant_99的表——但仍可能在同一 DB 实例内争抢 I/O。 - Per-tenant 迁移可选:可为单租户执行
ALTER而不锁全平台——在「大客户要自定义列」场景有吸引力。 - 备份粒度:可按 schema 逻辑导出——实际物理备份仍常是整实例。
5.2 Catalog 膨胀(Catalog Bloat)
PostgreSQL
系统目录(pg_class、pg_attribute、pg_depend
等)为每个 relation
存元数据。Schema-per-tenant 意味着:
- 租户数 \(N\) × 表数 \(T\) 个 relation 条目;
pg_dump/ migration 工具遍历 catalog 的时间随 \(N \cdot T\) 增长;- 连接建立时的 catalog cache、ORM metadata 反射变慢。
数量级示意(非实测,仅说明阶数):若每租户 50 张表、1 万租户,即 50 万 user tables——catalog 本身成为瓶颈,而非数据行数。业界经验阈值常落在 数千 schema 量级开始需要专门运维(分区 catalog、限制每实例 schema 数、升 Silo/分实例),具体取决于 PG 版本与硬件;本文不给出虚假 benchmark 数字。
5.3 连接与迁移运维
每个请求需 SET search_path TO tenant_X 或
fully-qualified 名称。与连接池组合时:
- Session pooling:一连接长时间绑定一 tenant,池利用率低。
- Transaction pooling:每事务
SET search_path,与 RLS 的SET LOCAL类似,需严格事务边界。
Schema 级 migration 工具(Rails
Apartment、django-tenants)要处理:
- 新 tenant signup 时 CREATE SCHEMA + 迁移 的延迟;
- 全租户滚动 migration 的 fan-out 时长——1
万 schema 的
ALTER TABLE即使用 CONCURRENTLY 也是运维事件。
5.4 何时从 Pool 迁到 Schema-per-tenant
合理触发条件:
- 少数租户需要 结构性 schema 差异(非 JSONB 能覆盖);
- 合规要求 逻辑分离 但尚不足以承担 DB-per-tenant 成本;
- 租户总数在 catalog 可承受 范围内(通常远低于 Pool 能支撑的行级租户数)。
不合理动机:「schema 听起来比 tenant_id 更安全」——若无
RLS/审计,schema 误设 search_path
仍会串租。
六、Silo 变体:Database-per-tenant
每租户独立 database(或独立实例)是 Bridge 向 Silo 的进一步移动:连接字符串级隔离。
6.1 收益
- 爆炸半径:单库慢查询、锁、磁盘满,不直接影响其他 tenant DB。
- 合规叙事:数据物理分离、独立备份保留策略、独立加密密钥(若配置)。
- 伸缩:超大租户可单独升配实例,无需迁全平台。
Citus 文档 Multi-tenant Applications(v11.2)指出:传统单实例 PostgreSQL 难以支撑大型 SaaS 数据量,而 Citus 允许在保持关系模型与 SQL 的前提下水平扩展;对「big tenants」可单独放置到节点——介于 Pool sharding 与完全 Silo 之间。
6.2 运维成本模型
设租户数为 \(N\),运维项近似线性或超线性增长:
| 运维项 | Pool | DB-per-tenant |
|---|---|---|
| Schema migration | 1 次 | \(N\) 次(或分批 fan-out) |
| 连接池配置 | 1 集群 | \(N\) 数据源或路由表 |
| 监控告警规则 | 按集群 | 每 DB 或聚合层 |
| 备份/恢复演练 | 1 套 | \(N\) 套或自动化编排 |
| 版本升级 | 1 窗口 | 滚动 \(N\) 窗口 |
| 成本分摊 | 难 | 易(按 DB 计量) |
当 \(N\) 从 100 到 10000,migration orchestration 成为平台团队核心产品——Heroku、Azure SQL elastic pools、PlanetScale branch 等都在降低这一成本,但不消除复杂度。
6.3 路由层:Tenant → DSN
应用需要 tenant router:
# 示意:租户路由配置片段(非生产文件)
tenant_routing:
acme-corp:
dsn: postgres://u:p@db-silo-07.internal/acme
tier: silo
small-shop-9912:
dsn: postgres://u:p@pool-shared.internal/saas_pool
tier: pool
schema: public
tenant_id: "9912"连接池
在此分裂为:每 Silo 一小池 + 共享
Pool 一大池。总连接数上限 \(\sum_i pool_i\) 仍受 PostgreSQL
max_connections 约束——Silo 不是免连接管理。
6.4 与 Citus「大租户」策略
Citus 文档 Dealing with Big Tenants 描述:当单个
company_id 数据量远超中位数,可将其 pin
到专用 worker 或使用 tenant
isolation
特性(版本功能以官方文档为准)。这在经济上等价于
Pool 逻辑 + Silo 物理 的混合,与 Shopify
独占 Pod 同构——不必为了几个 whale 把全体迁到
DB-per-tenant。
七、吵闹邻居:识别与多层防御
Noisy Neighbor 指某一租户对共享资源的过度使用,导致同池其他租户延迟上升或错误率升高。Citus 官方文档将 isolating tenants from noisy neighbors 列为多租户应用典型挑战之一;Kubernetes 与云盘场景下同类问题表现为 noisy neighbor pod——概念同源。
7.1 资源维度清单
| 资源 | 症状 | 典型触发 |
|---|---|---|
| DB 连接 | too many connections、获取连接超时 |
报表 job、连接泄漏、无池化 |
| CPU | P99 升高、GC 停顿 | 复杂 JSON 解析、正则、压缩 |
| 磁盘 IOPS | checkpoint、vacuum 争抢 | 大批量 INSERT、缺失索引 |
| 网络 | 带宽打满 | 大文件导出、备份 |
| 缓存 | hot key 挤占 | 明星租户流量 |
| 消息队列 | lag 扩散 | 租户级 burst publish |
7.2 第一层:Per-tenant Rate Limit
限流与过载保护 讨论的是服务自身的入站保护;多租户场景需把限流维度从 IP / API key 升级为 tenant_id:
- 网关层:每 tenant 令牌桶(token bucket)限制 QPS;
- 应用层:每 tenant 并发 in-flight 请求上限(舱壁);
- 异步层:每 tenant queue depth 与 publish rate。
// 示意:tenant 维度令牌桶(非生产库)
type TenantLimiter struct {
mu sync.Mutex
limiters map[string]*rate.Limiter
r rate.Limit
burst int
}
func (t *TenantLimiter) Allow(tenantID string) bool {
t.mu.Lock()
lim, ok := t.limiters[tenantID]
if !ok {
lim = rate.NewLimiter(t.r, t.burst)
t.limiters[tenantID] = lim
}
t.mu.Unlock()
return lim.Allow()
}分布式限流的一致性(Redis sliding window vs 本地近似)见限流一文;多租户额外要求:认证后的 tenant 与限流键一致,防止攻击者伪造 tenant 耗尽他人配额或绕过自身配额。
7.3 第二层:Quota(存储、对象数、seat)
Rate limit 管速率;quota 管累积:
- 存储字节上限、文档条数、API 调用月配额;
- 超 quota 时 拒绝写 而非拖垮邻居——产品层需明确 UX(升级套餐 vs 硬错误)。
Quota enforcement 点宜在 写路径入口 与 异步 aggregator( nightly 对账),避免扫描全表计量的热路径。
7.4 第三层:连接池配额
连接池设计 指出:PostgreSQL 每连接一 backend 进程,池化是性能刚需。多租户下仅「应用一个大池」时,单 tenant 报表可占满池。
防御模式:
- Per-tenant pool cap:HikariCP
maximumPoolSize无法 per-tenant,需在应用层 semaphore 或 PgBouncer pool_mode + 多 database route 实现。 - PgBouncer per-tenant pool:运维复杂,适合 tenant 数中等、大客户 Silo 场景。
- 只读副本路由:把 analytics 类只读查询赶到 replica,与 OLTP 池分离——数据库扩展 的读写分离在多租户下应 按 tenant tier 开放(小租户禁止 heavy scan)。
flowchart LR
T1[Tenant A OLTP] --> POOL_OLTP[Shared OLTP pool]
T2[Tenant B report] --> POOL_RO[Read replica pool]
POOL_OLTP --> PG_P[(Primary)]
POOL_RO --> PG_R[(Replica)]
图 4:将 heavy read 与 OLTP 连接池分离,降低吵闹邻居对写路径的影响。
7.5 第四层:计算隔离
- Kubernetes:ResourceQuota + LimitRange per namespace;大租户独立 namespace 或 node pool。
- Serverless:并发上限 per account——仍可能有 cold start 争抢。
- Cloudflare Workers:Isolate 级 CPU time limit;与 Cloudflare 架构 中的边缘调度一致。
优雅降级 在多租户场景的用法:非核心 tenant(免费档)在过载时先降级功能,付费核心 tenant 保 SLO——这要求产品层定义 tier 与降级目录,且与限流联动,避免「全体一起坏」。
7.6 场景推演:一次真实的跨租户过载链
以下链条为合成场景,用来说明机制,非特定公司事故报告:
- T0:Tenant W(whale)启动「导出全部订单 CSV」后台任务,未走只读 replica。
- T0+5s:W 占用应用连接池 80% 连接,每条连接执行 seq scan。
- T0+10s:Tenant S(small)正常下单,获取连接排队 > 3s;HTTP 超时触发客户端重试。
- T0+15s:重试放大入站 QPS;若无 per-tenant 限流,S 的重试与 W 的导出共享同一入口配额。
- T0+30s:PostgreSQL
active_connections打满;所有 tenant 写入失败。 - T0+45s:缓存 TTL 过期,读请求穿透,加剧 CPU 与 IOPS 争抢。
止损顺序(与本文分层一致):
- 立即:对 W 的 export API 实施 tenant 级 concurrency = 1,路由到 replica;
- 短期:W 升 tier,迁移到 Bridge/Silo 或独占 Pod;
- 长期:导出仅允许 async job + 对象存储交付,禁止 sync HTTP 大结果集。
若无 tenant 维度指标,步骤 1 的「找出 W」需人工 grep——在千级 tenant 下不可接受。
7.7 观测:如何证明是吵闹邻居
需在 metrics/logs 上 mandatory tenant label(基数受控时)或 采样 trace 带 tenant_id。与 可观测性多租户 交叉:
- Mimir/Loki
ingestion_rateper tenant; - DB 层
pg_stat_activity按application_name或SET标记聚合; - 告警:单 tenant 连接占用 > 阈值、单 tenant P99 / 全局 P99 比值。
没有 tenant 维度指标,吵闹邻居事故只能事后 grep 日志——在 1000 QPS 下不可接受。
八、混合分层:Shopify 式 Pod 与大客户路径
纯 Pool 无法支撑 BFCM 级 flash sale;纯 Silo 无法支撑数百万长尾 merchant。混合分层(Hybrid Tiering) 在同一产品内并存多档资源。
8.1 Shopify Pod 回顾
Shopify 架构 要点:
- Pod = 隔离的数据面单元(MySQL + Redis + memcached),非 K8s Pod;
- Sorting Hat 在边缘注入 Pod 上下文;
- 共享无状态 Worker,禁止跨 Pod I/O;
- 超大 merchant 可独占 entire pod(InfoQ Q&A, Bart de Water);
- Shop Mover + Ghostferry 在 Pod 间迁移 shop,分钟级 cutover。
这是 Pool(多 shop 共 Pod)+ Silo(whale 独占 Pod) 的运行时组合,且与 模块化单体(Packwerk Component)正交——Component 是代码边界,Pod 是数据边界。
8.2 分层决策表
| 信号 | 动作 | Shopify 对应 |
|---|---|---|
| shop 磁盘/连接持续高水位 | Shop Mover 到更空 Pod | rebalance |
| flash sale 预告 | 独占 Pod 或限流 co-tenant | 独占 Pod |
| 新 signup | round-robin 分配 Pod | signup 路由 |
| 跨 shop 产品需求 | 独立服务或数仓 | Shop Pay / 仓库 |
Tenant isolation principle:每个 request/job
只操作单一 shop——产品层应避免需要跨 tenant
原子一致的特性泛滥,否则在线路径会重现
with_each_shard 式脆弱性(Shopify 2018
官方文)。
8.3 自建 SaaS 的 whale 路径
不必照搬 Pod 命名,但逻辑步骤可复用:
- 计量:per-tenant 存储、QPS、连接、P99。
- 阈值策略:超过 P95 租户体量 10× 进入「候选 whale」。
- 迁移 playbook:Bridge/Silo 升级、DNS/路由切换、双写窗口、回滚。
- 定价对齐:whale 资源独占反映在合同,避免 subsidize 长尾。
8.4 与 Cloudflare 租户密度的对照
Cloudflare 在计算侧用 Isolate 换密度,在存储侧仍依赖中心系统——与 Shopify 数据面 Pod 隔离形成对照:边缘适合无状态、短生命周期;核心交易仍倾向 L4 单元化。多租户设计应区分 data plane 与 control plane 的分层策略,不能要求一种模式通吃。
九、跨租户分析:数仓路径与在线路径分离
Pool 模型的一个诱惑是:「既然都在一张表,运营 SQL 很方便。」便利 true,在生产 OLTP 上扫全 tenant 是吵闹邻居 + 一致性风险。
9.1 为何在线路径禁止 cross-tenant fan-out
Shopify 明确:跨 shop 产品(如 Shop Pay)需 独立应用 或 数据仓库——不能指望 monolith 单次 request 扫全 Pod。原因链:
- 延迟:\(\text{latency} \propto \max_i(\text{shard}_i)\) 或求和,取决于并行度;
- 故障:任一 shard 不健康是否让全局失败?2015 前后
with_each_shard教训; - 一致性:跨 tenant 报表 rarely 需要 serializable 跨库事务。
9.2 推荐架构
flowchart LR
OLTP[(OLTP<br/>tenant-scoped writes)]
CDC[CDC / logical replication]
WH[(Warehouse<br/>Snowflake/BigQuery/ClickHouse)]
BI[BI / internal analytics]
OLTP --> CDC --> WH --> BI
与 数据密集型架构 衔接:
- CDC(Debezium、PostgreSQL logical replication)把 tenant 行变更推到 Kafka / object storage;
- Lakehouse / Warehouse 做 cross-tenant aggregation、ML feature、合规审计报表;
- Serving layer 若需 low-latency 大盘,用预聚合表或 OLAP 引擎——不回源扫 OLTP。
9.3 Tenant 键在数仓中的治理
- 事实表保留
tenant_id,与 OLTP 一致; - PII 列在入仓前脱敏(与 可观测性 PII 清洗 同构);
- 内部 BI 账号 RLS 或 view 级 tenant 过滤,防止分析师误看全量。
9.4 Citus 与跨 tenant 查询
Citus 官方 Sharing Data Between Tenants 讨论 reference table(如国家码表)与 distributed table 的配合——跨 tenant 共享只读维表合理;跨 tenant 事实表 JOIN 仍应限制在 coordinator 能高效广播的小结果集,或推到数仓。Multi-tenant 文档强调:典型 SaaS 查询 always scoped to one tenant——这是性能与隔离的双重不变量。
9.5 在线变更 schema 与「全员 migration」
Citus 文档 Online Changes to the Schema 指出:Pool 模型下 一次 DDL 影响所有 tenant。这与 Weiss & Bobrowski 的运维优势一致,但也意味着:
ALTER TABLE ... ADD COLUMN在大表上可能长时间锁或 rewrite——全 tenant 共担;- 长 migration 窗口内,需考虑 expand/contract 与双写,而非直接 destructive DDL;
- Schema-per-tenant 或 DB-per-tenant 可把 migration fan-out 并行化,但 orchestration 成本上升。
工程上常见折中:热路径列走 strict migration;实验性字段走 JSONB,待稳定后再 promote 为列——这与 Salesforce metadata 的「先配置后物化」异曲同工,但类型约束更弱。
9.6 租户差异数据模型(When Data Differs Across Tenants)
Citus 文档 When Data Differs Across Tenants 直面 SaaS 现实:并非所有 tenant 启用相同功能模块。实现选项:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 稀疏列 | 全 tenant 共享宽表,NULL 表示未启用 | 查询简单 | 宽表、索引浪费 |
| JSONB | 每行 features jsonb |
灵活 | 查询与索引复杂 |
| 模块表 | 可选子表仅在有模块时插入 | 规范化 | JOIN 增多 |
| Schema 分叉 | whale 独立 schema | 隔离 | catalog 与 migration 分裂 |
无 universal 最优;选型应绑定 产品如何卖「可选模块」 与 报表是否要 cross-tenant 统一语义。
十、可观测性与存储多租户(轻触)
全站 可观测性系列 对多租户有专篇;此处只强调与 SaaS 应用多租户 的接口。
可观测性多租户 三层:
- 硬隔离:每 tenant 独立 Mimir/Loki cell——对应 Silo;
- 软隔离:
X-Scope-OrgID+ query RBAC——对应 Pool/Bridge 的逻辑隔离; - 混合:关键 tenant 硬隔离,其余软隔离。
与本文第七节的对应:
- 写入限流 ↔︎ Mimir
ingestion_rate/ Lokiingestion_rate_mbper tenant; - 吵闹邻居 ↔︎ 某 team 打满
max_global_series_per_user拖慢 ingester; - 成本分摊 ↔︎ 存储与成本 的 chargeback。
应用层 metrics 的 tenant_id label
与可观测性平台的 org id
应对齐命名空间,避免同一租户在 APM 与 infra
监控中 ID 不一致。高基数 caution:tenant_id
作为 Prometheus label 在 tenant 数极大时可能 cardinality
爆炸——常用 tier 聚合 + 对 whale 单独 label
或 低基数 hash 桶。
十一、开放问题与争论
多租户没有「2026 年标准答案」。以下问题在工业界与学术界仍活跃。
11.1 Pool vs Silo 的自动切换可否无人值守
Shopify Shop Mover 仍需 cutover 窗口与写锁;Citus big tenant pinning 需运维决策。完全自动的 tenant tiering(从 Pool 升到 Silo 再降回)在以下方面未统一:
- 迁移期间 session 粘性 与 in-flight 请求 语义;
- 定价与计费 与资源迁移的联动;
- 回滚:Silo 降回 Pool 的数据如何合并。
代表工程叙述:Shopify Engineering 2018–2022 系列;Citus v11.2 Dealing with Big Tenants。学术上更接近 elastic multi-tenant resource management(云调度 venue),但假设常与 SaaS OLTP 不一致。
11.2 RLS + ORM + 分片的三重一致性
当 Citus distribution column、RLS policy、ORM global scope 三者 tenant 键 命名或类型不一致 时,故障模式极隐蔽。社区尚无「单一 tenant context 贯穿 SQL 生成与 RLS」的通用框架——Prisma、Hibernate 等多租户插件各自为政。
开放点:能否在 PostgreSQL 扩展或 proxy 层(如 Supabase 方向)统一 session tenant 与 shard routing?
11.3 跨 tenant 分析的新鲜度 vs OLTP 隔离
CDC 延迟从秒到分钟;运营要求「实时大盘」时,是否允许 只读 replica 上 cross-tenant 近似查询?近似与精确的业务代价无统一模型。Lambda/Kappa 争论(见 数据密集型架构)在 SaaS 内再现为 OLTP vs 实时数仓 双轨成本。
11.4 插件市场与 Wasm:隔离强语义的成本
Cloudflare 选 Isolate;浏览器外 Wasm 容器(如 wasmCloud)选 module 级沙箱。租户上传代码 的隔离强度与 cold start / 密度 仍 trade-off;Spectre 说明 硬件侧信道 在可预见未来无法被软件完全「修复」,只能缓解。
CIDR 级讨论:edge multi-tenancy 是否终将 回归 lightweight VM(Firecracker 类)——无定论,取决于工作负载从 JS 短函数转向长连接有状态服务(Discord 案例)。
11.5 合规「数据驻留」与全球 Pool
GDPR、数据本地化要求 tenant 数据落在特定 region。全球统一 Pool 与 geo-sharded tenant home 冲突。Tenant 级 region 绑定 + 跨 region 禁止 replicate 增加路由与 failover 复杂度——容灾架构 的单元化思路需与 tenant home 结合,尚无低成本通用模板。
11.6 删除权与「被遗忘」的物理成本
GDPR Right to Erasure 在 Pool 模型下是 带 tenant_id 的 DELETE 或 anonymize——行数巨大时,单次删除可能触发 autovacuum 与索引维护长尾。DB-per-tenant 可 DROP DATABASE 作为终极删除,审计叙事清晰,但 backup 与 replica 上的残留仍需 lifecycle 策略。Crypto-shredding( per-tenant DEK 销毁)在混合模型中的密钥管理仍是开放工程话题,学术上相关 work 分散在 cloud storage 与 TEE 领域,尚无 SaaS 通用最佳实践。
11.7 多租户测试:如何不遗漏 cross-tenant 断言
文献较少系统讨论,但工程社区共识趋向:
- 每个集成测试 fixture 至少两个 tenant,断言 A 的 token 不能读 B 的 id;
- Property-based 随机 tenant 插入后,用错误 context 查询必空;
- Chaos:随机 kill 连接池连接,验证
SET LOCAL不会使下一请求继承错误 tenant。
这与 混沌工程 结合,可发现池化 + RLS 的边界 bug——但 tenant 级 fault injection 工具链仍不成熟,多数团队依赖 code review 与静态 SQL 分析。
十二、小结:决策清单
多租户架构的核心难题不是「选 shared table 还是独立库」,而是在 Silo / Bridge / Pool 谱系 上为不同生命周期阶段的租户放置合适档位,并在 L1–L4 至少两层同时加固。
数据模型
- 长尾:Pool +
tenant_id+ 索引前缀;Citus 则 distribution column 进 PK/FK。 - 中等定制:Schema-per-tenant,警惕 catalog bloat。
- Whale / 合规:DB-per-tenant 或 Pod/节点 pinning。
安全
- 应用 tenant context 不可伪造;
- RLS 作 Pool 的 defense in
depth,配合连接池
SET LOCAL防 session 泄漏; - 超级用户与 BYPASSRLS 角色不得用于应用。
吵闹邻居
- Per-tenant rate limit + quota(限流);
- 连接池分区 / 只读分离(连接池);
- 计算隔离(namespace、Isolate、独占 Pod);
- 过载时 tiered 降级(优雅降级)。
分析与跨 tenant
案例锚点
- 数据面单元化:Shopify Pod;
- 计算高密度隔离:Cloudflare V8 Isolate;
- 分布式 Pool:Citus Multi-tenant Applications(v11.2)。
下一篇 架构的不变量 将从穿越技术周期的设计原则收束本系列——多租户的具体模式会变,隔离、公平、blast radius 控制 作为质量属性的权重不会下降。
参考资料
规范与官方文档
- PostgreSQL Documentation. 5.8. Row Security
Policies.
https://www.postgresql.org/docs/current/ddl-rowsecurity.html - Citus Data. Multi-tenant Applications. Citus
11.2 Documentation.
https://docs.citusdata.com/en/v11.2/use_cases/multi_tenant.html - Citus Data. Distributed Data Modeling. Citus
11.2 Documentation.
https://docs.citusdata.com/en/v11.2/sharding/data_modeling.html
论文与书籍
- Weiss, A., Bobrowski, V. The Multi-Tenant Data Architecture of Salesforce.com. IEEE International Conference on Web Services (ICWS), 2009.
- Jacobs, D., Gilmore, A. Multi-Tenant Database Architecture. Salesforce.com Engineering. (与 Salesforce 元数据多租户模型相关,常被与 ICWS 2009 一并引用。)
- Kleppmann, M. Designing Data-Intensive Applications. O’Reilly, 2017. (第 6 章数据模型与第 9 章分区,与 tenant 分区设计相关。)
工程案例(B 级)
- Shopify Engineering. A Pods Architecture. 2018-03-02. (见 Shopify 架构 参考文献。)
- Cloudflare Blog. Cloudflare Workers. V8 Isolate 与多租户边缘计算。 (见 Cloudflare 架构 参考文献。)
- Google. Site Reliability Engineering. O’Reilly, 2016. 第 22 章 cascading failures 与 degraded modes。 (见 优雅降级。)
站内延伸
上一篇:数据密集型应用架构
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计】Shopify 架构:Rails 单体的极限突破
Shopify 在 Ruby on Rails 单体上承载数百万商户的多租户电商。本文沿官方工程博客与 InfoQ 演讲脉络,梳理 2015 分片触顶、Pod 完全隔离、Sorting Hat 路由、模块化单体与 Packwerk 边界、Checkout/Storefront 分叉,以及 BFCM 生产压测策略,并区分运行时 Pod 与代码 Component 两套正交维度。
【金融科技工程】账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户
账务(Ledger)数据库是金融系统最硬的那块骨头。本文从 RPO/RTO 目标出发,对比 PostgreSQL、MySQL、OceanBase、TiDB、CockroachDB、Oracle、TigerBeetle 等主流选型,讲分片维度、热点账户拆解、索引设计、冷热归档、MVCC 并发控制与审计合规,辅以蚂蚁、Stripe、PayPal、Square 的真实演进路径。
【系统架构设计】连接池设计:被忽视的性能杀手
每一次网络请求的背后,都隐藏着建立连接的成本。当应用服务器需要与数据库通信时,一次完整的连接建立过程可能消耗数十毫秒;在高并发场景下,频繁创建和销毁连接会迅速耗尽系统资源,成为整个架构中最容易被忽视的性能瓶颈。连接池(Connection Pool)技术通过预先创建并复用连接,将单次连接获取的时间从毫秒级压缩到微秒级,…
【系统架构设计】限流与过载保护:保命的最后一道防线
健康的下游也能被合法流量打垮。本文从一次缓存击穿引发的过载场景出发,梳理令牌桶、漏桶、固定/滑动窗口限流算法的公式与权衡,拆解客户端到网关到服务端的多层限流部署、自适应过载控制的分歧,以及分布式限流一致性、重试放大等失败模式。