Day 37 Hard Multi-tenant 租户隔离 数据分区 资源配额 用量计费

多租户 SaaS 架构 — 一套系统服务一万个互不信任的租户Multi-tenant SaaS: Tenant Isolation, Data Partitioning, Quotas & Metered Billing

问题场景 + 需求约束

设计一个 B2B 协作 SaaS(类 Notion / 类 Figma),1 万个企业租户、共 500 万用户、单库存不下。租户从 5 人小团队到 5 万人大客户,体量差三个数量级。核心矛盾:一套基础设施服务所有人换取成本效率,但任意两个租户互不信任——A 租户绝不能读到 B 的数据,A 的批量导入也不能拖垮 B 的在线请求。

本期讲四件事:隔离模型怎么选、数据怎么分区、噪声邻居怎么治、用量怎么算钱

高层架构

graph TD C[租户请求] --> GW[API Gateway
解析 tenant_id] GW --> AUTH[AuthN/AuthZ
注入 tenant context] AUTH --> RL[Per-tenant 限流/配额] RL --> APP[共享应用层
无状态] APP --> ROUTER[Tenant Router
tenant_id 到 shard 映射] ROUTER --> S1[(Shard 1
pool 小租户)] ROUTER --> S2[(Shard 2
pool 小租户)] ROUTER --> SILO[(Silo DB
大客户独享)] APP --> MET[用量事件
metering pipeline] MET --> BILL[计费/账单
Stripe] ROUTER -.查.-> CAT[(Tenant Catalog
路由+配置元数据)]

组件职责:网关从 subdomain / JWT 解出 tenant_id贯穿全链路成为一等公民;Tenant Catalog 是控制平面,存每个租户的 shard 归属、隔离级别、配额、计费计划;数据平面按 catalog 路由——小租户混在共享 shard(pool),大客户切到独享库(silo)。用量事件旁路异步流向计费。

关键技术点

1. 隔离模型:Silo vs Pool vs Bridge — 安全与成本的连续光谱

核心 trade-off:每多一分隔离,多一分安全/合规,少一分成本效率与运维可扩展性。

【原理】多租户隔离不是布尔值而是光谱。AWS 的术语把它分三档:Silo(每租户独享栈/库,物理隔离)、Pool(所有租户共享资源,靠应用层逻辑隔离)、Bridge(混合:该独享的独享、该共享的共享)。关键洞察是隔离边界可以逐层选择——计算层 pool、存储层对大客户 silo,完全合法。

Silo:每租户独立 DB/集群。✅ 爆炸半径小、合规简单、噪声邻居天然消失、计费=直接看资源账单。❌ 成本随租户线性增长、1 万个库的 schema 迁移是噩梦、资源利用率低(小租户也占一整套)。
Pool:共享表,靠 tenant_id 列 + 行级隔离。✅ 成本效率极高、一次迁移全覆盖、利用率高。❌ 一个漏 WHERE 就是跨租户数据泄露、噪声邻居、爆炸半径=全员。
Bridge:默认 pool,大客户/高合规客户 silo。✅ 80% 成本效率 + 20% 客户的强隔离卖点。❌ 两套代码路径、路由复杂度。
现实案例AWS《SaaS Tenant Isolation Strategies》白皮书系统化了 silo/pool/bridge 三模型,是行业事实标准词汇。Salesforce 是经典 pool(共享元数据驱动的 schema 服务海量租户);多数成熟 SaaS 最终演进成 bridge——免费/小客户 pool、企业版 silo。

2. 数据分区:按 tenant_id 分片 + 行级隔离 — 倾斜是头号敌人

核心 trade-off:共享 schema 省成本但隔离全靠代码纪律;schema/DB-per-tenant 隔离硬但运维不可扩展。

【原理】pool 模型下三种粒度:① 共享表 + tenant_id 列(最省,靠 Postgres RLS 兜底防漏查);② schema-per-tenant(一租户一 schema);③ database-per-tenant(=silo)。SaaS 数据天然以 tenant_id 为分片键——同租户数据落同一物理 shard,绝大多数查询带 tenant_id,不产生跨 shard 扇出,这是 SaaS 相比通用分片的巨大优势。

难点是倾斜:随机 hash(tenant_id) 到 shard,但一个巨型租户可能塞爆单 shard。解法是把热租户单独 isolate 到专属 shard

# Postgres RLS:纵深防御,即便应用漏写 WHERE tenant_id 也兜底
ALTER TABLE docs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_iso ON docs
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

# 每个请求开头注入 tenant context(连接池复用要在事务内 set local)
SET LOCAL app.tenant_id = '...';   # 之后所有查询自动被 RLS 过滤

# 路由:默认 hash 分片,热租户查 catalog 覆盖
def route(tenant_id):
    t = catalog.get(tenant_id)
    if t.dedicated_shard:        # 大客户 isolate
        return t.dedicated_shard
    return shards[hash(tenant_id) % len(shards)]
