土法炼钢兴趣小组的算法知识备份

【系统架构设计】多租户架构:SaaS 系统的核心设计难题

文章导航

分类入口
architecture
标签入口
#multi-tenant#SaaS#tenant-isolation#RLS#Citus#noisy-neighbor#sharding#PostgreSQL#connection-pool#rate-limiting

目录

一个 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:

争论点不在于「哪档更好」,而在于租户生命周期中何时升级档位、升级是否可逆、路由层能否无感切换。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 的多租户数据架构概括为三层:

  1. 内核(Core):所有 org 共享的表结构与索引;
  2. 元数据(Metadata):描述每个 org 的 UI、工作流、字段级定制——存在 shared catalog,运行时解释执行;
  3. 数据(Data):业务行带 org id,与内核表 JOIN。

这一「统一 schema + 元数据驱动差异」直接映射到现代 Pool 模型:JSONB 扩展字段、feature flag、tenant 级配置表取代了专有 metadata 引擎,但「一次 migration、全员生效」的经济学不变。Jacobs & Gilmore 对 Salesforce 数据库层的早期描述(常被与上述论文一并引用)强调:共享数据库进程是 SaaS 毛利率的前提——独立 DB per org 在 Salesforce 目标客户规模下不可想象。

分叉发生在数据量与租户体量方差超过单机 PostgreSQL 之后:

争论点:元数据驱动 vs 代码分支。Salesforce 式 metadata 允许「无代码配置新字段」;多数中小 SaaS 选择「代码 + migration + JSONB」——配置灵活性 vs 类型安全与查询性能之间的 trade-off 无统一最优,取决于产品是否允许租户 arbitrary schema。

1.4 与 数据库扩展 的关系

分库分表、读写分离、分布式 SQL 解决的是单租户或全局数据量的扩展;多租户解决的是租户间隔离与公平性。二者正交:

选型时应先画隔离谱系,再画扩展维度;把「我们用了 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 仍可能读到别家行。L3Cloudflare 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

  1. 边缘注入:Shopify Sorting Hat 在 OpenResty 层查 domain → pod_id / shop;Cloudflare 在 Workers 调度层绑定 script id。
  2. 服务内传播:Go 的 context.Context、Java 的 ThreadLocal、Rails 的 Current attributes——必须在 middleware 入口设置,在 outbound call 与 DB 访问前断言存在。
  3. 禁止客户端自选 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 内存开销远低于完整容器,切换成本低,适合边缘百万级轻量脚本。代价是:

对自建 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 选择连接池。关键约束:

这把 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;

索引设计原则:

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 的优势与隐性成本

优势(有文献与工程共识支撑):

隐性成本:

3.4 与 ORM Global Scope 的争论

社区对 default_scope 类机制有两派:

