4. SaaS多租户架构设计:租户隔离策略、路由与元数据管理

多租户,说白了就是一套系统服务N个客户。每个客户的数据怎么隔开?这是SaaS架构里最核心的决策之一。我这些年见过不少团队,一开始图省事选了简单的隔离方案,结果业务一扩张,数据迁移搞得焦头烂额。嗯,咱们今天就把这事聊透。

4.1 三种隔离策略:选对路子,少走弯路

租户隔离,本质上就三个流派。我习惯把它们比作租房:

  • 数据库级隔离:每个租户独享一栋楼(独立数据库)
  • Schema级隔离:每个租户一个独立楼层(同一数据库,不同Schema)
  • 表级隔离:大家住大通铺,靠身份证号(租户ID)区分

你想想看,这三种方案没有绝对的好坏,关键看你的业务场景。

4.1.1 数据库级隔离

每个租户一个独立数据库实例。这是最彻底的隔离方式。

适用场景:金融、医疗等强合规行业,或者大客户要求数据物理隔离。

我在项目中遇到过一家做HR SaaS的公司,他们的头部客户直接要求「数据不能和任何其他客户放在同一个MySQL实例里」。没得选,只能上数据库级隔离。

优点

  • 数据完全隔离,一个租户出问题不影响别人
  • 备份恢复简单,按库操作就行
  • 可以针对大客户单独做性能优化

缺点

  • 成本高,每个库都要消耗连接数、内存
  • 运维复杂,几百个库的监控、备份够你喝一壶
  • 跨租户查询基本不可能

4.1.2 Schema级隔离

同一个数据库实例,每个租户一个Schema(在MySQL里就是Database)。

我个人比较推荐这个方案,尤其是中小型SaaS。它平衡了隔离性和成本。

-- 租户A的数据
CREATE DATABASE tenant_001;
USE tenant_001;
CREATE TABLE orders (...);

-- 租户B的数据  
CREATE DATABASE tenant_002;
USE tenant_002;
CREATE TABLE orders (...);

优点

  • 比数据库级省钱,共享一个实例
  • 隔离性还不错,表结构可以不同
  • 迁移方便,把整个Schema导出就行

缺点

  • 单个实例有上限(MySQL一般建议不超过1000个库)
  • 一个租户的慢查询可能拖累整个实例

避坑指南:我曾经遇到一个客户,所有租户共用一个数据库实例,结果某个大租户跑了个全表扫描,整个实例CPU飙到100%,所有租户都挂了。后来我们加了实例级别的资源隔离,才解决这个问题。

4.1.3 表级隔离

所有租户共用同一套表,通过tenant_id字段区分数据。

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    tenant_id VARCHAR(32) NOT NULL,
    order_no VARCHAR(64),
    amount DECIMAL(10,2),
    created_at DATETIME,
    INDEX idx_tenant_id (tenant_id)
);

这是最经济的方案,但也是最容易出问题的。

优点

  • 成本最低,一套表搞定
  • 跨租户统计分析方便
  • 运维简单,就一个库

缺点

  • 隔离性最差,代码里漏掉where tenant_id=?就完蛋
  • 数据量大了以后,索引和查询性能是噩梦
  • 一个租户的数据损坏,可能影响所有人

我的建议:如果选表级隔离,一定要在ORM层做强制拦截。比如MyBatis的拦截器,自动给所有查询加上tenant_id条件。别指望每个开发人员都记得写。

4.2 租户路由:请求怎么找到自己的家

隔离策略定好了,接下来就是路由问题。用户发来一个请求,系统怎么知道他是哪个租户的?

常见的路由方式有三种:

路由方式 实现方式 优点 缺点
域名路由 tenant-a.your-saas.com 最直观,用户无感知 需要通配符域名和证书
请求头路由 Header: X-Tenant-ID: abc123 灵活,适合API调用 前端需要维护租户上下文
路径路由 /abc123/api/orders 实现简单 URL不够优雅

我个人习惯用域名路由。为什么?因为用户访问acme.myapp.com,他根本不知道背后还有别的租户。体验最好。

