Skip to content

SaaS 多租户概述

作者:唐亚峰 | battcn
字数统计:1.8k 字

学习目标

深入理解 Wemirr Platform 的 SaaS 多租户架构设计和实现

什么是多租户?

多租户(Multi-Tenancy)是 SaaS 应用的核心架构模式,让多个租户(客户/组织)共享同一套应用程序,同时确保各租户数据完全隔离。

核心特性

┌─────────────────────────────────────────────────────────┐
│                    多租户核心特性                         │
├─────────────────────────────────────────────────────────┤
│  🔐 数据隔离     │ 租户之间数据完全隔离,互不可见         │
│  💰 资源共享     │ 多租户共享计算资源,降低成本           │
│  📦 独立配置     │ 每个租户可独立配置功能和外观           │
│  🔄 统一升级     │ 平台统一升级,所有租户同步受益         │
│  📊 独立计费     │ 支持按租户独立计费和配额控制           │
└─────────────────────────────────────────────────────────┘

架构设计

整体架构

                         ┌─────────────────┐
                         │     用户请求     │
                         └────────┬────────┘

                         ┌────────▼────────┐
                         │    API 网关      │
                         │  ├─ 租户解析      │
                         │  └─ 路由转发      │
                         └────────┬────────┘

              ┌───────────────────┼───────────────────┐
              │                   │                   │
        ┌─────▼─────┐      ┌─────▼─────┐      ┌─────▼─────┐
        │    IAM    │      │   Suite   │      │  Plugin   │
        │  认证中心  │      │  业务中心  │      │  插件中心  │
        └─────┬─────┘      └─────┬─────┘      └─────┬─────┘
              │                   │                   │
              └───────────────────┼───────────────────┘

                      ┌───────────┴───────────┐
                      │     租户数据隔离       │
                      ├───────────────────────┤
                      │  ┌─────┐ ┌─────┐     │
                      │  │租户A│ │租户B│ ... │
                      │  └─────┘ └─────┘     │
                      └───────────────────────┘

租户识别

租户识别通过以下方式实现:

  1. 请求头 - X-Tenant-IdTenant-Code
  2. Token - JWT 中携带租户信息
  3. 域名 - 子域名解析
java
// 租户解析优先级
1. Header: X-Tenant-Id / Tenant-Code
2. Token 中的 tenant_id 字段
3. 请求参数 tenantId

隔离策略

Wemirr Platform 支持三种租户隔离策略:

1. 字段隔离(Column)

原理:所有租户共享数据库和表,通过 tenant_id 字段区分数据。

┌────────────────────────────────────────┐
│            t_order 表                   │
├────────────────────────────────────────┤
│  id  │ tenant_id │ order_no │  amount │
│  1   │    1001   │ NO001    │  100.00 │
│  2   │    1001   │ NO002    │  200.00 │
│  3   │    1002   │ NO001    │  150.00 │
│  4   │    1002   │ NO002    │  300.00 │
└────────────────────────────────────────┘

配置方式

yaml
# application.yml
extend:
  mybatis-plus:
    multi-tenant:
      type: column
      include-tables: t_order,t_product,t_customer
      # 忽略租户过滤的表
      ignore-tables: sys_dict,sys_config

特点

优点缺点
简单易用,开发成本低单表数据量大时性能下降
便于统计分析安全风险:SQL 注入可能跨租户
资源利用率高租户定制化受限

适用场景:中小型租户,年数据量 < 200W

2. Schema 隔离

原理:每个租户独立 Schema(数据库),共享数据库实例。

┌─────────────────────────────────────────┐
│           MySQL 实例                     │
├─────────────┬─────────────┬─────────────┤
│  tenant_001 │  tenant_002 │  tenant_003 │
│  ├─ t_order │  ├─ t_order │  ├─ t_order │
│  ├─ t_user  │  ├─ t_user  │  ├─ t_user  │
│  └─ ...     │  └─ ...     │  └─ ...     │
└─────────────┴─────────────┴─────────────┘

特点

优点缺点
数据隔离性好租户数量受数据库实例限制
便于备份恢复跨租户统计复杂
支持一定定制化Schema 管理复杂

3. 数据源隔离(DataSource)

原理:每个租户独立数据库实例,完全物理隔离。

┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│  MySQL-A    │  │  MySQL-B    │  │  MySQL-C    │
│  (租户 A)   │  │  (租户 B)   │  │  (租户 C)   │
│  ├─ t_order │  │  ├─ t_order │  │  ├─ t_order │
│  └─ ...     │  │  └─ ...     │  │  └─ ...     │
└─────────────┘  └─────────────┘  └─────────────┘

