多租户隔离,我选择"倒退"后开发速度直接翻了 3 倍

一、业务背景:家政 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,数据就跨租户泄露了。

目前靠三道防线扛:

  1. 代码规范 :所有查询必须在 build_condition 里以 Column::MerchantId.eq 开头
  2. Code Review :PR Review 时第一眼看是否有 merchant_id 过滤
  3. 单元测试 :核心查询的 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 多租户,几种方案的选择顺序我的建议是:

  1. < 1000 商户 :共享表 + merchant_id 过滤,开发效率最高
  2. 1000~10000 商户:共享表 + RLS 兜底,系统级保障
  3. > 10000 商户 或 强监管:独立数据库/独立 Schema

你的 SaaS 用的是哪种多租户方案?遇到过什么坑?欢迎在评论区聊聊。


完整的代码见 GitHub 仓库 Pico-CRM,Rust 全栈(Axum + Leptos + PostgreSQL)。还在持续迭代中,欢迎 Star 和交流。

相关推荐
敖正炀2 小时前
手写简易 MyBatis 框架(mini-mybatis)—— 完善版架构设计与核心实现
后端·mybatis
掘金者阿豪2 小时前
R3play让听歌不再费劲,想咋就咋!
后端
EthanYuan2 小时前
🦴不是MCP害了我,是这个阻塞害了我啊
后端
用户298698530142 小时前
Java 文档处理:在 Word 中插入分页符与分节符
java·后端
fliter2 小时前
分布式聚合查询的工程内幕:Cloudflare R2 SQL 如何实现 GROUP BY
后端
无限进步_2 小时前
【C++】红黑树完全解析:从概念到插入与平衡维护
java·c语言·开发语言·数据结构·c++·后端·算法
MacroZheng2 小时前
狂揽34k star!一款AI编程必不可少的神器,和Claude Code/Codex绝配!
人工智能·后端·claude
阿聪谈架构3 小时前
第09章:AI Skills 技能系统 —— 用能力包管理 Agent 的技能库
人工智能·后端