我在第 2 天做了多租户。第 67 天,我把它重做了
我曾以为给每张表加上 org_id 就是防弹级多租户。直到一次安全审计发现,代理公司在向 SME 表写数据——不是漏洞造成的,而是架构设计本身导致的。
2025 年 10 月 27 日,晚上 11:47。我在对 STRAŦUM 做一次原本以为很常规的安全审计。几周以来一切都看起来正常——SME 有自己的数据,代理公司有自己的数据,多租户很稳。
咖啡冷了,审计脚本还在刷日志。然后我看到了:代理公司在往 SME 的表里写数据。
不是通过 bug。不是通过安全漏洞。而是通过架构本身。
两个月前,我在 Day 2 就决定做多租户。对一个只有一个可用 AI agent 的 solo founder 来说,这已经很激进了。我给每张表加了 org_id,写了 RLS,给 SME 和 Agency 做了不同路由。它确实“能跑”——SME 有自己的 campaign,agency 有自己的 clients,数据看起来都去了正确位置。
至少我是这么以为的。
我盯着 schema 发呆了大概 20 分钟。我怎么会漏掉这个?我花了几周搭多租户、写了 83 条 RLS、拿 SME 和 Agency 账号都测过。所有东西都能工作。但“能工作”和“正确”不是一回事。
这类问题会让你怀疑自己是不是应该写软件。因为它不是拼写错误,不是漏了某个边界条件,而是架构层面的天真。
我犯了最经典的错误:以为按 org_id 过滤就足够隔离多租户。并不够。
这是我如何发现“真正多租户隔离”不只是给每张表加 org_id,以及在 48 小时里做了 33 个 migration 才把它彻底修好的过程。
---
Note: 本文 SQL 示例使用了泛化后的 schema 与表名(
tenant_b、workspace_entities、entity_data)以保证安全。概念不受命名影响。
---
问题本质:并非所有租户都相同
这是我最初做的版本:
```sql
-- Brand guidelines table (shared by SMEs and Agencies)
CREATE TABLE brand_guidelines (
id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
name TEXT,
guidelines JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS policy (seems safe)
CREATE POLICY brand_guidelines_org_isolation ON brand_guidelines
FOR ALL TO authenticated
USING (org_id = get_user_org_id());
```
这对 SME 看起来完全没问题。每个组织有自己的品牌规范,RLS 保证互相看不到数据。(至少我当时这么认为。后面会说。)
但代理公司不是这种模型。
代理公司不是一套品牌规范,而是每个客户一套:
- Client A 品牌规范(高饱和配色、粗体排版、强调创新)
- Client B 品牌规范(低饱和配色、极简设计、强调品质)
同一个 agency,不同 client,品牌体系完全不同。
天真的解法(我第一版做的):
```sql
-- Add entity_id to the shared table
ALTER TABLE brand_guidelines ADD COLUMN entity_id UUID;
-- Update RLS policy
CREATE POLICY brand_guidelines_isolation ON brand_guidelines
FOR ALL TO authenticated
USING (
organization_id = get_user_org_id() AND
(entity_id IS NULL OR entity_id = get_user_entity_id())
);
```
问题在于:这变成了一张表混着两种数据模型:
```
SME row: organization_id='org-123', entity_id=NULL, guidelines=\{...\}
Agency row: organization_id='org-456', entity_id='entity-a', guidelines=\{...\}
Agency row: organization_id='org-456', entity_id='entity-b', guidelines=\{...\}
```
查询变得一团乱麻,要处理复杂 NULL 逻辑;每个功能都要在应用代码里写“if SME else Agency”。
而且是的,我在意识到这是更深层问题症状之前,已经写了很多这种代码。几周工作都在指向同一结论:我把自己架构进死角了。
每个功能都变成定制逻辑:“SME 这样做,Agency 那样做。”
更糟的是,这种架构带着错误假设:
- Agency 如果写了 entity_id=NULL,会污染 SME 数据语义
- SME 想做子账户时,模型本身不支持 sub-entities
- schema 变成“瑞士奶酪”,到处是可空列
这不是多租户架构。这是 一张表硬扛两种数据模型。
---
关键认知:不同租户类型需要不同 schema
到 10 月底我终于承认:SME 和 Agency 不共享同一数据模型。
SME 数据模型:
```
organization → campaigns → agent_outputs
```
Agency 数据模型:
```
organization → workspace_entities → campaigns → agent_outputs
↓
entity_data (e.g., brand guidelines, personas)
```
代理公司有一整层 SME 没有的结构(workspace entities),还需要 entity 级 intelligence,这在 SME 世界里并不存在。
解法:拆分数据库 schema。
```sql
-- SME tables (public schema)
public.brand_guidelines
public.campaigns
public.outputs
-- Agency tables (tenant_b schema)
tenant_b.workspace_entities
tenant_b.entity_data -- Includes brand guidelines, personas, etc.
tenant_b.campaigns
tenant_b.outputs
```
这样 SME 和 Agency 就是完全不同的表。没有共享 schema,没有 entity_id 的可空污染,也不需要应用层 “if SME else Agency”。
---
为什么这件事重要:Schema Routing 的业务意义
在进技术实现之前,先说它不止是“代码更整洁”。
为增长做准备(也许)
Schema routing 不只是解决当前问题,更是为未来可能性留门。
我目前还在 private alpha,只有 15 个用户。没有 enterprise 客户,也还没跟 GDPR 律师深聊。但如果 STRAŦUM 成长,这套架构可能带来:
国际扩展:
- 如果进 EU:分 schema 有机会支持数据驻留(EU 客户在 eu_agency schema + EU server)
- 删除权更好做:查一个 schema,不用在混合表里筛
- 审计追踪:“给我 Client X 的全部数据”可以单 schema 查询
合规沟通:
- 当有人问“你如何保证数据隔离?”
- 只有 org_id 过滤时:只能回答“我们用了 RLS”(抽象、难核验)
- 有 schema routing 时:可以回答“每类客户数据在独立数据库 schema”(具体、可审计)
我不知道这是否马上重要,但如果未来走到那一步,它可能很重要。
诚实讲:
我现在不是在做 HIPAA/SOC 2。我的目标是服务需要更好营销策略的 SMEs 和小型代理公司。
但 schema routing 的意义是:如果未来有人问“能服务医疗客户吗?”或“支持数据驻留吗?”,我可以说“可以,来看看架构”,而不是“先等我重构”。
代价(实话实说)
Schema routing 不是只有好处:
开发复杂度:
- 每次 WRITE 都要 router function
- 每次 READ 都要 security view
- 测试必须覆盖 SME 与 Agency 两条路径
- 借助 Claude Code:我在 2025/10/27-10/29 晚上高强度做了 2 天
- 没有 AI:大概率是几周
迁移风险:
- 33 个连续 migration = 33 次出错机会
- 一个错误 ALTER TABLE 就可能污染生产数据
- 每个 migration 我都在 staging 跑 3 遍才敢碰 prod
- 那种紧张感是真的
查询性能开销:
- UNION ALL 视图会让读取略慢
- router function 让写入多一层函数调用
- RLS + view 让 query plan 更复杂
- (目前我没明显感知慢,但我也只有 15 个 alpha 用户)
运维复杂度:
- schema migration 现在要影响 2+ schemas(public + agency)
- 备份恢复需要 schema-aware
- 监控查询也得跨 schema 检查
- 这个坑未来一定会咬我,只是不知道何时
我为什么仍然做了这个取舍
这份“选择权价值”可能巨大,也可能最终没用上。
Schema routing 让我保留了很多未来选项,哪怕我现在不确定是否会走:
- 白标合作:可给合作方独立 schema + UI rebrand
- 代理转售模式:可证明级数据隔离
- 分层定价:“Premium”客户给专属 schema
- 地域扩展:EU schema、US schema、APAC schema,共用一套代码
现实是:我还在 private alpha,不确定这些会不会发生。也许永远不会有白标请求,也许地域扩展还很远,也许业务方向会转,这些都不再相关。
但我确定一件事:有 schema routing,这些选项存在;只有 org_id 过滤,大部分都要整库重写。
这就是我的下注: 现在多花 2 天(有 Claude Code),换未来保留更多门。
是不是对的?一年后再看。
---
架构方案:Schema Routing
模式 1:按 schema 划分专属表
有些表只属于一种租户类型:
```sql
-- Specialized tenant schema
CREATE SCHEMA tenant_b;
-- Workspace entities (specific to this tenant type)
CREATE TABLE tenant_b.workspace_entities (
id UUID PRIMARY KEY,
organization_id UUID,
name TEXT,
metadata JSONB
);
-- Entity-specific data
CREATE TABLE tenant_b.entity_data (
id UUID PRIMARY KEY,
organization_id UUID,
entity_id UUID REFERENCES tenant_b.workspace_entities(id),
data_type TEXT,
content JSONB
);
```
SME 永远不会碰这些表,因为 public schema 里根本没有它们。
模式 2:数据库 Router Functions
如何写到正确 schema?答案是 router functions。
概念如下(简化版):
```sql
CREATE FUNCTION save_resource_routed(params)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER -- Run with elevated privileges
AS $$
BEGIN
-- Step 1: Detect organization type
SELECT type INTO org_type FROM organizations WHERE id = p_org_id;
-- Step 2: Route to correct schema based on type
IF org_type = 'TENANT_B' THEN
INSERT INTO tenant_b.entity_data (...) VALUES (...);
ELSE
INSERT INTO public.brand_guidelines (...) VALUES (...);
END IF;
RETURN result;
END;
$$;
```
它的工作方式:
1. 检测组织类型:查询 organizations 确认 tenant type
2. 路由到正确 schema:按类型写到对应 schema
3. 返回结果:附带使用了哪个 schema 方便调试
应用代码(所有租户统一):
```typescript
// Just call the router function - no tenant-specific logic
const result = await supabase.rpc('save_resource_routed', \{
p_org_id: orgId,
p_entity_id: entityId, // null for simple tenants
p_data: { ... \}
});
```
应用层不需要 if/else。数据库负责路由。
我写第一个 router function 花了 4 小时。调它为什么不生效又花了 6 小时。原因?我忘了授予 EXECUTE 权限。典型 solo founder:架构想得很大,权限细节漏得很小。 :P
模式 3:Security-Invoker Views 统一读取
写入靠 router function,读取靠 views。
```sql
-- Unified view combining both schemas
CREATE VIEW resources_unified
WITH (security_invoker = on) -- Respects RLS policies
AS
SELECT id, organization_id, NULL AS entity_id, data, 'public' AS source
FROM public.brand_guidelines
UNION ALL
SELECT id, organization_id, entity_id, content AS data, 'tenant_b' AS source
FROM tenant_b.entity_data
WHERE data_type = 'brand_guidelines';
```
应用代码(统一读):
```typescript
// Read resources (works for all tenant types)
const \{ data \} = await supabase
.from('resources_unified')
.select('*')
.eq('organization_id', orgId);
// RLS policies filter correctly regardless of source schema
```
关键点:WITH (security_invoker = on) 会强制视图遵循 RLS。没有这个,view 会绕过 RLS(安全灾难)。
---
迁移过程:48 小时 33 个 migration
Schema routing 不是一次迁移就能结束,而是一段旅程。
你知道什么最刺激吗?连续写 33 个 migration,并且清楚只要其中一个打错字,就可能污染生产数据。真正感受不是“好玩”,而是“恐惧”。我每个 migration 都在 staging 跑 3 次才敢动生产。
2025 年 10 月 27-29 日:为完整 schema routing 执行 33 个连续 migration。
迁移阶段:
1. 创建专属 schema - 建 tenant_b 并配置权限
2. 创建 schema 专属表 - 在新 schema 里镜像必要表
3. 构建 router functions - 每种需要路由的资源都做函数
4. 创建安全视图 - 用 UNION ALL 统一读取
5. 更新 RLS - 确保两个 schema 都有正确隔离
6. 数据迁移 - 把历史数据搬到正确 schema
7. 应用侧更新 - 从直连查询切到 router/view
总投入:33 个 migration,在 Claude Code 协助下 2 天完成,100% 值得。
---
结果:真正多租户隔离
之前(共享表 + org_id)
数据模型:
```sql
public.brand_guidelines (organization_id, entity_id, guidelines)
```
问题:
- ❌ 一种租户类型引入可空 entity_id(模型混乱)
- ❌ 查询要处理复杂 NULL 分支
- ❌ 应用层逻辑:if (tenantTypeA) { ... } else { ... }
- ❌ 存在跨类型污染风险
之后(Schema Routing)
数据模型:
```sql
public.brand_guidelines (organization_id, guidelines) -- Tenant Type A
tenant_b.entity_data (organization_id, entity_id, data) -- Tenant Type B
```
收益:
- ✅ 数据模型干净(不再依赖可空外键)
- ✅ 查询简单,不需要复杂 NULL 处理
- ✅ 应用层无 if/else(数据库负责路由)
- ✅ 跨 schema 污染在物理层面不可发生(表彻底分离)
安全提升
之前:共享表 + 可空列,存在跨类型污染风险。
之后:router function 基于组织类型自动写入对应 schema。物理 schema 分离让跨类型污染不可达。
隔离级别:数据库强制,而不是应用层“自觉检查”。
---
何时用 Schema Routing,何时只用 org_id 过滤
不是每个多租户应用都要上 schema routing。可以按这个决策树:
适合 org_id 过滤(更简单)
✅ 所有租户数据模型一致(例如待办应用)
✅ 没有层级租户结构(组织内没有 sub-entities)
✅ 查询简单(到处 WHERE org_id = X 就够)
✅ B2C 或小型 B2B(无 enterprise 销售、无合规压力)
✅ MVP 速度优先(几周内上线)
业务逻辑: 你现在在验证 PMF,不是在做 Fortune 500 合规答卷。先快上线,拿到 enterprise traction 再重构。
例子:每个组织独立管理项目的项目管理工具。
```sql
CREATE TABLE projects (
id UUID PRIMARY KEY,
org_id UUID, -- Simple filtering
name TEXT
);
```
适合 Schema Routing(更复杂)
✅ 不同租户类型需要不同数据模型(Type A / Type B / Type C)
✅ 有层级租户结构(organizations → workspace_entities → sub-entities)
✅ 路线图包含 enterprise 销售(金融、医疗、政企等)
✅ 存在合规要求(GDPR、HIPAA、SOC 2、FedRAMP)
✅ 有白标或转售机会(合作方要求严格隔离)
✅ 计划国际扩展(数据驻留要求)
业务逻辑: 如果目标是 enterprise,“数据隔离”会变成安全问卷里的硬选项。Schema routing 让你回答更有底气;仅行级过滤通常会让你只能含糊表述。
例子:某些租户有层级 workspace,数据形态与直接客户完全不同的平台。
成本/收益(我的实际体感):
- 成本:有 Claude Code 也花了 2 天(没 AI 估计是几周)
- 潜在收益:架构更干净、合规可对话、合作可扩展
- 已实现收益:未知(我仍在 private alpha)
- 回本点:只要它帮我打开一个原本打不开的门,就值回票价
真正问题是: 你在优化“更快上市”,还是“更大选择权”?两者都合理。我选了后者。
---
其他隔离策略对比
Schema routing 不是唯一方案,下面是光谱:
Level 1:独立数据库(隔离最高)
database_tenant_1
database_tenant_2
database_tenant_3
优点:
- ✅ 物理隔离最强
- ✅ 可做租户级备份
- ✅ 可独立扩缩容
- ✅ 合规友好(驻留)
缺点:
- ❌ 运维复杂度极高(N 套数据库)
- ❌ 成本高(每租户一实例)
- ❌ 跨租户查询几乎不可做
- ❌ schema migration 要全库同步
适用:高价值 enterprise SaaS($10k+/月)且有强合规要求。
Level 2:独立 Schema(强隔离)
```
database
├── schema_tenant_1
├── schema_tenant_2
└── public (shared tables)
```
优点:
- ✅ 强逻辑隔离
- ✅ 共用基础设施(单数据库)
- ✅ 可做 schema 级权限
- ✅ 可支持不同租户模型
缺点:
- ❌ 比行级隔离复杂很多
- ❌ 需要 router function
- ❌ migration 会更复杂(多 schema)
适用:不同客户层级的数据模型不一致的 B2B SaaS(STRAŦUM 方案)。
Level 3:RLS 行级过滤(中等隔离)
```
database
└── public
└── table (with org_id column)
```
优点:
- ✅ 实现简单
- ✅ migration 简单(单 schema)
- ✅ 跨租户分析可做
- ✅ Postgres RLS 可强制隔离
缺点:
- ❌ 所有租户必须共享同一数据模型
- ❌ RLS 有性能开销
- ❌ RLS 配置错误风险
适用:数据模型统一的 B2B SaaS(项目管理、CRM 等)。
---
实施清单:Schema Routing
如果你要做 schema routing,可以按这个 checklist:
阶段 1:Schema 设计
- [ ] 建租户类型区分字段(organizations.type)
- [ ] 设计 schema 专属表(哪些对象放哪)
- [ ] 创建专属 schema:CREATE SCHEMA tenant_b;
- [ ] 在专属 schema 镜像必要表
- [ ] 文档化每张表所属 schema
阶段 2:Router Functions
- [ ] 每种资源写对应 router function
- [ ] 使用 SECURITY DEFINER 获取提升权限
- [ ] 设置 search_path = public, tenant_b 访问多 schema
- [ ] 处理租户类型识别:SELECT type FROM organizations
- [ ] 返回 schema 信息便于调试
- [ ] 给 authenticated 角色授予 EXECUTE
阶段 3:Security Views
- [ ] 建统一读取视图(跨 schema UNION ALL)
- [ ] 使用 WITH (security_invoker = on) 保证 RLS 生效
- [ ] 加 source_schema 字段便于排查
- [ ] 验证视图上的 RLS 行为
- [ ] 给 authenticated 角色授予 SELECT
阶段 4:应用集成
- [ ] 写入改为 router function:supabase.rpc('save_resource_routed', ...)
- [ ] 读取改为 view:supabase.from('resource_unified').select()
- [ ] 删除应用层 if/else 租户判断
- [ ] 测 Tenant A 流程(写入 public)
- [ ] 测 Tenant B 流程(写入 tenant_b)
- [ ] 验证数据隔离(Entity A ≠ Entity B)
阶段 5:迁移与测试
- [ ] 写迁移脚本(完整覆盖常见要 30+)
- [ ] 在 staging 用近真实数据压测
- [ ] 运行安全审计(有无跨 schema 泄漏)
- [ ] 压测性能(RLS + views)
- [ ] 生产监控(慢查询)
---
经验总结
1. org_id 是必要条件,不是充分条件
每表加 org_id 只能实现行级过滤。若租户类型的数据模型不同,你仍需要 schema routing。
我的体会:“多租户”不是二元开关,而是隔离等级光谱。我给当前 15 个用户选了更高隔离等级。是聪明还是过度,时间会说话。
2. 应用逻辑应下沉到数据库逻辑
应用里每个 if (tenantType === 'TYPE_B') 都是异味。把租户感知逻辑放到数据库路由函数里。
我的体会:数据库函数更难写,但未来可能更容易审计。若将来 enterprise 客户问“证明隔离”,我可以给他们看存储过程。只是现在,这仍是潜在收益。
3. Views + RLS 能统一读取
跨 schema 读取本来很复杂。用 views + security_invoker = on,可以做到统一读且隔离正确。
我的体会:视图层可能让未来合规沟通更容易;也可能只是增加了当前不必要的复杂度。还要继续验证。
4. Security Definer 函数很强
SECURITY DEFINER 允许函数以提升权限执行,同时仍能与 RLS 配合。对 router function 很关键。
5. 这 33 个 migration(大概率)值得
48 小时 33 个 migration 感觉很多,但换来的是干净架构、真实隔离、跨租户 bug 归零。
我的体会:技术债迟早要还。我选择在 15 个 alpha 用户时先还,而不是等到有付费客户再还。是否完全正确,要等我有 100 个客户时再看。
6. 架构缺陷比代码 bug 更伤
晚上 11:47 发现一个空指针?烦。发现整套多租户架构在原则上有问题?这种事会让你一夜睡不着。
但我学到的是:架构错误可以修,虽然昂贵、耗时,但可修。我从“某租户类型可能污染另一类数据”走到“数据库强制隔离且不可绕过”。
早修、彻底修,你会睡得更安稳。
7. 架构可能就是战略(也可能只是过度工程)
Schema routing 的决定,不只是“代码干净”,它是为未来选择权下注:白标、enterprise、国际扩展。
但实话是:我还在 private alpha,只有 15 个用户。没有 enterprise 客户上门,也还没收到 GDPR 问卷。那些我设想的合作机会可能永远不会发生。
我以为自己在做技术决策。也许我其实在做商业策略决策。又或者,我只是因为喜欢数据库架构而过度工程。 :)
你有遇到过这种“不是 bug,而是设计失误”的架构问题吗?你是渐进式修,还是像我一样一次性推倒重来?
致敬,
Chandler
STRAŦUM 架构系列: 这是多租户旅程第 2 部分。它始于 Day 2 做多租户。完成 schema 重建后,我又遇到了 31 个导航上下文丢失导致的空白屏,以及数据库 逻辑正确却慢了 296 倍。
---
正在构建隔离需求复杂的多租户 SaaS? STRAŦUM 使用 schema routing 为不同租户类型提供真正数据隔离。申请 alpha: https://stratum.chandlernguyen.com/request-invitation
---
*Still learning that "multi-tenant" has many levels of isolation. Still debugging RLS policies at midnight. Still questioning my Day 2 architecture decisions (but less so now). More database adventures at https://www.chandlernguyen.com/ .
---