现实案例Notion『The Great Re-shard』——按 workspace ID 分区,同 workspace 全部数据落同一 shard,扩容时从 32 库 reshard 到 96 库零停机。Figma把『逻辑分片』(应用层 + view)与『物理分片』(Postgres 层)解耦,先低风险铺逻辑分片再做物理切换,并自建 DBProxy 路由。AWS 详述用 Postgres RLS 做租户隔离的范式。

3. 噪声邻居与资源配额 — 公平性是 pool 模型的命门

核心 trade-off:硬隔离(独享资源)杜绝传染但浪费;软配额(共享 + 公平调度)省钱但要主动治理。

【原理】pool 下,A 租户一次百万行批量导入会吃光连接池/CPU,B 租户的在线请求被饿死——噪声邻居。三道防线:① 入口限流(per-tenant token bucket,按计费计划分级);② 资源配额(每租户最大连接数、查询超时、存储上限);③ 公平调度(独立队列轮询,而非 FIFO 共享队列让大租户插队)。关键是配额要按租户而非全局——全局限流挡不住单租户打爆。

# Per-tenant 令牌桶 + 公平队列(避免单租户独占 worker)
def admit(tenant_id, cost):
    bucket = buckets[tenant_id]            # 按计费计划设容量
    if not bucket.try_consume(cost):
        raise RateLimited(tenant_id)        # 429,带 Retry-After
    enqueue(tenant_queues[tenant_id])       # 每租户独立队列

def dispatch():                             # worker 轮询而非抢占
    for q in round_robin(tenant_queues):    # 大租户也只能拿到公平份额
        if (job := q.poll()): run(job)
现实案例Stripe 公开过多层 rate limiter(含按用户/load shedding 分级),核心思想就是隔离突发租户保护整体。AWS SaaS Lens 把『noisy neighbor』列为 pool 模型头号风险并推荐 per-tenant throttling + usage-based quota。

4. 用量计量与计费集成 — 账单错一分钱都是事故

核心 trade-off:实时精确计量成本高;批量近似省钱但对账困难。账单要求 exactly-once 语义。

【原理】混合计费(席位 + 用量)的难点在用量:每次 API 调用/存储增量是一个 usage event,要不丢不重地聚合成账单。流水线:应用埋点 → 事件队列 → 聚合(按 tenant×metric×时间窗)→ 推送给 Stripe metered billing。关键设计:每个 usage event 带幂等键(防重试重复计费)、聚合用 at-least-once 投递 + 下游幂等去重、用量数据是财务真相源要持久化可重算(出账争议时能回放)。

# 幂等用量事件:idempotency_key 防止重试导致重复计费
event = {
  "idempotency_key": f"{tenant}:{request_id}",   # 唯一
  "tenant_id": tenant, "metric": "api_call",
  "qty": 1, "ts": now,
}
emit(usage_topic, event)        # at-least-once 投递

# 聚合:按 (tenant, metric, hour) 累加,去重靠 idempotency_key
INSERT INTO usage_agg(tenant_id, metric, window, qty)
SELECT tenant_id, metric, date_trunc('hour', ts), sum(qty)
FROM (SELECT DISTINCT ON (idempotency_key) * FROM raw_events) d
GROUP BY 1,2,3;
现实案例Stripe usage-based / metered billing 提供带幂等键的用量上报 API,正是为防重复计费设计;席位+用量混合是 SaaS 标配(Notion/Figma 按编辑席位、API 类产品按调用量)。AWS SaaS 架构强调 metering 必须与 tenant context 绑定,否则无法归因到租户出账。

扩展与优化

常见陷阱 + 面试问题

1. 漏写 WHERE tenant_id = 跨租户数据泄露:pool 模型最致命的 bug,且测试很难覆盖。纵深防御:RLS 兜底 + ORM 强制注入 tenant scope + 自动化测试模拟跨租户访问。
2. 连接池被单租户耗尽:共享连接池下,一个慢查询/大事务的租户会饿死全部。要 per-tenant 连接配额或语句超时。
3. 全局唯一约束在分片后失效:邮箱唯一、序号自增在跨 shard 后不再全局成立。要么把唯一性限定在租户内,要么用全局 ID 服务(见 Day 11 Snowflake)。
4. 用量事件丢失/重复:at-most-once 漏算少收钱、无幂等的 at-least-once 重复多收钱被投诉。必须幂等键 + 可重算。

面试追问:

  1. silo / pool / bridge 怎么选?什么信号触发把一个租户从 pool 迁到 silo?
  2. pool 模型如何在代码层和 DB 层双重防止跨租户数据泄露?
  3. 一个巨型租户占了单 shard 80% 容量,怎么办?resharding 怎么做到零停机?
  4. 设计 per-tenant 限流,免费版和企业版配额不同,热点租户突发怎么不影响别人?
  5. 按用量计费的流水线如何保证账单 exactly-once、出账争议时可回放?

深入资源

深入思考(点击展开答案)

