SaaS 多租户概述
作者:唐亚峰 | battcn
字数统计:1.8k 字
学习目标
深入理解 Wemirr Platform 的 SaaS 多租户架构设计和实现
什么是多租户?
多租户(Multi-Tenancy)是 SaaS 应用的核心架构模式,让多个租户(客户/组织)共享同一套应用程序,同时确保各租户数据完全隔离。
核心特性
┌─────────────────────────────────────────────────────────┐
│ 多租户核心特性 │
├─────────────────────────────────────────────────────────┤
│ 🔐 数据隔离 │ 租户之间数据完全隔离,互不可见 │
│ 💰 资源共享 │ 多租户共享计算资源,降低成本 │
│ 📦 独立配置 │ 每个租户可独立配置功能和外观 │
│ 🔄 统一升级 │ 平台统一升级,所有租户同步受益 │
│ 📊 独立计费 │ 支持按租户独立计费和配额控制 │
└─────────────────────────────────────────────────────────┘架构设计
整体架构
┌─────────────────┐
│ 用户请求 │
└────────┬────────┘
│
┌────────▼────────┐
│ API 网关 │
│ ├─ 租户解析 │
│ └─ 路由转发 │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ IAM │ │ Suite │ │ Plugin │
│ 认证中心 │ │ 业务中心 │ │ 插件中心 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└───────────────────┼───────────────────┘
│
┌───────────┴───────────┐
│ 租户数据隔离 │
├───────────────────────┤
│ ┌─────┐ ┌─────┐ │
│ │租户A│ │租户B│ ... │
│ └─────┘ └─────┘ │
└───────────────────────┘租户识别
租户识别通过以下方式实现:
- 请求头 -
X-Tenant-Id或Tenant-Code - Token - JWT 中携带租户信息
- 域名 - 子域名解析
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;
});
}