引言#
多租户主要用来做数据隔离的,它通常被用于 SAAS 系统里面,把软件作为服务提供给企业使用。每个企业就是一个租户,每个租户只能看到本租户内的数据。所以需要对每个租户做数据隔离。
数据隔离#
多租户的数据隔离方案,可以分成分成三种:
DATASOURCE 模式:独立数据库
SCHEMA 模式:共享数据库,独立 Schema
COLUMN 模式:共享数据库,共享 Schema,共享数据表
DATASOURCE 模式#
一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。
DATASOURCE 模式
优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。
缺点:增大了数据库的安装数量,随之带来维护成本和购置成本的增加。
SCHEMA 模式#
多个或所有租户共享数据库,但一个租户一个表。
SCHEMA 模式
优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。
缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。
COLUMN 模式#
共享数据库,共享数据架构。租户共享同一个数据库、同一个表,但在表中通过 tenant_id 字段区分租户的数据。这是共享程度最高、隔离级别最低的模式。
COLUMN 模式
优点:维护和购置成本最低,允许每个数据库支持的租户数量最多。
缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。
总结#
在大多数情况下,DATASOURCE 和 COLUMN 模式最常被使用。
当预算充足或租户对安全性,隔离性要求较高时选择DATASOURCE 模式
普通情况下选择COLUMN 模式就足够了,开发、运维简单,以最少的服务器为最多的租户提供服务。
原理#
下面以最常用的COLUMN 共享数据库的模式来解释多租户的实现原理。
相信你已经注意到了,在这个模式下和普通的单租户应用的唯一区别仅在数据库表中新增了一个 tenant_id 字段。
没错,此字段为唯一租户标识,程序需要使用此字段来判断每个租户下有哪些数据。
你会想,那我直接在写 SQL 时把租户字段拼接上去不就行了?
当然可以这么做,但是,很明显不够优雅。
mybatis plus 多租户插件#
那怎么写出优雅的代码实现多租户呢?
mybatis 作为国内开发者最常用也是最熟悉的 ORM 框架,给我们提供了原生的多租户插件。
官方多租户插件
示例
public interface TenantLineHandler {
/**
* 获取租户 ID 值表达式,只支持单个 ID 值
* <p>
*
* @return 租户 ID 值表达式
*/
Expression getTenantId();
/**
* 获取租户字段名
* <p>
* 默认字段名叫: tenant_id
*
* @return 租户字段名
*/
default String getTenantIdColumn() {
// 如果该字段你不是固定的,请使用 SqlInjectionUtils.check 检查安全性
return "tenant_id";
}
/**
* 根据表名判断是否忽略拼接多租户条件
* <p>
* 默认都要进行解析并拼接多租户条件
*
* @param tableName 表名
* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
*/
default boolean ignoreTable(String tableName) {
return false;
}
}
说明:
多租户!= 权限过滤,不要乱用,租户之间是完全隔离的!!!
启用多租户后所有执行的 method 的 sql 都会进行处理.
自写的 sql 请按规范书写 (sql 涉及到多个表的每个表都要给别名,特别是 inner join 的要写标准的 inner join)
当我们使用此插件时,拼接 SQL 的操作就由 mybatis-plus 框架帮我们做了,它的实现方式是基于分页插件 (拦截器) 进行实现的。
进阶#
上面只是一个简单的示例,接下来我将以 ruoyi-vue-plus
脚手架来扩展多租户的功能。
yml 配置#
在 yml 配置文件新增如下配置
# 多租户配置
tenant:
# 是否开启多租户模式
enable: true
# 租户字段名
column: tenant_id
# 需要判断多租户的表名
includes:
- sr_card
- sr_room
- sr_seat
- sr_room_seat
- sr_swiper
- sys_oss
编写组件对应配置文件中的值
@Data
@Component
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
/**
* 是否开启多租户模式
*/
private Boolean enable;
/**
* 租户字段名
*/
private String column;
/**
* 需要判断多租户的表名
*/
private List<String> includes;
}
mybatis-plus 配置#
然后需要在 mybatis-plus 的配置文件中注册多租户插件,并且编写插件逻辑
@Resource
private TenantProperties tenantProperties;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
if (tenantProperties.getEnable()) {
// 多租户插件
interceptor.addInnerInterceptor(tenantLineInnerInterceptor());
}
// 数据权限处理
interceptor.addInnerInterceptor(dataPermissionInterceptor());
// 分页插件
interceptor.addInnerInterceptor(paginationInnerInterceptor());
// 乐观锁插件
interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
return interceptor;
}
首先,引入配置文件,然后注册多租户插件,需要注意的是,多租户插件需要在分页插件的前面。
然后编写对应的逻辑
public TenantLineInnerInterceptor tenantLineInnerInterceptor() {
return new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
try {
LoginUser loginUser = LoginUserShareMap.getLoginUser();
if (loginUser == null) {
System.err.println("当前未登录用户!");
//多租户模式,租户id为null
return new LongValue(0L);
} else {
//多租户模式,租户id从当前用户获取
return new LongValue(loginUser.getUserId());
}
} catch (Exception e) {
throw new RuntimeException("获取当前登录用户信息失败!", e);
}
}
@Override
public String getTenantIdColumn() {
// 对应数据库租户ID的列名
System.err.println("当前租户列名:" + tenantProperties.getColumn());
return tenantProperties.getColumn();
}
// 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件
@Override
public boolean ignoreTable(String tableName) {
LoginUser loginUser = LoginUserShareMap.getLoginUser();
// 判断是否登录,如果登录则过滤
if (loginUser != null) {
// 判断是否平台超级管理员,如果是平台超级管理员则拥有所有数据权限
if (!LoginHelper.isAdmin()) {
// 需要过滤租户的表
List<String> includes = tenantProperties.getIncludes();
return !includes.contains(tableName);
}
}
return true;
}
});
}
这里面有些需要注意的点
- 默认情况下
ruoyi-vue-plus
在项目初始化时会读取数据库表中的配置信息,而且又因为框架使用的是 sa-token 做的登录验证。在项目初始化执行此方法时,如果你使用 sa-token 获取登录用户,则会报 无法获取 web 上下文 的错误 - 可以使用一个组件来保存登录状态,通常情况下我们会使用
ThreadLocal
,但注意,此框架使用了线程池,使用ThreadLocal
可能会有预期外的错误。比如在高并发或高负载的情况下,用户登录的线程是 A, 但判断租户的线程却是 B,这 2 个线程的ThreadLocal
是不一样的。
特定 SQL 过滤#
多租户插件虽然提供了基于 表 的过滤,但没有提供更细粒度的基于 SQL 的过滤。
不过它提供了一个注解来实现
注解 | mybatis-plus 版本 |
---|---|
@InterceptorIgnore(tenantLine = "true") | Mybatis-Plus3.4+ |
@SqlParser(filter=true) | Mybatis-Plus3.4- |
需要注意的是如果你的 MP 版本在 3.1.1 及以下使用 @SqlParser(filter=true)
还需要配置
# 开启 SQL 解析缓存注解生效,如果你的MP版本在3.1.1及以上则不需要配置
mybatis-plus:
global-config:
sql-parser-cache: true
在 mapper 中使用即可
public interface TenantMapper extends BaseMapper<Tenant> {
/**
* 自定Wrapper, @SqlParser(filter = true)注解代表不进行SQL解析也就没有租户的附加条件。
*
* @return
*/
@SqlParser(filter = true)
@Select("SELECT count(5) FROM t_tenant ")
public Integer myCount();
}