1. pool 模型省成本,为什么大客户反而常常要求 silo?这背后是技术问题还是商业问题?

两者都是,且常常以商业理由包装技术诉求。

  • 合规/审计:金融、医疗客户的 SOC2/HIPAA 审计要求『可证明的隔离』,pool 的逻辑隔离虽然安全,但向审计员『证明』一行漏查不会泄露,远比指着一个独立数据库难。silo 是可审计性的捷径。
  • 数据驻留:『我的数据必须在欧盟境内』在 pool 下要按行路由,silo 直接整库放在某区域。
  • 爆炸半径:大客户不想和别人共担风险——别的租户被攻破或触发 bug,不应影响自己。
  • 性能 SLA:独享资源=可承诺的性能下限,pool 永远有噪声邻居的不确定性。
  • 商业杠杆:silo 是溢价卖点。厂商乐意——既满足客户、又能多收钱补偿成本。所以 bridge 几乎是所有成熟 SaaS 的终局。
2. 你用 hash(tenant_id) 均匀分片,但生产中某 shard 的 p99 持续比别人高 3 倍。可能原因?

hash 让租户数量均匀,但不让负载均匀——这是 SaaS 倾斜的本质。

  • 体量倾斜:一个 5 万人大租户和几百个小租户 hash 到同一 shard,数据量和 QPS 完全压垮它。这是最常见原因。
  • 行为倾斜:某租户刚跑了批量导入/全量导出,临时打满该 shard。
  • 大 key/大事务:某租户单文档巨大、或长事务持锁,拖慢同 shard 所有人。
  • 修法:监控 per-shard + per-tenant 负载,把热租户 isolate 到专属 shard(catalog 覆盖路由);对超大租户考虑租户内二级分片。注意:盲目加 shard 数不解决问题,因为热点是单租户造成的,rehash 后它还是和别人挤在一起。
3. 全局限流(如整体 10万 QPS)已经存在,为什么还必须 per-tenant 限流?两者关系?

全局限流保护系统不被打挂,per-tenant 限流保护租户之间的公平性——两个正交目标。

  • 只有全局限流:单个租户可以合法地占满 10 万 QPS 的 99%,其他所有租户被它挤到 429。系统没挂,但对其余租户=不可用。这就是噪声邻居在限流层的体现。
  • per-tenant 限流给每个租户一个上限(按计费计划分级),保证任意单租户无法独占全局容量。
  • 关系:两层叠加。per-tenant 是第一道(公平),全局是兜底(保命,应对所有租户同时突发的极端情况 + load shedding)。理想还要有动态公平份额:系统空闲时允许租户超出基础配额(突发友好),系统繁忙时回落到保证份额(weighted fair queuing 思路)。
4. 用量计费要 exactly-once,但分布式系统做不到真正的 exactly-once 投递。怎么办?

用『at-least-once 投递 + 下游幂等去重』组合出 effectively-once 的效果——这是分布式计费的标准范式(和 Day 8 消息队列、Day 17 支付幂等同源)。

  • 投递保证 at-least-once:宁可重复也不能丢(丢=少收钱,账对不上)。生产者重试、队列持久化。
  • 幂等去重:每个 usage event 带全局唯一 idempotency_key(如 tenant:request_id),聚合时按 key 去重(DISTINCT / upsert),重复事件被吸收。
  • 可重算(真相源):原始 usage events 持久化保留(不只存聚合值)。出账争议、聚合 bug、迟到事件时,能从原始流回放重算账单。聚合值是派生的,raw events 才是财务真相。
  • 时间窗 + 迟到处理:用 watermark 处理跨小时边界的迟到事件(参考 Day 20 流处理),避免把昨天的用量算进今天。
5. 一个 pool shard 上有 5000 个租户共享一张表,你要给其中一个租户做 schema 迁移(加一列+回填)。这比单租户系统难在哪?

难点不在『加列』,而在共享表上的操作会波及无关租户

  • 回填波及全员:加列后回填,UPDATE 扫的是整张共享表(5000 租户的数据),不是目标租户那部分——锁、IO、复制延迟全员买单,违反隔离。要分批回填 + 限速,且 WHERE 必须带 tenant_id 只回填目标租户(但加列本身是表级 DDL,避不开)。
  • DDL 是表级的:Postgres 加列即使是 metadata-only 也要短暂表级锁,会卡住所有 5000 个租户的写。要用 online DDL 工具或选低峰、设短 lock_timeout 重试。
  • 不能只给一个租户改 schema:pool 共享 schema,加的列对全员可见——这是 pool 的根本约束。如果一个租户要定制字段,要么用 JSONB 扩展列、要么这就是把它迁到 silo 的信号。
  • 对比单租户:单租户系统迁移只影响自己,可随意停机窗口;pool 下任何 DDL 都是对全体租户的共享变更,风险和协调成本指数级上升。这正是 silo 在『定制化/迁移灵活性』上的隐藏优势。