Skip to content
··阅读时间7分钟

我在第 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_bworkspace_entitiesentity_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/ .

---

继续阅读

我的旅程
联系
语言
偏好设置