一、业务背景:家政 SaaS 的多商户底座
大家好,我是 Pico-CRM 的作者。
Pico-CRM 是一个家政行业的 SaaS CRM 系统,服务多个家政公司(商户)。每个商户有自己的员工、客户、订单、服务目录,数据必须严格隔离------商户 A 绝对不能看到商户 B 的客户信息。
这是所有 SaaS 系统绕不开的命题:多租户数据隔离。
提前声明:本文分享的只是我个人在 MVP 阶段的技术决策,不构成通用最佳实践。隔离方案的选择跟你的业务规模、团队大小、监管要求强相关。
二、最初的方案:一个商户一个 PostgreSQL Schema
刚开始设计的时候,我的想法很"工程师":既然要隔离,那就隔离到最彻底------一个商户一个独立的 PostgreSQL Schema。
架构是这样的:
bash
public ← 平台表(商户列表、License、系统配置)
merchant_001 ← 商户 1 的全部业务表(users, orders, contacts...)
merchant_002 ← 商户 2 的全部业务表(users, orders, contacts...)
merchant_003 ← ...
理由也很充分:
- 隔离性最强 :每个商户的数据在独立的 namespace 里,SQL 写漏
WHERE merchant_id = ?也不会泄露到其他租户 - 备份/恢复灵活:给大客户单独备份一个 Schema,操作成本低
- 听着专业:做 SaaS 嘛,当然要隔离到位(笑)
于是每个 API 请求的流程变成了:
markdown
JWT Token → 解析出 merchant_id 和 schema_name
→ 动态选择 schema
→ 在当前 schema 内执行 SQL
→ 返回结果
三、翻车现场:跑了两个月我受不了了
3.1 Migration 地狱
Django/Rails 有成熟的 multitenant migration 方案,但 Rust + SeaORM 的生态下没有现成的"给所有 Schema 跑一遍 migration"的工具。每次加表改字段:
bash
# 只有一个商户的时候
$ sea-orm-cli migrate up -s merchant_001 # 1 秒
# 有 5 个商户的时候
$ sea-orm-cli migrate up -s merchant_001
$ sea-orm-cli migrate up -s merchant_002
$ sea-orm-cli migrate up -s merchant_003
$ sea-orm-cli migrate up -s merchant_004
$ sea-orm-cli migrate up -s merchant_005 # 5 秒
更要命的是出问题时的排障成本。这个商户有数据那个没有?你得跨 Schema 排查:
sql
SELECT COUNT(*) FROM merchant_001.orders WHERE status = 'pending'; -- 5 条
SELECT COUNT(*) FROM merchant_002.orders WHERE status = 'pending'; -- 0 条
SELECT COUNT(*) FROM merchant_003.orders WHERE status = 'pending'; -- 3 条
-- 为什么 merchant_002 没有 pending?是业务正常还是 bug?
凭空多了一层跨 Schema 的排查维度,debug 时间直接翻倍。
3.2 开通新商户慢到怀疑人生
原来的开通流程:
sql
创建商户记录 → CREATE SCHEMA → 跑完整 Migration → 插入默认数据
一个创建操作好几秒。如果 Migration 有几十个,那新商户开通的体验就是"点一下,转圈 5 秒"------对于一个还在 MVP 阶段、连 10 个商户都没有的系统来说,这成本完全不成比例。
3.3 自问一个问题
有一天我问了自己一个问题:我现在连 10 个商户都没有,隔离性真的是我最需要解决的问题吗?
答案很扎心:不是。
我真正需要的是:改得快、看得清、部署简单。MVP 阶段,开发效率比隔离完美重要得多。
四、"倒退"式决策:从独立 Schema 退回共享表
于是我做了一个从架构书上看起来"倒退"的决策:从独立 Schema 退回共享表。
具体做法落地在五个层面:
4.1 认证层:tenant_id 从 Token 里取,绝不信前端
rust
// server/src/middlewares/auth_middleware.rs
pub async fn global_api_auth_middleware(
State(db): State<Database>,
mut req: Request<Body>,
next: Next,
) -> Result<Response<Body>, StatusCode> {
// JWT 里只放 merchant_id,不放 schema_name
let claims = auth.get_claims(&token)?;
let merchant_id = claims.merchant_id.clone();
// 注入 TenantContext,后面的 handler 直接从 extension 取
req.extensions_mut().insert(TenantContext {
merchant_id: merchant_id.clone(),
role: claims.role.clone(),
});
Ok(next.run(req).await)
}
租户 ID 绝不从前端请求参数获取 。所有 /api/orders?merchant_id=xxx 这种写法直接 ban 掉。租户身份只在 JWT 签发时注入,中间件提取后放到 TenantContext,后续链路都从 context 里取。
4.2 数据层:每个查询第一行永远是 WHERE merchant_id = ?
rust
// backend/src/infrastructure/queries/crm/order_query_impl.rs
fn build_order_condition(query: &OrderQuery, merchant_uuid: Uuid) -> Result<Condition, String> {
let mut condition = Condition::all();
// 这行是底线:所有查询必须带 merchant_id
condition = condition.add(Column::MerchantId.eq(merchant_uuid));
// 以下才是业务筛选条件
if let Some(status) = query.status.clone() {
condition = condition.add(Column::Status.eq(status));
}
// ...
Ok(condition)
}
而且这段代码有单元测试保证:
rust
#[test]
fn generated_sql_contains_merchant_scope_for_order_list() {
let sql = Entity::find()
.filter(build_order_condition(&query, merchant_uuid).expect("condition"))
.build(DbBackend::Postgres)
.to_string();
// 验证 SQL 里一定有 merchant_id 过滤条件
assert!(
sql.contains(r#""orders"."merchant_id" = '...'"#),
"sql: {sql}"
);
}
4.3 Repository 层:构造函数强制接收 merchant_id
rust
// backend/src/infrastructure/repositories/crm/order_repository_impl.rs
pub struct SeaOrmOrderRepository {
_db: DatabaseConnection,
merchant_id: String, // 构造时必须传入,不存在默认值
}
impl SeaOrmOrderRepository {
pub fn new(db: DatabaseConnection, merchant_id: String) -> Self {
Self { _db: db, merchant_id }
}
}
不传 merchant_id 就不给你查。类型系统帮你防住"忘了加过滤条件"这个错误。
4.4 事件溯源层:stream identity 天然隔离
事件溯源里,每个事件流都有 id 标识:
rust
// backend/src/domain/crm/order/es/events.rs
pub enum OrderEventEnvelope {
OrderCreated {
#[id]
merchant_id: String, // 租户隔离键,事件流 ID 的一部分
#[id]
order_uuid: String, // 聚合根 ID
// ...
},
}
(merchant_id, order_uuid) 组成事件流的唯一标识------两个不同商户的订单事件流绝不可能混在一起。
4.5 唯一约束改成复合的
sql
-- 之前:独立 Schema,phone 唯一就够了
CREATE UNIQUE INDEX idx_user_phone ON merchant_001.users (phone);
-- 之后:共享表,(merchant_id, phone) 才唯一
CREATE UNIQUE INDEX idx_user_phone ON public.users (merchant_id, phone);
所有涉及唯一性的业务约束都要加上 merchant_id,否则商户 A 的手机号就会跟商户 B 冲突。
五、开通新商户变成两行代码
做了这个"倒退"后,开通新商户的流程从:
sql
创建商户 → CREATE SCHEMA → 跑 Migration → 插入默认数据
变成了:
rust
// 开通新商户:
// 1. merchant 表插一条
// 2. 默认数据插几条
// 搞定。
不需要 CREATE SCHEMA,不需要跑 Migration。一个 API 调用 200ms 内完成。
改表加字段更是一行 migration 影响所有商户,不需要跑 N 遍。
六、最大的风险:这不是银弹
当然,这个方案最大的风险我心里很清楚:哪天真写漏一个 WHERE merchant_id,数据就跨租户泄露了。
目前靠三道防线扛:
- 代码规范 :所有查询必须在
build_condition里以Column::MerchantId.eq开头 - Code Review :PR Review 时第一眼看是否有
merchant_id过滤 - 单元测试 :核心查询的 SQL 生成测试断言
merchant_id一定在 WHERE 子句里
但老实说,这三道防线都是人肉防线,不是系统级保障。
进阶方案比如 PostgreSQL 的 Row Level Security (RLS) 可以在数据库层面兜底------设置 policy 让当前连接只能看到 tenant_id = current_setting('app.tenant_id') 的行。但 RLS 要注意连接池的坑:你的连接是复用的,current_setting 在事务结束后需要重置,否则下一个请求可能读到上一个租户的数据。
RLS 大概率是下一个迭代会加上的东西。但对当前阶段来说,代码规范 + Review + 测试足够了。
七、总结
从独立 Schema 退回到共享表,听起来像是架构上的"开倒车"。但这个决策让开发速度提升了至少 3 倍------改表快、排查快、开通快。
我想表达的核心观点是:
MVP 阶段,改得快比隔离完美重要得多。 你的用户只关心功能好不好用,不会关心你的数据库有没有按 Schema 隔离。
而且退回到共享表不意味着以后不能升级:
merchant_id天然就是未来的分片键- 真要拆库时,按
WHERE merchant_id = ?导出数据 → 导入独立 Schema → 改连接配置,路径是通的
如果你也在做 SaaS 多租户,几种方案的选择顺序我的建议是:
- < 1000 商户 :共享表 +
merchant_id过滤,开发效率最高 - 1000~10000 商户:共享表 + RLS 兜底,系统级保障
- > 10000 商户 或 强监管:独立数据库/独立 Schema
你的 SaaS 用的是哪种多租户方案?遇到过什么坑?欢迎在评论区聊聊。
完整的代码见 GitHub 仓库 Pico-CRM,Rust 全栈(Axum + Leptos + PostgreSQL)。还在持续迭代中,欢迎 Star 和交流。