工程判断:至少要有单一入口函数(如 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>';

要点:

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,可能出现:

PostgreSQL 社区与 Citus 文档均建议:distribution column / tenant 列上必须有索引;RLS 是安全网,不是替代索引设计。性能敏感路径仍应在应用层带 tenant 谓词——RLS 防的是遗漏,不是替代查询优化。

4.3 RLS 不能单独支撑的合规叙事

RLS 无法防御:

因此 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 策略同理。集成测试应包含:

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.orderstenant_99.orders。这是 Bridge 的常见实现:共享 DB 实例、逻辑分 schema

5.1 动机

5.2 Catalog 膨胀(Catalog Bloat)

PostgreSQL 系统目录(pg_classpg_attributepg_depend 等)为每个 relation 存元数据。Schema-per-tenant 意味着:

数量级示意(非实测,仅说明阶数):若每租户 50 张表、1 万租户,即 50 万 user tables——catalog 本身成为瓶颈,而非数据行数。业界经验阈值常落在 数千 schema 量级开始需要专门运维(分区 catalog、限制每实例 schema 数、升 Silo/分实例),具体取决于 PG 版本与硬件;本文不给出虚假 benchmark 数字。

5.3 连接与迁移运维

每个请求需 SET search_path TO tenant_X 或 fully-qualified 名称。与连接池组合时:

Schema 级 migration 工具(Rails Apartment、django-tenants)要处理:

5.4 何时从 Pool 迁到 Schema-per-tenant

合理触发条件:

不合理动机:「schema 听起来比 tenant_id 更安全」——若无 RLS/审计,schema 误设 search_path 仍会串租。


六、Silo 变体:Database-per-tenant

每租户独立 database(或独立实例)是 Bridge 向 Silo 的进一步移动:连接字符串级隔离

6.1 收益

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 维度令牌桶(非生产库)
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 管累积

Quota enforcement 点宜在 写路径入口异步 aggregator( nightly 对账),避免扫描全表计量的热路径。

7.4 第三层:连接池配额

连接池设计 指出:PostgreSQL 每连接一 backend 进程,池化是性能刚需。多租户下仅「应用一个大池」时,单 tenant 报表可占满池

防御模式:

  1. Per-tenant pool cap:HikariCP maximumPoolSize 无法 per-tenant,需在应用层 semaphorePgBouncer pool_mode + 多 database route 实现。
  2. PgBouncer per-tenant pool:运维复杂,适合 tenant 数中等、大客户 Silo 场景。
  3. 只读副本路由:把 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 第四层:计算隔离

优雅降级 在多租户场景的用法:非核心 tenant(免费档)在过载时先降级功能,付费核心 tenant 保 SLO——这要求产品层定义 tier 与降级目录,且与限流联动,避免「全体一起坏」。

7.6 场景推演:一次真实的跨租户过载链

以下链条为合成场景,用来说明机制,非特定公司事故报告:

  1. T0:Tenant W(whale)启动「导出全部订单 CSV」后台任务,未走只读 replica。
  2. T0+5s:W 占用应用连接池 80% 连接,每条连接执行 seq scan。
  3. T0+10s:Tenant S(small)正常下单,获取连接排队 > 3s;HTTP 超时触发客户端重试。
  4. T0+15s:重试放大入站 QPS;若无 per-tenant 限流,S 的重试与 W 的导出共享同一入口配额。
  5. T0+30s:PostgreSQL active_connections 打满;所有 tenant 写入失败。
  6. T0+45s:缓存 TTL 过期,读请求穿透,加剧 CPU 与 IOPS 争抢。

止损顺序(与本文分层一致):

若无 tenant 维度指标,步骤 1 的「找出 W」需人工 grep——在千级 tenant 下不可接受。

7.7 观测:如何证明是吵闹邻居

需在 metrics/logs 上 mandatory tenant label(基数受控时)或 采样 trace 带 tenant_id。与 可观测性多租户 交叉:

没有 tenant 维度指标,吵闹邻居事故只能事后 grep 日志——在 1000 QPS 下不可接受。


八、混合分层:Shopify 式 Pod 与大客户路径

纯 Pool 无法支撑 BFCM 级 flash sale;纯 Silo 无法支撑数百万长尾 merchant。混合分层(Hybrid Tiering) 在同一产品内并存多档资源。

8.1 Shopify Pod 回顾

Shopify 架构 要点:

这是 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 命名,但逻辑步骤可复用:

  1. 计量:per-tenant 存储、QPS、连接、P99。
  2. 阈值策略:超过 P95 租户体量 10× 进入「候选 whale」。
  3. 迁移 playbook:Bridge/Silo 升级、DNS/路由切换、双写窗口、回滚。
  4. 定价对齐:whale 资源独占反映在合同,避免 subsidize 长尾。

8.4 与 Cloudflare 租户密度的对照

Cloudflare计算侧用 Isolate 换密度,在存储侧仍依赖中心系统——与 Shopify 数据面 Pod 隔离形成对照:边缘适合无状态、短生命周期;核心交易仍倾向 L4 单元化。多租户设计应区分 data planecontrol plane 的分层策略,不能要求一种模式通吃。


九、跨租户分析:数仓路径与在线路径分离

Pool 模型的一个诱惑是:「既然都在一张表,运营 SQL 很方便。」便利 true,在生产 OLTP 上扫全 tenant 是吵闹邻居 + 一致性风险

9.1 为何在线路径禁止 cross-tenant fan-out

Shopify 明确:跨 shop 产品(如 Shop Pay)需 独立应用数据仓库——不能指望 monolith 单次 request 扫全 Pod。原因链:

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

数据密集型架构 衔接:

9.3 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 的运维优势一致,但也意味着:

工程上常见折中:热路径列走 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 应用多租户 的接口。

可观测性多租户 三层:

与本文第七节的对应:

应用层 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 再降回)在以下方面未统一:

代表工程叙述: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 tenantshard 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 断言

文献较少系统讨论,但工程社区共识趋向:

这与 混沌工程 结合,可发现池化 + RLS 的边界 bug——但 tenant 级 fault injection 工具链仍不成熟,多数团队依赖 code review 与静态 SQL 分析。


十二、小结:决策清单

多租户架构的核心难题不是「选 shared table 还是独立库」,而是在 Silo / Bridge / Pool 谱系 上为不同生命周期阶段的租户放置合适档位,并在 L1–L4 至少两层同时加固。

数据模型

安全

吵闹邻居

分析与跨 tenant

案例锚点

下一篇 架构的不变量 将从穿越技术周期的设计原则收束本系列——多租户的具体模式会变,隔离、公平、blast radius 控制 作为质量属性的权重不会下降。


参考资料

规范与官方文档

  1. PostgreSQL Documentation. 5.8. Row Security Policies. https://www.postgresql.org/docs/current/ddl-rowsecurity.html
  2. Citus Data. Multi-tenant Applications. Citus 11.2 Documentation. https://docs.citusdata.com/en/v11.2/use_cases/multi_tenant.html
  3. Citus Data. Distributed Data Modeling. Citus 11.2 Documentation. https://docs.citusdata.com/en/v11.2/sharding/data_modeling.html

论文与书籍

  1. Weiss, A., Bobrowski, V. The Multi-Tenant Data Architecture of Salesforce.com. IEEE International Conference on Web Services (ICWS), 2009.
  2. Jacobs, D., Gilmore, A. Multi-Tenant Database Architecture. Salesforce.com Engineering. (与 Salesforce 元数据多租户模型相关,常被与 ICWS 2009 一并引用。)
  3. Kleppmann, M. Designing Data-Intensive Applications. O’Reilly, 2017. (第 6 章数据模型与第 9 章分区,与 tenant 分区设计相关。)

工程案例(B 级)

  1. Shopify Engineering. A Pods Architecture. 2018-03-02. (见 Shopify 架构 参考文献。)
  2. Cloudflare Blog. Cloudflare Workers. V8 Isolate 与多租户边缘计算。 (见 Cloudflare 架构 参考文献。)
  3. Google. Site Reliability Engineering. O’Reilly, 2016. 第 22 章 cascading failures 与 degraded modes。 (见 优雅降级。)

站内延伸

  1. 连接池设计
  2. 限流与过载保护
  3. 优雅降级
  4. 数据密集型架构
  5. 可观测性:多租户与安全

上一篇:数据密集型应用架构

下一篇:架构的不变量:穿越技术周期的设计原则

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-29 · architecture

【系统架构设计】Shopify 架构:Rails 单体的极限突破

Shopify 在 Ruby on Rails 单体上承载数百万商户的多租户电商。本文沿官方工程博客与 InfoQ 演讲脉络,梳理 2015 分片触顶、Pod 完全隔离、Sorting Hat 路由、模块化单体与 Packwerk 边界、Checkout/Storefront 分叉,以及 BFCM 生产压测策略,并区分运行时 Pod 与代码 Component 两套正交维度。

2026-04-22 · architecture / fintech

【金融科技工程】账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户

账务(Ledger)数据库是金融系统最硬的那块骨头。本文从 RPO/RTO 目标出发,对比 PostgreSQL、MySQL、OceanBase、TiDB、CockroachDB、Oracle、TigerBeetle 等主流选型,讲分片维度、热点账户拆解、索引设计、冷热归档、MVCC 并发控制与审计合规,辅以蚂蚁、Stripe、PayPal、Square 的真实演进路径。

2026-04-13 · architecture

【系统架构设计】连接池设计:被忽视的性能杀手

每一次网络请求的背后,都隐藏着建立连接的成本。当应用服务器需要与数据库通信时,一次完整的连接建立过程可能消耗数十毫秒;在高并发场景下,频繁创建和销毁连接会迅速耗尽系统资源,成为整个架构中最容易被忽视的性能瓶颈。连接池(Connection Pool)技术通过预先创建并复用连接,将单次连接获取的时间从毫秒级压缩到微秒级,…

2026-07-28 · architecture

【系统架构设计】限流与过载保护:保命的最后一道防线

健康的下游也能被合法流量打垮。本文从一次缓存击穿引发的过载场景出发,梳理令牌桶、漏桶、固定/滑动窗口限流算法的公式与权衡,拆解客户端到网关到服务端的多层限流部署、自适应过载控制的分歧,以及分布式限流一致性、重试放大等失败模式。


By .