文章目录
- **1. 独立数据库**(**database-based multitenancy**)
- **2. 共享数据库,隔离数据架构(**schema-based multitenancy**)**
- **3. 共享数据库,共享数据架构**(**table-based multitenancy**)
- **4. 方案对比**
- 1、租户间是资源隔离的。相互无法访问对方的数据。
- 2、组织也是一种资源,各租户都有自己的组织。
- 3、租户可以通过组织进行资源划分。
- 4、租户不支持多层级,租户有不同的类型。
- 5、通过组织的层级结构,来实现用户的数据权限。
- 6、运营侧只管理到租户级别,不应该涉及到租户的私有资源。
“多租户技术或称多重租赁技术” 是一种软件架构技术,是实现 如何在多组织环境下共用相同的系统或程序组件,并且可确保各租户间数据的隔离性。在当下云计算时代,多租户技术在共用的数据中心以单一系统架构与服务提供多数客户端相同甚至可定制化的服务,并且仍可以保障客户的数据隔离。目前各种各样的云计算服务就是这类技术范畴。
实现方案
多租户的数据隔离方案,不外乎以下三种方案(来自网络转载)分别是:
1. 独立数据库(database-based multitenancy)
也称per-database-per-tenant,即一个租户一个数据库实例。
这是第一种方案,即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。
优点: 为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。
缺点: 增多了数据库的安装数量,随之带来维护成本和购置成本的增加。这种方案与传统的一个客户、一套数据、一套部署类似,差别只在于软件统一部署在运营商那里。如果面对的是银行、医院等需要非常高数据隔离级别的租户,可以选择这种模式,提高租用的定价。如果定价较低,产品走低价路线,这种方案一般对运营商来说是无法承受的。
2. 共享数据库,隔离数据架构(schema-based multitenancy)
也称per-schema-per-tenant,即一个租户一个schema,但都共享同一个数据库实例。
这是第二种方案,即多个或所有租户共享 DataBase,但是每个租户一个 Schema(也可叫做一个user)。
在MySQL中,schema和database是同义词.
CREATE SCHEMA和CREATE DATABASE是等效的.
但是其他的数据库产品(几乎所有数据库)有所不同.在oracle数据库产品中,schema是database的一部分.
表示the tables and other objects owned by a single user.
优点: 为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;
缺点: 如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据;如果需要跨租户统计数据,存在一定困难。
3. 共享数据库,共享数据架构(table-based multitenancy)
即所有租户都使用一个表,然后通过在所有表中增加一个字段(通常就是租户id)来区分不同租户。数据库实例和表都是共享的。
这是第三种方案,即租户共享同一个 DataBase、同一个 Schema,但在表中增加了租户ID
的多租户的数据字段。这是共享程度最高、隔离级别最低、维护成本和购置最低的模式。也是一种逻辑隔离方案。
优点: 三种方案比较,第三种方案的维护和购置成本最低,跨租户统计方便,允许每个数据库支持的租户数量多。
缺点: 隔离级别最低,需要在设计开发时加大对安全的开发量;对单个租户的数据备份和恢复困难。
如果希望以最少的服务器为最多的租户提供服务,并且租户接受牺牲隔离级别换取降低成本,这种方案最适合。
4. 方案对比
审视一下三种设计方案,从1到3隔离程度越来越低,共享程度越来越高。我们从以下一些维度对比一下它们各自的优劣(注意:对比主要是从数据库角度看的,而不是整个SaaS):
- 可扩展性:隔离度越高,扩展性越差。数据库实例在数据库中是一个比较重的资源,虽然RDBMS中一般没有对database的个数做限制,但一个数据库服务器上面创建成千上万个数据库实例的场景应该是很少见的吧。所以从1到3,扩展性依次变差。
- 隔离性:主要是数据的隔离、负载的隔离。这个很明显,隔离性1最好,3最差,2适中。
- 成本/资源利用率:这里的成本主要指数据库的成本,或者说硬件的资源利用率。隔离程度越高,利用率越差。比如很多业务其实都有业务高峰和低峰,如果能把高峰不在同一时间段的业务部署在一起,自然是能够提升资源的利用率。
- 开发复杂度:主要体现在查询、过滤、database/schema/table切换等。1和2适中,3难度高一些。
- 运维复杂度:性能监控、管理;database/schema/table的管理;租户数据恢复;容灾等。扩展其实也算运维的一部分,第一个已经讨论过了,这里就不包含扩展了。从监控、管理、租户数据恢复、容灾等考虑,隔离度越高,越简单。
- 可定制性:根据不同租户的需求进行定制的难度,这个自然也是隔离度越高,定制化越好做。
技术方案实现
共享数据库,共享数据架构
应用管理员, 也就是平台管理员
设计原则
多租户SaaS系统怎么设计,下面是我总结的几点原则,供大家参考:
1、租户间是资源隔离的。相互无法访问对方的数据。
我们目前做到逻辑隔离,通过表里面增加租户ID的方式来实现多租户的支持。当然我们自然想做到物理隔离,相应的成本也会多很多。这块大家必须有租户间资源是隔离的概念。为了能更好的理解整个SaaS系统的设计初衷,我们可以认为租户间资源是物理隔离的。
2、组织也是一种资源,各租户都有自己的组织。
每个租户是有各自的资源信息的,这些资源是租户私有的。比如:角色信息、用户信息、组织信息等。
3、租户可以通过组织进行资源划分。
租户和组织这块有很多相近的地方,这块需要深刻的理解下。
我个人是这么理解的:租户是对全部资源物理层面的隔离,而组织是对租户私有资源逻辑上的隔离。
4、租户不支持多层级,租户有不同的类型。
为了降低系统的复杂性,我们建议租户不支持多层级,只能建一级,租户是有类型的,通过类型区分不同的业务场景,租户间是平等的。
比如:XXX运营方也是独立的租户,与其它用户无本质区别。
5、通过组织的层级结构,来实现用户的数据权限。
这块也是我们做的最大改动:轻租户,重组织。发挥组织的天然业务隔离的特性,通过组织树来实现资源数据权限。
6、运营侧只管理到租户级别,不应该涉及到租户的私有资源。
组织是租户的私有资源,运营管理侧自然不应该去管理他,也不方便管理。
思路设计
首先,我们为了租户能否方便的访问,以及平台能自动识别访问是哪个租户,我们在接入层采用通过url来识别租户。即系统在初始化租户信息时,会随机生成一个租户编码(租户编码允许修改一次),用于saas平台的三级域名监听,通过在业务系统的处理和绑定,当接收到请求时,拦截器会自动识别对应的租户编码,并加载对应的租户信息。
其次,在业务处理时,租户标识编号作为必须条件带入,进行数据操作。
后期,随着租户数量增多, 数据量必然指数级上涨,可以采用分库分表的处理策略。
首页:
- 区分平台端(
/platform/login
), 和租户端入口(/saas/login
)平台管理端:
- 新增租户管理, 包含租户基本信息, 租户角色, 租户权限管理,
- 1 – 租户基本信息, 要有租户编号,建议有个开放时间限制
- 新增运营管理:重视租户登录日志和操作日志的收集
- 业务修改:原来的业务逻辑中增加
租户ID
的字段平台租户端:
- 支持有各自的资源信息
技术实现-Mybatis-plus
Mybatis-plus
在第3层隔离级别上,提供了基于分页插件的多租户的解决方案,我们对此来进行介绍。参考: https://baomidou.com/guide/interceptor-tenant-line.html#tenantlineinnerinterceptor
第一步:租户管理
在应用添加维护一张sys_tenant(租户管理表),在需要进行隔离的数据表上新增
租户id
;
第二步:实现TenantHandler接口并实现它的方法:
public interface TenantHandler { /** * 获取租户 ID 值表达式,支持多个 ID 条件查询 * <p> * 支持自定义表达式,比如:tenant_id in (1,2) @since 2019-8-2 * * @param where 参数 true 表示为 where 条件 false 表示为 insert 或者 select 条件 * @return 租户 ID 值表达式 */ Expression getTenantId(boolean where); /** * 获取租户字段名 * * @return 租户字段名 */ String getTenantIdColumn(); /** * 根据表名判断是否进行过滤 * * @param tableName 表名 * @return 是否进行过滤, true:表示忽略,false:需要解析多租户字段 */ boolean doTableFilter(String tableName); }
PreTenantHandler 实现 TenantHandler
@Slf4j @Component public class PreTenantHandler implements TenantHandler { @Autowired private PreTenantConfigProperties configProperties; /** * 租户Id * * @return */ @Override public Expression getTenantId(boolean where) { //可以通过过滤器从请求中获取对应租户id Long tenantId = PreTenantContextHolder.getCurrentTenantId(); log.debug("当前租户为{}", tenantId); if (tenantId == null) { return new NullValue(); } return new LongValue(tenantId); } /** * 租户字段名 * * @return */ @Override public String getTenantIdColumn() { return configProperties.getTenantIdColumn(); } /** * 根据表名判断是否进行过滤 * 忽略掉一些表:如租户表(sys_tenant)本身不需要执行这样的处理 * * @param tableName * @return */ @Override public boolean doTableFilter(String tableName) { return configProperties.getIgnoreTenantTables().stream().anyMatch((e) -> e.equalsIgnoreCase(tableName)); } }
第三步:配置mybatisPlus的分页插件配置
这里主要实现的功能:
- 创建SQL解析器集合
- 创建租户SQL解析器
- 设置租户处理器,具体处理租户逻辑
@EnableTransactionManagement @Configuration @MapperScan({"com.xd.pre.**.mapper"}) public class MyBatisPlusConfig { // 租户处理器 @Autowired private PreTenantHandler preTenantHandler; /** * 分页插件 */ @Bean public PaginationInterceptor paginationInterceptor() { PaginationInterceptor paginationInterceptor = new PaginationInterceptor(); List<ISqlParser> sqlParserList = new ArrayList<>(); // 攻击 SQL 阻断解析器、加入解析链 sqlParserList.add(new BlockAttackSqlParser()); // 多租户拦截 TenantSqlParser tenantSqlParser = new TenantSqlParser(); tenantSqlParser.setTenantHandler(preTenantHandler); sqlParserList.add(tenantSqlParser); paginationInterceptor.setSqlParserList(sqlParserList); return paginationInterceptor; } }
配置好之后,不管是查询、新增、修改删除方法,MP都会自动加上租户ID的标识,测试如下:
@Test public void select(){ List<User> users = userMapper.selectList(Wrappers.<User>lambdaQuery().eq(User::getAge, 18)); users.forEach(System.out::println); }
运行sql实例:
DEBUG==> Preparing: SELECT id, login_name, name, password, email, salt, sex, age, phone, user_type, status, organization_id, create_time, update_time, version, tenant_id FROM sys_user WHERE sys_user.tenant_id = '001' AND is_delete = '0' AND age = ?
特定SQL过滤
如果在程序中,有部分SQL不需要加上租户ID的表示,需要过滤特定的sql,可以通过如下两种方式:
方式一:
在配置分页插件中加上配置ISqlParserFilter解析器,如果配置SQL很多,比较麻烦,不建议。
//有部分SQL不需要加上租户ID的表示,需要过滤特定的sql。如果比较多不建议这里配置。 /*paginationInterceptor.setSqlParserFilter(new ISqlParserFilter() { @Override public boolean doFilter(MetaObject metaObject) { MappedStatement ms = SqlParserHelper.getMappedStatement(metaObject); // 对应Mapper或者dao中的方法 if("com.erbadagang.mybatis.plus.tenant.mapper.UserMapper.selectList".equals(ms.getId())){ return true; } return false; } });*/
方式二:
通过租户注解的形式,目前只能作用于Mapper的方法上。特定sql过滤 过滤特定的方法 也可以在userMapper需要排除的方法上加入注解SqlParser(filter=true) 排除 SQL 解析。
package com.erbadagang.mybatis.plus.tenant.mapper; import com.baomidou.mybatisplus.annotation.SqlParser; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.erbadagang.mybatis.plus.tenant.entity.Tenant; import org.apache.ibatis.annotations.Select; /** * <p> * Mapper 接口 * </p> * */ 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(); }
参考链接:
https://gitee.com/jinzheyi/yubb-saas
https://gitee.com/xiaoqiangBUG/hello-ruoyi-saas
https://gitee.com/Spring-Pig/RY-SAAS
https://baomidou.com/guide/interceptor-tenant-line.html#tenantlineinnerinterceptor
https://www.jianshu.com/p/1e2cef81bce8
https://docs.microsoft.com/zh-cn/azure/azure-sql/database/saas-tenancy-app-design-patterns
https://baomidou.com/guide/interceptor-tenant-line.html#tenantlineinnerinterceptor
https://gitee.com/baomidou/mybatis-plus-samples/tree/master/mybatis-plus-sample-tenant