配置方式

yaml
# application.yml
extend:
  mybatis-plus:
    multi-tenant:
      type: datasource
      strategy: feign  # 非 IAM 模块配置为 feign
      db-notify: redis # 数据源变更通知方式

特点

优点缺点
完全隔离,安全性最高成本高
性能最优运维复杂
支持完全定制化统一升级复杂

适用场景:大型租户,高安全要求,年数据量 > 200W

选择建议

              ┌─────────────────────────────────┐
              │       如何选择隔离策略?         │
              └─────────────────────────────────┘

                    租户数据量大吗?
                    年订单 > 200W?

              ┌───────────────┴───────────────┐
              │                               │
             否                              是
              │                               │
      需要跨租户统计?              对安全性要求高吗?
              │                               │
       ┌──────┴──────┐               ┌────────┴────────┐
       │             │               │                 │
      是            否              是                否
       │             │               │                 │
   字段隔离      Schema隔离      数据源隔离        Schema隔离

核心功能

租户管理

  • 租户创建 - 创建新租户,初始化基础数据
  • 租户配置 - 配置租户功能、限制、外观
  • 租户切换 - 支持用户在多租户间切换
  • 租户禁用 - 暂停或禁用租户

产品订阅

支持租户订阅不同产品套餐:

┌──────────────────────────────────────────────────────┐
│                    产品套餐管理                        │
├──────────────────────────────────────────────────────┤
│  基础版          │  专业版          │  企业版         │
│  ├─ 5 用户       │  ├─ 50 用户      │  ├─ 不限用户    │
│  ├─ 1GB 存储     │  ├─ 100GB 存储   │  ├─ 1TB 存储    │
│  ├─ 基础功能     │  ├─ 高级功能     │  ├─ 全部功能    │
│  └─ ¥99/月      │  └─ ¥999/月     │  └─ ¥9999/月   │
└──────────────────────────────────────────────────────┘

数据初始化

新租户创建时自动初始化:

  • 基础角色和权限
  • 系统字典数据
  • 默认管理员账号
  • 初始配置项

真实代码示例

以下代码均来自 wemirr-platform-iam 模块的真实实现。

租户实体

java
// 来自 Tenant.java
@Data
@TableName("t_tenant")
public class Tenant extends SuperEntity<Long> {
    
    @Schema(description = "编码")
    private String code;
    
    @Schema(description = "名称")
    private String name;
    
    @Schema(description = "状态;0=未启用;1=启用")
    private Boolean status;
    
    @Schema(description = "LOGO")
    private String logo;
    
    @Schema(description = "联系人")
    private String contactPerson;
    
    @Schema(description = "联系方式")
    private String contactPhone;
    
    @Schema(description = "行业")
    private String industry;
    
    @Schema(description = "统一信用代码")
    private String creditCode;
    
    @Schema(description = "法人")
    private String legalPersonName;
}

租户数据源绑定

java
// 来自 TenantDbBinding.java
@Data
@TableName("t_tenant_db_binding")
public class TenantDbBinding extends SuperEntity<Long> {

    @Schema(description = "租户ID")
    private Long tenantId;

    @Schema(description = "物理节点ID")
    private Long dbInstanceId;

    @Schema(description = "隔离策略: DATABASE, SCHEMA, COLUMN")
    private String strategy;

    @Schema(description = "运行时Schema名称")
    private String schemaName;

    @Schema(description = "是否为主数据源")
    private Boolean isPrimary;
}

租户创建

java
// 来自 TenantServiceImpl.java
@Override
@DSTransactional(rollbackFor = Exception.class)
public void create(TenantSaveReq req) {
    // 1. 校验租户名称唯一
    long nameCount = this.baseMapper.selectCount(Tenant::getName, req.getName());
    if (nameCount > 0) {
        throw CheckedException.badRequest("租户名称重复");
    }
    // 2. 校验租户编码唯一
    long codeCount = this.baseMapper.selectCount(Tenant::getCode, req.getCode());
    if (codeCount > 0) {
        throw CheckedException.badRequest("租户编码重复");
    }
    // 3. 创建租户
    Tenant tenant = BeanUtil.toBean(req, Tenant.class);
    this.baseMapper.insert(tenant);
}

租户初始化

