1、SaaS数据模型设计:多租户架构概述、三种主流租户隔离方案与数据路由

大家好,我是你们的老朋友。今天咱们来聊聊SaaS系统里最核心、也最让人头疼的问题——数据怎么存。

你想想看,一个SaaS平台要服务成百上千个客户(也就是租户),每个客户的数据都得安全隔离,不能串。但你又不能给每个客户单独部署一套数据库,那成本谁也扛不住。怎么办?

嗯,这就是多租户数据架构要解决的核心矛盾。我个人习惯把这个问题拆成两个层面来看:隔离粒度路由效率。今天咱们就把这三个主流方案掰开揉碎了讲清楚。

1.1 多租户架构:到底在解决什么问题?

说白了,多租户架构就是「一套系统,服务N个客户」。每个客户看到的都是自己的专属系统,但底层其实是共享的。

我在项目中遇到过最典型的场景:一个客户突然说「我们公司数据特别敏感,能不能物理隔离?」另一个客户却说「我们预算有限,能省就省吧」。你看,需求完全相反。

所以,没有银弹。你得根据客户画像、合规要求、预算水平,选择不同的隔离方案。

1.2 三种主流租户隔离方案

业界公认的三种方案,我按隔离强度从高到低给你排个序:

方案 隔离级别 共享程度 运维成本 典型场景
Database per Tenant 最高 几乎不共享 金融、医疗、大客户
Schema per Tenant 中等 共享实例,隔离Schema 中型企业、行业SaaS
Shared Table 最低 完全共享 小微企业、C端工具

方案一:Database per Tenant(独立数据库)

这个方案最暴力,也最安全。每个租户一个独立的数据库实例。数据完全物理隔离,备份恢复互不影响。

优点:

  • 数据隔离性最好,一个租户出问题不影响别人
  • 可以针对大客户单独做性能调优、备份策略
  • 迁移、扩容非常灵活

缺点:

  • 连接数爆炸。1000个租户就是1000个数据库连接池
  • 运维成本高。升级一次数据库版本,要跑1000次脚本
  • 资源利用率低。小租户可能只用了1%的容量
⚠️ 避坑指南: 我曾经接手过一个项目,给每个租户单独建库,结果半年后数据库连接数把服务器搞挂了。后来不得不引入连接池中间件,才勉强撑住。所以,选这个方案前,先算好你的连接数天花板。

方案二:Schema per Tenant(共享实例,隔离Schema)

这个方案是我个人比较推荐的折中方案。所有租户共享同一个数据库实例,但每个租户拥有独立的Schema(在MySQL里就是独立的Database)。

你想想看,这样既保留了逻辑隔离,又避免了连接数爆炸。一个实例可以轻松承载几百个租户。

优点:

  • 连接数可控,一个实例一个连接池
  • 数据逻辑隔离,查询时天然带Schema前缀
  • 备份恢复可以按租户粒度操作

缺点:

  • 实例级别的故障会影响所有租户
  • 跨租户统计查询比较麻烦
  • Schema数量过多时,元数据管理会变慢
💡 我的经验: 我建议把租户ID编码到Schema名称里,比如 tenant_1001tenant_1002。这样一眼就能看出是哪个租户,排查问题特别方便。

方案三:Shared Table(共享表)

这个方案最省事,也最省钱。所有租户的数据放在同一张表里,通过一个 tenant_id 字段来区分。

说白了,就是一张表里既有A公司的数据,也有B公司的数据。嗯,这里要注意,所有查询都必须带上 tenant_id 条件,否则数据就串了。

优点:

  • 资源利用率最高,几乎没有浪费
  • 开发最简单,不需要动态建库建表
  • 扩容方便,加机器就行

缺点:

  • 数据隔离最弱,一个SQL写错就可能泄露数据
  • 索引压力大,一张表可能存几亿行数据
  • 租户数据删除困难,物理删除影响性能
⚠️ 避坑指南: 我曾经见过一个团队,共享表里忘了加 tenant_id 索引,结果一个租户的查询把整个库拖垮了。记住:共享表方案,索引设计是第一优先级

1.3 租户上下文与数据路由

不管选哪种方案,你都得解决一个问题:怎么知道当前请求是哪个租户?

这就是租户上下文(Tenant Context)要做的事。我习惯在请求入口处(比如Filter或Interceptor)解析租户信息,然后塞到ThreadLocal里。这样整个请求链路都能拿到。

数据路由就更直接了。根据租户ID,决定数据往哪个库、哪个表、哪个Schema里写。

来看一个简单的路由示例:

// 伪代码:根据租户ID选择数据源
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        // 从ThreadLocal获取当前租户ID
        String tenantId = TenantContext.getCurrentTenant();
        // 根据租户ID映射到对应的数据源Key
        return "datasource_" + tenantId;
    }
}

这段代码的核心逻辑就一句话:请求来了,先认人,再认路

核心要点总结:

  • 隔离方案没有绝对的好坏,只有合不合适
  • 大客户用独立库,中型客户用独立Schema,小客户用共享表
  • 租户上下文一定要在请求入口处统一处理,别散落在业务代码里
  • 数据路由的Key(tenant_id)必须全局唯一且稳定

好了,这一章的内容就到这里。下一章咱们会深入聊聊「分库分表」的具体实现,包括怎么选分片键、怎么处理跨分片查询。到时候我会分享一个我踩过的坑——分片键选错了,导致全表扫描,差点被老板骂死。敬请期待!