// 一个简单的租户路由中间件(Node.js示例)
app.use(async (req, res, next) => {
    // 从域名提取租户标识
    const host = req.hostname;
    const tenantCode = host.split('.')[0];
    
    // 查询租户元数据
    const tenant = await TenantService.getByCode(tenantCode);
    if (!tenant) {
        return res.status(404).send('租户不存在');
    }
    
    // 绑定到请求上下文
    req.tenant = tenant;
    req.tenantId = tenant.id;
    
    // 动态切换数据源
    await DataSourceSwitcher.switchTo(tenant.databaseName);
    
    next();
});

注意:路由信息一定要缓存。我曾经见过一个系统,每次请求都去查数据库获取租户信息,QPS一上来直接打挂了。用Redis缓存租户路由表,TTL设个5分钟,够用了。

4.3 租户元数据管理:系统的「户口本」

租户元数据,就是记录每个租户「是谁、用什么数据库、什么配置」的那张表。这张表要是设计不好,后面运维能累死你。

我一般这样设计:

CREATE TABLE tenants (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_code VARCHAR(32) NOT NULL UNIQUE COMMENT '租户编码,用于路由',
    tenant_name VARCHAR(128) NOT NULL COMMENT '租户名称',
    status TINYINT DEFAULT 1 COMMENT '1:启用 0:禁用',
    
    -- 隔离策略相关
    isolation_level ENUM('database', 'schema', 'table') NOT NULL,
    database_name VARCHAR(64) COMMENT '数据库名或Schema名',
    db_host VARCHAR(128) COMMENT '独立数据库时使用',
    db_port INT DEFAULT 3306,
    db_user VARCHAR(64),
    db_password VARCHAR(256), -- 建议加密存储
    
    -- 配置信息
    plan_type VARCHAR(32) COMMENT '套餐类型',
    max_users INT DEFAULT 10 COMMENT '最大用户数',
    storage_quota BIGINT DEFAULT 1073741824 COMMENT '存储配额,默认1GB',
    
    -- 时间戳
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这张表是整个多租户架构的「大脑」。每次请求进来,先查这张表,才知道往哪路由。

核心要点

  • tenant_code要唯一,且不可变。一旦变了,所有域名、缓存都得更新
  • 数据库连接信息建议加密存储,别明文存密码
  • 加一个status字段,可以快速禁用某个租户

4.4 混合策略:现实世界没有银弹

说了这么多,其实真实项目里很少只用一种策略。我见过最典型的做法是:

  • 免费版/小客户:表级隔离,降低成本
  • 标准版/中型客户:Schema级隔离,性价比高
  • 企业版/大客户:数据库级隔离,满足合规和性能要求

这种混合模式,需要在租户元数据表里记录每个租户的隔离级别和连接信息。路由层根据这些信息动态切换数据源。

// 动态数据源切换的核心逻辑
public class DynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        TenantContext tenant = TenantContextHolder.get();
        if (tenant == null) {
            return "default";
        }
        
        // 根据隔离级别返回不同的key
        switch (tenant.getIsolationLevel()) {
            case "database":
                return "db_" + tenant.getDatabaseName();
            case "schema":
                return "schema_" + tenant.getTenantCode();
            default:
                return "shared"; // 表级隔离用共享数据源
        }
    }
}

避坑指南:我曾经在一个项目里,把所有租户的连接信息都放在一个Map里。结果租户一多,Map越来越大,GC压力剧增。后来改用连接池分组管理,每个租户一个连接池子,才解决问题。记住,连接池不是越多越好,但也不是越少越好。

4.5 总结一下

多租户架构设计,说白了就是三个选择:

  1. 怎么隔离:数据库、Schema、表,或者混着来
  2. 怎么路由:域名、请求头、路径,选个顺手的方式
  3. 怎么管理:一张元数据表,管好所有租户的「户口信息」

没有完美的方案,只有适合你业务的方案。我个人的建议是:

  • 起步阶段用Schema级隔离,成本可控,扩展方便
  • 等有了大客户,再单独给他们开独立数据库
  • 元数据表设计时留好扩展字段,后面加配置不用改表结构

嗯,多租户这事,想清楚再动手。一旦选错了隔离策略,后面数据迁移的成本,够你哭半年的。