java
// 来自 TenantServiceImpl.java - 初始化租户数据
@Override
@DSTransactional(rollbackFor = Exception.class)
public void initSqlScript(Long id) {
    var tenant = Optional.ofNullable(this.baseMapper.selectById(id))
        .orElseThrow(() -> CheckedException.notFound("租户信息不存在"));
    
    if (!tenant.getStatus()) {
        throw CheckedException.badRequest("租户未启用");
    }
    
    final DatabaseProperties.MultiTenant multiTenant = properties.getMultiTenant();
    if (isSuperTenant(tenant, multiTenant)) {
        throw CheckedException.badRequest("超级租户,禁止操作");
    }
    
    // 根据隔离模式选择初始化方式
    if (multiTenant.getType() == MultiTenantType.COLUMN) {
        initColumnTypeTenant(tenant);      // 字段隔离
    } else if (multiTenant.getType() == MultiTenantType.DATASOURCE) {
        initDatasourceTypeTenant(tenant);  // 数据源隔离
    }
}

// 初始化租户基础数据
private void initializeTenantData(Tenant tenant, Role role) {
    // 1. 创建默认组织
    Org org = new Org();
    org.setLabel(tenant.getName());
    org.setTenantId(tenant.getId());
    org.setStatus(true);
    this.orgMapper.insert(org);

    // 2. 创建管理员用户
    User user = new User();
    user.setUsername(tenant.getContactPhone());
    user.setPassword(PasswordEncoderHelper.encode("123456"));
    user.setTenantId(tenant.getId());
    user.setNickName(tenant.getContactPerson());
    this.userMapper.insert(user);
    
    // 3. 分配管理员角色
    this.userRoleMapper.insert(UserRole.builder()
        .userId(user.getId())
        .roleId(role.getId())
        .build());
}

TenantHelper 工具类

java
// 来自 TenantHelper.java

// 判断是否是超级租户
boolean isSuper = TenantHelper.isSuperTenant();

// 使用主数据源执行(查询平台级数据)
List<Dict> dictList = TenantHelper.executeWithMaster(() -> {
    return dictMapper.selectList(SysDict::getType, 1);
});

// 切换到指定租户数据源执行
TenantHelper.executeWithTenantDb("8888", () -> {
    List<User> users = userMapper.selectByTenantId(tenantId);
    return users;
});

// 临时忽略租户过滤
User user = TenantHelper.withIgnoreStrategy(() -> {
    return userMapper.selectById(userId);
});

// 根据隔离类型执行不同逻辑
TenantHelper.executeWithIsolationType(
    () -> dbSupplier.get(),     // 数据源隔离逻辑
    () -> columnSupplier.get()  // 字段隔离逻辑
);

租户管理 API

java
// 来自 TenantController.java
@RestController
@RequestMapping("/tenants")
@Tag(name = "租户管理")
public class TenantController {

    @PostMapping
    @Operation(summary = "创建租户")
    public void create(@RequestBody TenantSaveReq req);

    @PutMapping("/{id}")
    @Operation(summary = "修改租户")
    public void modify(@PathVariable Long id, @RequestBody TenantSaveReq req);

    @PutMapping("/{id}/init-sql-script")
    @Operation(summary = "加载初始数据")
    @RedisLock(prefix = "tenant:init-script")
    public void initSqlScript(@PathVariable Long id);

    @GetMapping("/{id}/db-ref")
    @Operation(summary = "租户关联DB")
    public TenantDbBindingResp dbRef(@PathVariable Long id);

    @PostMapping("/{id}/db-binding")
    @Operation(summary = "租户关联DB")
    public void dbBinding(@PathVariable Long id, @RequestBody TenantDbBindingSaveReq req);

    @PutMapping("/{id}/refresh-dict")
    @Operation(summary = "字典刷新")
    public void refreshTenantDict(@PathVariable Long id);
}

数据源隔离模式详解

java
// 来自 TenantServiceImpl.java - 数据源隔离初始化
private void initDatasourceTypeTenant(Tenant tenant) {
    DynamicDataSourceHandler handler = SpringUtil.getBean(DynamicDataSourceHandler.class);
    
    // 准备变量
    Map<String, Object> variables = Maps.newHashMap();
    variables.put("tenant_id", tenant.getId());
    variables.put("tenant_name", tenant.getName());
    
    // 获取租户绑定的数据源
    DbInstancePageResp dbInstance = dbInstanceMapper
        .getTenantDynamicDatasourceByTenantId(tenant.getId());
    
    // 执行初始化SQL脚本
    DynamicDataSourceEvent event = BeanUtil.toBean(dbInstance, DynamicDataSourceEvent.class);
    handler.initSqlScript(event, variables);
    
    // 切换到租户数据源初始化数据
    TenantHelper.executeWithTenantDb(tenant.getCode(), () -> {
        initializeTenantData(tenant, role);
        return null;
    });
}

下一步