Multi-Tenant 架构最佳实践
本文是通用方法论。retaintive V1 实际落地口径 → 看
why-tenant.md(原上线方案已归档到docs/archive/multi-tenant-v1-plan/)。Final / long-term refactor → 看final/migration-plan.md。
一、北极星原则
Tenant 是头等公民。Auth ≠ Isolation。Strategic 早做,Tactical 延后。
Tenant 不是某张表上的一个外键。它是整个系统的第一维度 — schema、API、queue、observability、billing、admin、deploy region 都要按它切。Auth 只证明"用户合法登录",不证明"这个用户只能看自己 tenant 的数据"。隔离必须在每一条数据访问路径上独立强制(DB query / background job / cache / log / search index)。
Strategic 决策(隔离模型、命名层级、tier 映射、provider 抽象)改起来贵 — 早。Tactical 实现(RLS policy、rate limit 数值、cache TTL)可以增量上 — 延后。
5 条硬规则
- 隔离:跨 tenant 绝对不串。同 tenant 内,跨 location 允许(一个 tenant 自己的多个门店 / 仓库 / 子团队可以互看)。
- 计费:默认 1 tenant = 1 billing customer。Stripe 接入后通常是 1 tenant = 1 Stripe Customer;manual invoice / reseller / enterprise contract 可以通过
billing_accounts表表达(Final phase only,V1 不建,字段挂tenants表里),不要把 billing exception 混进数据隔离边界。升级路径见下方 Stripe 计费层升级路径 — BPO 真要分账时切 Stripe Connect Connected Accounts。 - BPO:BPO(Business Process Outsourcing)公司是 tenant,end-clients 是 tenant_clients(业务字段,不是隔离层)。BPO 一家代运营 50 个品牌 = 1 个 tenant + 50 个 tenant_clients;不是 50 个 tenant。
- 扩张:tenant ↔ location 是 1:N。N 改字段不动 schema — 加店、关店、改 vertical 全是 INSERT/UPDATE 行,不是 ALTER TABLE。
- Placement ↔ 部署:tier 是商业包装,placement 是基础设施路由。不要把 tier hardcode 成 Pool/Silo。少量高价值客户可以 all-Silo;大量低价 self-serve / free trial 才需要 Pool/Hybrid。
Stripe 计费层升级路径
Decision:Stripe 计费抽象分 3 层 — V1 用最简单的 Customer 层,BPO 出现后才升 Connected Accounts。
Why this:Stripe 有 3 个层次的 billing model,不同业务复杂度对应不同层。一开始就上 Connect = over-engineering,但等真有 BPO 客户再迁也是工程量。提前预留字段才能平滑升级。
3 层 Stripe 模型:
升级判定:
字段预留:当前 V1 identity-only 没有落 billing 字段。第一个 L1 billing / Stripe integration 触发时,先在 tenants 或 future billing_accounts 加 billing_provider_customer_id (nullable 字段),表达默认的 1 tenant = 1 Stripe Customer。billing_provider_account_id 只属于未来 Connect/BPO 路径:若建 tenant_clients 或 billing_accounts,Day 1 就预留 nullable 字段,不要把 Connect account id 混进当前 identity-only V1。
Why not 一开始就上 Connect:Connect 比 Customer 复杂很多 — 需要处理 onboarding KYC、payout、reverse transfer、platform fee 等。3 个客户都是 L1 时上 Connect = 3 倍 onboarding 复杂度换 0 业务价值。
Why not 等 BPO 来再改 flow:加字段不可怕,改 billing flow 才贵。第一个 L1 billing integration 可以只落 billing_provider_customer_id,但 webhook / invoice pipeline 要把 Customer 与 Connect 的边界留出来;一旦写成"永远假设全是 Customer",未来要插 Connect 就等于重构 webhook handler。预留扩展点是廉价保险。
Reference:
- Stripe Connect — Accounts v2 API (2025-12 update)
- Stripe Multiple Separate Accounts
- Building Multi-Tenant SaaS with Stripe Connect (2026)
Consequences:
- V1 webhook handler 只接
customer.subscription.*events;预留account.*event handler 的 hook 但不实现。 - L1 → L3 升级时,单个 tenant 不能从 Customer 转 Connected Account 直接迁数据 — 必须 Stripe 端新建 Connected Account + retaintive 端 link 到 tenant_clients。这就是为什么
tenant_clients.billing_provider_account_id是 nullable 而不是 inherits fromtenants.billing_provider_customer_id。
二、Strategic — 架构设计层 (改不了的)
隔离模型谱系
Pattern:用 Pool / Bridge / Silo / Hybrid 四档表示隔离强度。选择哪档不是“架构高级程度”问题,而是 business model / compliance / cost / automation 成熟度问题。
Why this:
- Pool(共享表 + tenant_id + RLS)— compute 共享,单位成本最低。代价是每条数据访问路径都必须严守 tenant scope,RLS / query audit / tenant-aware cache 必须成熟。
- Silo(每 tenant 独立 DB / 独立 Neon branch/project)— 合规友好(PCI / HIPAA / SOC2 audit 范围小)、性能可预测、blast radius 限于单 tenant。代价是每 tenant 都有独立 endpoint / secret / migration / observability 维度。
- Bridge(共享 cluster + 每 tenant 独立 schema)— PG 的 schema-per-tenant,介于两者之间。schema 数量上限 ~1000 后元数据查询变慢。
- Hybrid — 同一个 SaaS 里部分 tenant 走 Pool、部分 tenant 走 Silo。通常在同时服务低价 self-serve 和高价值 enterprise 时才值得。
When all-Silo works:客户数量少、客单价高、合规叙事重要、且 branch/project provisioning / migration / secret / monitoring 自动化到位时,all-Silo 是合理默认。
硬前提:没自动化的 all-Silo 5 个客户能撑,10 个开始崩。手工
pg_dump+ 手工切 secret + 手工跑 schema migration,在 3 个客户时还行,10 个就 human error 频出(漏跑 migration → schema drift / 拷错 secret → 客户连别人的 DB)。详见下方 Silo 自动化必备的 7 件事。
When all-Silo hurts:客户数量很多且价格很低时,每个 tenant 独立 compute / endpoint / secret / migration / observability 的固定成本会吃掉毛利。例:大量低价 self-serve tenants 如果都要独立 always-on compute、独立 secret、独立 migration fan-out 和独立 monitoring,固定成本会比共享 Pool 高很多;这时 Pool 通过共享 compute 才有意义。
Why not 全 Pool:大客户合规 / 性能 / blast radius 要求满足不了。一个 noisy tenant 把 connection pool 占满全停服。
Reference:
- AWS SaaS Tenant Isolation Strategies whitepaper — Pool/Silo 命名来源
- AWS Serverless SaaS Reference Architecture — basic/standard/premium = Pool, platinum = Silo
- Azure Multitenant Solution Architecture — 同样 Pool/Silo 谱系
Consequences:
- Placement 决定 connection string。code 里必须用 tenant resolver 拿 connection,不能 hardcode DB URL。
- 如果支持 Pool / Silo 互转,必须有自动化迁移工具(计费 tier ↔ 隔离强度)。如果产品选择 all-Silo,迁移工具可以先聚焦
createTenant/promoteTenantToSilo/archiveTenant。
命名与层级
Decision:tenants → locations → phone_lines(或 users / orders / tickets 等业务实体)。需要 BPO 场景再加 tenant_clients 作为业务字段挂在 locations 或独立表。
Why this:
tenant是计费 + 隔离单位(通常 1:1 with Stripe Customer;manual billing 阶段可以先为空)。location是运营单位(一家店 / 一个仓库 / 一个 region office)。同 tenant 跨 location 允许互看。phone_lines是设备 / 资源单位。一个 location 多个 phone_line 很常见(前台 / 销售 / 客服分机)。tenant_clients(可选)— BPO 给 end-client 提供服务时,end-client 是业务字段("这条 ticket 属于哪个 end-client"),不是隔离层(BPO 员工要能跨 end-client 查询和汇总)。
Why not franchise_id / site_id / account_id 当一级隔离:这些是某个 vertical 或某个 provider 的本地概念(franchise 是连锁概念,site 是 RingCentral 概念,account 是 OAuth 概念)。绑死了换 vertical / 换 provider 就改 schema。
Why not 把 BPO 的 end-client 当 tenant:BPO 业务模型是"一个甲方运营多个品牌",账单付一份。end-client 当 tenant 意味着每个 end-client 一个 Stripe Customer — 跟 BPO 实际付款关系不符。
Reference:
- Slack —
workspace(tenant 等价)+channel(location 等价的子粒度) - Notion —
workspace+teamspace - Linear —
workspace+team - Salesforce —
Account(tenant 等价,付款方)+Contact(end-client 等价,业务字段) - AWS Serverless SaaS reference — 统一用
tenantId命名
Consequences:
- 所有 schema 字段名必须
tenant_id,不是org_id/account_id/customer_id。混用就是混乱的开始。 - 业务字段(
tenant_client_id、location_id)可以加 index,但不参与 RLS policy。RLS 只看tenant_id。
权限 resolve 顺序 (tenant_members vs location_members)
Decision:两张成员表都存在,但职责不同。每个请求按下面 3 步顺序判,任何一步 fail 就 reject。
Why this:Glow Beauty 老板登录看 3 家店 = tenant 级权限;SoHo 店的前台只看 SoHo = location 级权限。两张表同时存在,系统必须有明确 resolve 顺序,否则会出现 "员工是 location_members 里 SoHo 的 staff,但 tenant_members 里没他,他能不能登录?" 这类歧义。
3 层 resolve 顺序:
3 种 user 实例对照:
Why not 单一张表合并 tenant + location role:tenant 内可能有跨 location 的角色(老板 / 客服 / 财务 = 跨店),也有限制到单 location 的角色(前台 / 店长)。混一张表 → 每个 user 跨 location 都要 N 行,query 变复杂,permission audit 更难。
Why not 只用 tenant_members:某些 vertical (大客户 / BPO) 需要把员工限制到具体 location,tenant_members 不够。
Why not 只用 location_members:tenant 级行为(改 Stripe 卡 / 改 tier / 改 SSO)没法挂 location,必须 tenant_members。
Consequences:
- API endpoint 默认走 Step 1 + Step 3,跨 location query 自动走 Step 2 多一层。
- Admin impersonate(SaaS 提供方员工 debug) → 绕过 Step 1-2,但必须记 audit log。
权限 / 访问 scope 支持矩阵 (V1 vs Final)
下表是这套设计在 V1 / Final 两个阶段能覆盖的访问场景。字段级 / 行级 / 临时权限不在 multi-tenant scope 内,真要做时单独设计。
Why "字段级 / 行级" 不在 multi-tenant scope:multi-tenant 解决的是 "谁能看到哪个客户的数据" 这一层。客户内部不同员工看到的不同字段 / 不同行 = 业务层 RBAC,跟 tenant 隔离是正交的两件事。混在一起 design 会让 tenant 隔离变得复杂、行级权限也做不深。
Silo 自动化必备的 7 件事
Decision:如果产品方向是 all-Silo / Silo-first,这 7 件事必须自动化。每一件没做就是手工卡点 — 5 个客户能撑,10 个 human error 频出。
Why this:Silo 模式把"运维成本"从"共享一份基础设施"变成"per-tenant N 份"。手工管 N 份的临界点是 5-10 个客户。WorkOS / Northflank 2026 SaaS 产线指南原话:"automated provisioning covers the full onboarding sequence, each step needs to be idempotent"。
自动化 7 件:
业界 stack 推荐:
- Terraform / Pulumi — infra-as-code,project template 模板化(创 Neon project + IAM + secret 一起跑)
- Stripe Webhook handler — raw body 验签 + idempotent (用 Stripe event_id 去重) + 入 SQS 异步处理
- SQS + DLQ — webhook 入队避免 Stripe 超时;失败重试 + 死信告警
- Provisioning job table (
tenant_provisioning_jobs) — 每步 job 记 status + retry,失败可断点续传
触发条件:第 6 个 Silo 客户来之前必须 4 件做完(1, 2, 3, 4)。第 10 个之前必须 7 件全做完。否则 ops 撑不住。
Reference:
- Northflank 2026 SaaS Platform Deployment Guide
- Kodekx — Practical Multi-Tenant SaaS Provisioning and Automated Onboarding
- Stripe Connect webhooks — raw body verification + idempotency
Consequences:
- 当前 V1 identity-only 不建
tenant_provisioning_jobs;等 onboarding 自动化 / self-serve / Silo provision 真正触发时,这张表是 7 件事的执行 ledger。前 1-2 个客户可以手工建档,但进入自动化前要把 job table 和幂等流程补上 — 后期接 Stripe webhook 时只是切 trigger,不改 pipeline。 - Future
getDbForTenant()的connection_secret_ref字段是自动化的 SoT — 自动化脚本写 secret + 写这个字段是同一个 atomic op。
Anti-pattern:
- "先手工建,等客户多了再自动化" — 客户增长不是线性的,客户 10 个来时一周内可能就崩。
- 自动化脚本不 idempotent — provision 失败重试就会建 2 个 Neon project。
- Stripe webhook 直接同步建 Neon project — Stripe 30s 超时,Neon provision 可能 2 min,会 timeout 重发 → 建多个 project。永远 webhook → SQS → worker。
Tenant lifecycle
Decision:把 tenant 当成有状态机的实体。8 类生命周期变化都要有显式的代码路径 + audit log 记录。
Why this:默认状态 active 跑着没事。出问题都是状态转换没做好 — churn 之后数据还在被查询、M&A 之后两 tenant 的数据混了、tier 降级之后 silo 还活着没回收。
8 类生命周期变化:
状态机:
provisioning— 新建中,Stripe Customer 已创但 DB / silo 还没准备好。API 拒访问。active— 正常服务。suspended— 欠费 / 政策违规 / 临时停服。读可降级(只读模式),写禁。churned— 已退订。30 / 60 / 90 天保留期内可恢复。deleted— GDPR 触发或保留期结束。silo project drop,pool 数据 cascade delete。不可恢复。
Why not 把状态藏在多个 boolean 列(is_active / is_paying / is_deleted):状态机不显式 = 转换规则散在代码各处 = 一定有 case 漏掉。一个枚举 + 转换函数收敛所有路径。
Reference:Stripe Subscription status 模型、AWS Control Tower account lifecycle。
Consequences:
- 每个 API endpoint 进来先 load tenant,check status,不是
active就返回对应错误(402 Payment Required/403 Suspended/410 Deleted)。 - 所有 background job 也要 check status,churned tenant 的 cron 必须停。
计费 tier ↔ 隔离强度
Decision:tier 是计费/包装概念,placement 是部署/路由概念。两者用映射表关联,不是同一个字段。
Why this:
- Stripe 接入后可以是 tier 的 source of truth。V1 / enterprise contract 可以先 manual。
- Placement(shared pool / Neon branch / Neon project / region)由 Control Plane 决定。tier 可以影响 placement,但不能在业务代码里 hardcode。
- 升 tier 不一定触发 placement 变化。同 placement 内升降 tier 只改 entitlement / feature / rate limit。只有 placement 变化才需要迁移。
Tier ↔ placement 映射示例:
Pool ↔ Silo 迁移流程(只有支持 Pool/Hybrid 时才需要完整实现):
Pool → Silo(升 platinum):
- Provision 独立 Neon project(自动化脚本,~2 min)
- 启动 dual-write — 应用层同时写 pool DB + silo DB
- 用
COPY (SELECT ... WHERE tenant_id = ?)或 store-scoped export 回填历史数据到 silo - 切读 — 该 tenant 所有 query 走 silo connection
- 切写 + 清理 pool DB —
DELETE FROM ... WHERE tenant_id = ?,cascade 删全部 pool 数据
Silo → Pool(降级或 churn):
- 降级:反向 Step 1-5。警告:PCI / SOC2 强度降级,需 tenant 显式同意,记 audit log。
- Churn 软删:
tenants.status = 'churned'。Silo project 保留,30 / 60 / 90 天宽限期后 cascade delete。 - GDPR delete:立即 cascade delete + drop silo project。不走宽限期。
One function, all paths:
所有 placement 变化都走这个 function。不允许散在各 API handler 里手写 pg_dump。
Why not Stripe webhook 直接驱动迁移:webhook 是 trigger,迁移是异步 long-running job(可能跑 1 小时)。Webhook handler 只写 tenants.tier,发 SQS message。后台 worker 跑迁移。
Reference:Stripe Subscription webhook docs、AWS DMS migration pattern、Neon project provisioning API。
Consequences:
- Placement 变化 SLA = 几小时到一天,不是即时。前端要显示"升级中"状态。
- 必须有
dryRunmode — 不真迁,只 print 影响的 row count + 估计耗时。 - 迁移失败要可重试 + 可回滚。
Provider adapter 层
Decision:把外部 provider(telephony / payment / messaging / CRM)抽象成一层 adapter。Core business logic 不直接调 provider SDK。
Why this:
- 行业现实 — telephony 有 RingCentral / Twilio / Genesys / Five9 / Amazon Connect,payment 有 Stripe / Adyen / Braintree。同一类 provider 概念差很多(账号 / 子账号 / org / instance)。
- 客户带 provider 来 — enterprise 客户经常有自己的 Twilio / Genesys 合同,要求 SaaS 接入他们的实例。绑死一家就接不了。
- AI / MCP 友好 — adapter 层用统一抽象词(
PhoneNumber/Call/Message),AI agent 能跨 provider 推理。
抽象模型:
Why not 直接用 RingCentral SDK 满天飞:换 provider 要改几百处。adapter 一层,换 provider = 加一个 adapter 实现。
Why not 完全抽象到不可见:abstraction leak 是常态。某些 provider-specific 字段(call recording URL / DTMF 行为)必须暴露。Adapter 允许 provider_metadata 字段透传原始 payload。
Reference:Twilio 自家也用 adapter pattern 支持多 CPaaS、Vonage 的 Provider Switch、CrushBank 的 ITSM connector pattern。
Consequences:
- Adapter interface 一旦稳定,加 provider = 实现 interface + 注册。Core 0 改动。
- Adapter 层就是 AI / MCP 接口的天然来源 — 统一抽象词直接变 MCP tool spec。
API platform + AI / MCP
Decision:把 multi-tenant API 当 platform 设计,不是当内部 CRUD。2026 年这一层 = AI agent 的入口。
Why this:
- 抽象词稳定 = AI 友好。
POST /tenants/:id/locations比POST /createStoreForFranchise好太多。AI agent 推理路径短。 - Webhook + MCP 是同一类 — outbound event delivery。MCP server 暴露 tool spec 给 LLM,webhook 暴露 event spec 给客户后端。设计原则相同(idempotent、tenant-scoped、auth-required)。
- Self-serve API key + scope = enterprise 入场券。每个 tenant 能自助生成 API key,绑 scope(
read:calls/write:tasks/admin:locations)。
API 层 checklist:
Why not 等 AI agent 火了再加抽象:抽象层不是临时贴的。API shape 一旦发布出去,外部客户 / AI agent 都在用,改成本指数级涨。Day 1 就当 platform 设计。
Reference:Stripe API design、Linear GraphQL schema、Anthropic MCP spec、Twilio webhook patterns。
Consequences:
- 内部 service-to-service 也走 API(不是直接 DB query)。这样 tenant scope 强制在 API 层,DB 层 RLS 是 defense in depth。
- 加 AI agent / MCP server 几乎 0 改动 — API 已经是抽象词层。
三、Tactical — 技术实现层 (AWS 5 pillar)
每个 pillar 写 Principle(原则)+ When to enable(何时启用)+ Anti-pattern(反模式)。Current / Delta / Owner / Next PR 在
final/migration-plan.md里填。
Security
Principle:Defense in depth — 每一层独立强制 tenant scope。Auth → API → Service → DB,每层失效其他层仍能挡。
Auth ≠ Isolation:JWT 解出 tenant_id 之后,必须通过 tenant resolver 选对 placement / DB。光"用户登录了"不证明"用户只能看自己 tenant 的数据"。
Pool / Silo / Hybrid 三种模式下 RLS 的作用
RLS 是数据库强制每条 query 必须满足 policy 的兜底。在不同模式下用法不同:
Hybrid 怎么设计:一套代码,运行时按 tenant 路由
Hybrid 不是"Pool 和 Silo 两套代码",是一套代码,运行时根据 tenant 路由到不同 DB。3 个 design invariant:
关键设计 invariant:业务表 schema 永远带 tenant_id 字段(无论 Pool 还是 Silo)。Silo 模式下这个字段"看似冗余",但有 3 个好处:
- 同一份 query / migration 在两种 DB 上都跑得通(代码一份)
- 客户从 Pool promote 到 Silo 时数据格式不用转(直接
COPY ... WHERE tenant_id) - M&A / 政策变化要把 Silo 客户合回 Pool 时,数据原生支持
不这样做的代价:Silo 业务表不带
tenant_id→ Pool/Silo 两套 schema 差异 → 两套 query 代码 → 客户在 Pool/Silo 之间流动几乎不可能。这是早期 Hybrid 设计最常见的 trap,业界 RLS articles 反复警告。
RLS 实施细节
- PG
CREATE POLICYper table,policy 用current_setting('app.tenant_id')拿当前 session 的 tenant_id。 - 应用层每个 connection 进来先
SET LOCAL app.tenant_id = '...'。用SET LOCAL不是SET—SET是 session 级,pgbouncer / connection pool 复用 connection 时上一次的值漏过来。SET LOCAL是 transaction 级,跨 transaction 自动清理。 - Bypass RLS 只给特定 superuser role(migration、admin tool)。普通 app role 不能 bypass。
GDPR delete + Admin impersonation
- GDPR delete:cascade delete 所有 tenant 数据 + audit log 留 retention 字段("this tenant was deleted on YYYY-MM-DD by user X",不留 PII)。Silo placement 多一步 drop Neon project。
- Admin impersonation:所有 admin(包括 SaaS 提供方员工)跨 tenant 操作必须 (1) 显式触发 impersonate flow,(2) 记 audit log(who / when / which tenant / what action),(3) 时间窗口受限(默认 1 hour)。
8 治理项中归这里的:GDPR delete / Audit log per tenant(跟 Op Excellence 共享)/ Admin impersonation。
When to enable:
Anti-pattern:
- "Auth 拿到 user_id 就够了,DB 层不用过滤 tenant"。错。BOLA(OWASP API1)的标准场景。
- "RLS 太复杂,应用层过滤就行"。错。任何 dev 写
SELECT * FROM contacts漏掉 WHERE 就是 cross-tenant 泄漏。RLS 是兜底。 - "Silo 模式不用 RLS,DB 已经隔离了"。半对。
getDbForTenant()路由 bug → 跨 tenant 数据访问;RLS 在那种 2nd-order failure 下兜底。 - "Silo 业务表不加
tenant_id字段,反正是物理隔离的"。错。这把 Hybrid 路径堵死了 — Pool/Silo 之间客户流动 = 数据格式转换 = 几乎不可行。 - "admin tool 跨 tenant 不记 log,反正只有我们自己用"。错。员工内鬼 / 凭证泄漏后这是唯一追溯路径。
Reference:
- OWASP API Top 10 (BOLA / Broken Auth)、CVE-2024-10976(见 §4)、Stanford CS253
- AWS Multi-tenant PostgreSQL RLS
- Crunchy Data — Row Level Security for Tenants in Postgres
- Nile — Shipping multi-tenant SaaS using Postgres RLS
Operational Excellence
Principle:每个 tenant 看起来像独立产品。Observability、deploy、runbook、kill switch 都能 per-tenant 操作。
Per-tenant observability:
- Log / metric / trace 都带
tenant_idtag。Datadog / Sentry / CloudWatch query 能按 tenant 切。 - Per-tenant dashboard — 客户 X 的 P50 / P99 / error rate 单独看得见,不混在全局聚合里。
Audit log per tenant:
- 所有 write / admin action / config change 入 audit log,按 tenant 分区(DB partition 或 log stream)。
- 客户合规要求时直接导出该 tenant 的 audit log,不需要全局 grep。
Admin / migration tool:
- Pool↔Silo 迁移工具(计费 tier ↔ 隔离强度)— 一个 CLI / API,覆盖所有 placement 变化。
- Tenant lifecycle CLI —
tenant suspend <id>/tenant churn <id>/tenant restore <id>一行命令。
Per-tenant kill switch:
- 单 tenant 出问题(DDOS attack / bug 触发 / 客户主动暂停)能 kill 单 tenant 而不影响其他人。
- Implementation:
tenants.status = 'suspended',所有 API entry point check 一下。
Lifecycle 状态机:见 Tenant lifecycle。状态机的 transition log 本身就是 audit log 的一部分。
8 治理项中归这里的:
- Lifecycle 状态机
- Audit log per tenant(也跟 Security 共享)
- Migration tool (Pool↔Silo)
When to enable:
tenant_idtag — Day 1。所有 log / metric 加这个 tag 几乎 0 成本。- Per-tenant dashboard — 第一个客户问"我的 P99 是多少"之前。
- Audit log — 第一个付费客户。
- Migration tool — 第一个 platinum tenant 之前(手动 pg_dump 在客户 3 家以前能撑,第 4 家就崩了)。
Anti-pattern:
- 全局 dashboard 一锅炖,问"客户 X 慢吗" → 答不出。
- 手工 pg_dump + manual 切换 — 第 5 次就出 human error。
- 没有 kill switch — 一个 tenant 的 bug 触发全平台限流。
Reference:AWS Well-Architected Operational Excellence pillar、Honeycomb 的 high-cardinality observability、Stripe 的 dashboard per Connect Account。
Reliability
Principle:一个 tenant 不能把其他 tenant 拖死(noisy neighbor)。Rate limit、queue partition、circuit breaker 都按 tenant 切。
Noisy neighbor 防护:
- Per-tenant rate limit — token bucket,每 tenant 独立 quota。premium tier 更高 quota。
- Per-tenant queue partition — SQS FIFO MessageGroupId = tenant_id,or 每 tier 一组独立 queue。
- Connection pool quota — pool 模式下,单 tenant 占连接数有 hard cap。
DR failover:
tenants.primary_region挂时,自动切到tenants.replica_region。RTO / RPO 按 tier 区分(premium 5 min RTO,basic 1 hour 也接受)。- Failover 必须可自动 + 可手动触发。手动触发是 region partial outage 时管理员判断的口子。
Per-tenant feature flag:
- LaunchDarkly / Unleash / 自建 — feature gate 能按 tenant 开关,不只是按 percentage。
- 用途:beta 客户先开新 feature、出问题的 tenant 单点关一个 feature 缓解。
Provider unbind:
- 客户离开时(churn / 换 provider),所有 provider 绑定要能干净解绑 — webhook 取消、token revoke、queue stop。
- 反向:客户来时 provider rebind 也是同一套流程,可重入。
8 治理项中归这里的:
- Per-tenant feature flags
- Region failover
- Provider unbind
When to enable:
- Per-tenant rate limit — 拿到第一个客户用 API 之后立刻。
- Per-tenant feature flag — 进 closed beta 之前。
- DR failover — 拿到第一个企业合同(一定会问 RPO / RTO)。
- Provider unbind — 第一个 churn 之前(必然会发生)。
Anti-pattern:
- 全局 rate limit — 一个 tenant 写脚本爆调,把其他 tenant 全 throttle。
- SQS queue 不 partition — 一个 tenant 灌 100 万 message,其他 tenant 的 message 卡 4 小时。
- Feature flag 只支持 percentage — 想给客户 X 单独开 / 关一个 feature 做不到。
- Churn 客户的 webhook 没取消 — 半年后还在收 event,被客户法务投诉"为什么还在调我们 API"。
Reference:AWS Well-Architected Reliability pillar、Stripe 的 tenant-aware rate limiting、Cloudflare 的 per-zone limits。
Performance Efficiency
Principle:Pool 模式下,单 tenant 的 query / cache / connection 都不能挤压其他人。Hot tenant 是异常,不是常态。
Caching strategy:
- Cache key 必须含
tenant_id。SELECT * FROM contacts WHERE id = ?cache key =contact:${tenantId}:${contactId}。漏掉 tenant_id = cross-tenant cache poisoning。 - Per-tenant cache size cap — 单 tenant 不能占 cache memory 超过 X%。
- TTL 按 data type 分(不是全局 60s)。
Sharding(Pool 内部分片):
- 当 Pool DB 撑不住,按
tenant_idhash 分片到多个 DB instance。 - 一致性 hash + virtual node,加 / 减 shard 不需要全量迁移。
- 跨 shard query 禁(admin 才允许)。
Connection pool strategy:
- Per-tenant connection limit — 单 tenant 最多占 X 个 connection(pool size 20 时 cap 5)。
- pgbouncer transaction pooling — 多 tenant 共享 connection,配合
SET LOCAL用。
Query plan / index:
- 所有 index 第一个字段 =
tenant_id。(tenant_id, created_at)不是(created_at)。 - PG
partial index给大 tenant 单独优化(如WHERE tenant_id = 'big-tenant')。
When to enable:
- Cache key 含 tenant_id — Day 1。这个不补救。
- Per-tenant cache cap — 第一次有 tenant 撑爆 cache memory。
- Sharding — 单 DB 撑不住的那一天(通常 1000+ tenant 或单表 1 亿+ 行)。
- Per-tenant connection limit — 第一次 noisy neighbor 把 pool 占满。
Anti-pattern:
- Cache key 不含 tenant_id — A tenant 的数据缓存里给 B tenant 看到。
Cache poisoning是教科书漏洞。 - Index 不带 tenant_id 第一字段 —
WHERE tenant_id = ?走 full scan,pool 模型下越加 tenant 越慢。 - 全局 connection pool 不限单 tenant — noisy tenant 直接 OOM。
Reference:PG 多租户索引最佳实践、Redis tenant-aware caching、AWS DynamoDB partition key design。
Cost Optimization
Principle:成本能按 tenant 归因。Pool 模式下大家共享基础设施,但每 tenant 的 query 数 / storage 量 / network egress 能算出来 → 才能算单 tenant 单位经济。
Per-tenant cost attribution:
- DB query 量 / storage 量按
tenant_id聚合(PGpg_stat_statements+ tenant_id tag、Neon usage API)。 - Network egress / CDN bandwidth 按 tenant 域名 / API key 切。
- AI / LLM token 用量按 tenant 计(每次 call OpenAI / Anthropic 记 token + tenant_id)。
Tier 经济学:
- 每 tier 的成本 =
(共享基础设施 / tenant 数) + (per-tenant 变量成本)。Pool 模式下共享基础设施 amortize,所以基础 tier 能定低价。 - Silo 模式(platinum)每 tenant 有 fixed 成本(独立 Neon project / 独立 connection pool)。pricing 必须覆盖。
- 临界点 — 算清楚 "Pool tenant 升到 Silo 的成本差" → 决定 platinum 起步价。
Tier 越高 margin 越高 vs. 越低:取决于 silo 模式的成本结构。如果 silo 的 fixed 成本被 platinum 价格 cover 还有富余 → high tier high margin。如果 silo 实际烧钱(独立 DR / 独立 24x7 oncall)→ basic tier 反而 high margin(amortize 充分)。两种都见过。
When to enable:
- Per-tenant cost attribution — 想做 tier pricing 之前。盲定价 = 不知道哪 tier 烧钱。
- Silo placement cost model — 第一个 platinum 客户之前。
- AI token attribution — 加 AI feature 之前。LLM 成本 spike 是 SaaS startup 最常见的 cost surprise。
Anti-pattern:
- 成本只看总账单,不按 tenant 归因 — 一个客户烧钱不知道是哪个。pricing 凭感觉。
- 给 enterprise 客户白嫖 silo("反正 Neon free tier")— 几个客户后 paid plan 上来一算账亏的。
- AI feature 不限 token — 一个客户的 agent 死循环烧 1000 美金 token,公司付账。
Reference:AWS Well-Architected Cost Optimization pillar、Stripe Connect platform fee model、Snowflake per-customer cost attribution。
四、业界参考 + 概念附录
Pool / Bridge / Silo 一图速查
业界对照表
CVE-2024-10976
PostgreSQL RLS bypass — 影响 PG <17.1 / <16.5 / <15.9 / <14.14 / <13.17 / <12.21。攻击者在 RLS 启用的表上 SET ROLE 后能绕过 policy。Neon 当前已 patched(PG 16.5+)。自己跑 PG 的 SaaS 必须升级。详见 PG CVE-2024-10976。
概念 glossary(人话)
tenant_id
: 一行数据或一个 request "属于哪家客户"的字段。通常 1 个 tenant = 1 个 billing customer。Pool 模式里它必须出现在业务表和 RLS policy 中;Silo 模式里它至少存在于 Control Plane / request context,业务表可选。
location_id
: tenant 内部的运营单位 ID(一家店 / 一个仓库 / 一个 region office)。同 tenant 跨 location 允许互看。为什么重要:业务扩张(开新店)= 加 location 行,不动 schema。
RLS(Row Level Security)
: PostgreSQL 内置功能,给表加 policy,让数据库强制每个 query 必须满足某条件。例:CREATE POLICY tenant_isolation ON contacts USING (tenant_id = current_setting('app.tenant_id'))。为什么重要:应用层漏过滤时,DB 层兜底。Defense in depth。
SET LOCAL vs SET
: PG 设置 session 变量的两种方式。SET = session 级,跨 query 持续,pgbouncer 复用 connection 时上一次的值漏给下一次。SET LOCAL = transaction 级,COMMIT / ROLLBACK 自动清。为什么重要:连接池 + multi-tenant 必须用 SET LOCAL,否则上 tenant 的设置会泄到下 tenant。
Defense in Depth : 每一层独立强制 tenant scope,任一层失效其他层仍能挡。Auth 层、API 层、Service 层、DB 层(RLS)。为什么重要:单层防御一旦被绕(bug、依赖更新、配置错误),数据全泄。多层防御要求攻击者同时绕过所有层。
CPaaS(Communications Platform as a Service) : Twilio / RingCentral / Vonage 这类提供电话 / 短信 / 视频 API 的平台。为什么重要:multi-tenant SaaS 不绑死单一 CPaaS — 客户带自己的 Twilio / Genesys 合同进来很常见。Provider adapter (Provider adapter 层) 就是为这个。
BPO(Business Process Outsourcing) : 一家公司代另一家公司运营某个业务环节(call center、客服、销售)。SaaS 视角:BPO 是 tenant(付账户主),end-clients 是 tenant_clients(业务字段)。为什么重要:BPO 业务模型决定了 tenant 必须是计费单位,end-client 不是 — 否则 Stripe Customer 数量爆炸而且和真实付款关系不符。
指针
retaintive 自己的隔离层级(tenants / locations / phone_lines)、当前哪些表满足 / 缺什么、迁移路径、未填的 gap → final/migration-plan.md。
Store-level isolation 的具体落地(store_id UUID 作为单一隔离键、PhoneStoreAssignments、电话移店场景)→ store-level-isolation.md。
五、完整 Hybrid 8 维度图
Overview 里给的是 3 面简版 (数据 / 接入 / 运维)。这里是工程团队需要的完整 8 维度。
这张图是 generic Hybrid reference,不是 retaintive V1 的默认部署图。真实成本要按当时的 Neon / AWS / Cloudflare pricing、compute scale-to-zero、branch 数量、迁移自动化和 oncall 成本单独算;这里不写固定 dollar 数字,避免把示例当成预算。
3 个关键认知:
- Hybrid 不是开关,是 8 维度逐项决定。每一维可独立选 Pool / Silo。
- Platinum tier ≠ 全部独立。AWS Account 通常仍共享 (太贵);Neon / S3 / 监控是必须独立 (合规硬要求)。
placement这个概念在不同时期用不同 schema 表达,见下方演进表。
Placement 字段演进 (V1 → 中期 → Hybrid)
为什么这一节单独存在:placement 在 4 份文档里出现 3 个不同名字 — overview 说 tenants.placement 单字段,本文 §五 说 8 维度 placement_profile,v1-launch-plan 说独立表 tenant_database_placements。3 个名字不是矛盾,是 3 个时期的演进:
关键:V1 实际落地是 identity-only,没有建任何 placement 表或字段 — placement 抽象整体 DEFERRED,首个需要分库 routing(Silo/Pool)的客户来时再落,届时优先按 tenant_database_placements 独立表方案。overview / migration-plan 提到"placement"是为了讲业务模型容易讲。
触发升级:
Why not 一开始就 placement_profile 16 组合:V1 客户 1-3 个,没人要细粒度组合。一开始就上 16 组合 = over-engineering,徒增维护成本。
→ 原方案表结构(包括 placement_type 枚举值 / connection_secret_ref 字段)见已归档的 rollout.md §2.3 tenant_database_placements(DEFERRED,未落地)。
六、Provider adapter 完整 DDL
Overview 给的是 2 张新表的职责。这里是完整建表语句 + 索引 + 约束。
Adapter interface (TypeScript)
多 provider 数据合并策略
同一个 location 挂多 provider 时,业务表里 calls / messages 怎么去重?
七、MCP server 实现细节
Overview 给的是 MCP 时序流。这里是工程实现细节。
Tenant 注入机制
Tool 列表 (建议初始 6 个)
Rate limit per tenant + tier
→ Rate limit 也是 placement_profile 的一个维度 (见 §5)。