SaaS多租户,如何设计?

一、SaaS多租户简介

多租户技术是一种软件架构技术,它是在探讨与实现如何于多用户的环境下共用相同的系统或程序组件,并且仍可确保各用户间数据的隔离性。它是为共用的数据中心内如何以单一系统架构与服务提供多数客户端相同甚至可定制化的服务,并且仍可保障客户的数据隔离。简单来说是一个单独的实例可以为多个组织服务。

多租户是SaaS(Software-as-a-Service)下的一个概念,意思为软件及服务,即通过网络提供软件服务。SaaS平台供应商将应用软件统一部署在自己的服务器上,客户端可以根据工作的实际需求,通过互联网向厂商租用所需的应用软件服务,按定购的服务多少和时间长短向厂商支付费用,并通过互联网获得SaaS平台供应商提供的服务。

SaaS服务尤其利于一些中小企业,以低成本实现自己的软件需求。


SaaS模式

什么是多租户技术

多租户技术或称多重租赁技术,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业)共用相同的系统或程序组件,并且确保各用户间数据隔离性。

在一台服务器上运行单个应用实例,它为多个租户(客户)提供服务。从定义中我们可以理解:多租户是一种架构,目的是为了让多用户环境下使用同一套程序,且保证用户间数据隔离。多租户的重点就是同程序下实现多用户数据的隔离。

1.1什么是SaaS多租户

SaaS,是Software-as-a-Service的缩写名称,意思为软件即服务,即通过网络提供软件服务。

SaaS平台供应商将应用软件统一部署在自己的服务器上,客户可以根据工作实际需求,通过互联网向厂商订购所需的应用软件服务,按定购的服务多少和时间长短向厂商支付费用,并通过互联网获得SaaS平台供应商提供的服务。

SaaS服务通常基于一套标准软件系统为成百上千的不同客户(又称为租户)提供服务。这要求SaaS服务能够支持不同租户之间数据和配置的隔离,从而保证每个租户数据的安全与隐私,以及用户对诸如界面、业务逻辑、数据结构等的个性化需求。由于SaaS同时支持多个租户,每个租户又有很多用户,这对支撑软件的基础设施平台的性能、稳定性和扩展性提出很大挑战。

多租户是SaaS领域的特有产物,探究何为多租户需回归到对SaaS的理解上。

SaaS服务是指部署在云上的,客户可以按需购买,并通过网络请求就能获取到的服务;也就是说,在这样的场景下,会有N个客户同时使用同一套SaaS服务。

那么对SaaS服务供应商来说,构建SaaS体系需要完成两部分工作:上层服务+底层多租户系统。

上层服务是供应商对外售卖的软件服务,其可以为客户创造价值、为公司带来营收;而底层多租户系统则是SaaS模式实现的具体方式,公司在对外售卖SaaS服务时,需要考虑如何实现客户之间的数据隔离、服务的权限控制、计费管理等;因此需要引入多租户概念来解决上述问题。

通过多租户系统,公司可以更好的管理客户和上层服务,客户也可以更好的使用软件服务。这也就是多租户系统存在的意义了。

1.2 SaaS多租户的优势

开发和运维成本低

按需付费,节约成本

即租即用,软件版本更新快

故障排查更及时

大数据和AI的能力支持更强大

1.3 多租户模型


多租户模型

如图所示,涉及主要模型有以下几类:

(1)租户:指一个企业客户或是个人客户,租户之间数据与行为隔离,上下级租户间通过授权实现数据共享。每个租户只能操作归属或授权给该租户的数据;

(2)组织:如果租户是一个企业客户,通常就会拥有自己的组织架构;

(3)用户:租户下的具体使用者,拥有用户名、密码、邮箱等账号信息的自然人;

(4)角色:用户操作权限的集合;

(5)员工:组织内的某位员工;

(6)解决方案:为了解决客户的某类型业务问题,SaaS供应商一般都将产品和服务组合在一起,为客户提供整体的打包方案;

(7)产品能力:能够帮助客户实现场景解决方案闭环的能力;

(8)资源域:用来运行1个或多个产品应用的一套云资源环境;

(9)云资源:SaaS产品一般都部署在各种云平台上,例如阿里云、腾讯云、华为云等。对这些云平台提供的计算、存储、网络、容器等资源,抽象为云资源。

二、SaaS多租户的数据隔离设计方案

多租户对于用户来说,最主要的一点就在于数据隔离。

绝对不能出现:一个用户登了A用户单位的号,但是看到了B用户单位的数据。因此,多租户的数据库设计方案和代码实现就相当有必要考虑了。

目前开发者们普遍接受的SaaS多租户设计方案,常见的大概就3种:即为每个租户提供独立的数据库、独立的表空间、按字段区分租户,每种方案都有其各自的适用情况。

一个租户独立一个数据库

一个租户独立使用一个数据库,那就意味着我们的SaaS系统需要连接多个数据库,这种实现方案其实就和分库分表架构设计是一样的,好处就是数据隔离级别特别高、安全性好,毕竟一个租户单用一个数据库,但是物理硬件成本,维护成本也变高了。

独立的表空间

这种方案的实现方式,就是所有租户共用一个数据库系统,但是每个租户在数据库系统中拥有一个独立的表空间。

按租户id字段隔离租户

这种方案是多租户方案中最简单的数据隔离方案,即在每张表中都添加一个用于区分租户的字段(如tenant_id或org_id啥的)来标识每条数据属于哪个租户,当进行查询的时候每条语句都要添加该字段作为过滤条件,其特点是所有租户的数据全都存放在同一个表中,数据的隔离性是最低的,完全是通过字段来区分的,很容易把数据搞串或者误操作。

2.1三种数据隔离架构设计的对比


隔离架构设计对比

大部分公司都是采用第三种多租户设计方案:按租户id字段隔离租户架构设计实现多租户数据隔离的。

因为这种方案服务器成本最低,但是提高了开发成本。

2.2MyBatis-Plus多租户插件优雅实现数据隔离

该系统只有一个数据库,所有租户共用数据表。

在每一个数据表中增加一列租户ID,用以区分租户的数据。

增删改查时,一定要带上租户ID,否则就会操作到其他租户的数据。因此,这里的设计一定要重点考虑。

我们要保证的就是一定不要忘记带上租户ID。一个很好的方案就是通过AOP的方案,隐式的为我们的每一个SQL带上这个租户ID。

推荐使用MyBatis-Plus来操作数据库的。它提供了插件的机制,我们可以通过拦截它提供的四大组件的某些对象,某些方法,来操作SQL,动态的为我们的SQL拼接上租户ID字段。

当然,MyBatis-Plus高版本提供了更加方便的拦截器,并且已经将多租户插件放入JAR包,我们只需稍加实现,并将该插件加入到MyBatis的拦截器链中,就可以不用再显示的拼接租户ID字段了,降低了出错的概率。


三、MyBatisPlus实现多租户功能

如果希望以最少的服务器为最多的租户提供服务,并且租户接受以牺牲隔离级别换取降低成本。可以采用方案三,即共享数据库,共享数据架构,因为这种方案服务器成本最低,但是提高了开发成本。

所以MyBatisPlus就提供了一种多租户的解决方案,实现方式是基于多租户插件TenantLinelnnerlnterceptor进行实现的。

在MyBatis Plus中,采用“共享数据库,共享数据架构”方式实现多租户。

mybatisPlus提供了租户处理器(Tenantld行级),租户之间共享数据库,共享数据架构,通过表字段(租户ID)进行数据逻辑隔离。

该种实现方式,需要我们在要实现多租户的表中添加tenant_id(租户ID)字段,每次在对数据库操作时都需要在where后面添加租户判断条件“tenant_id=用户的租户ID”。

然而,使用了MyBatis Plus后,我们就不需要每次都手动在where后面添加tenant_id条件。

注意事项:

多租户!=权限过滤,不要乱用,租户之间时完全隔离的!!!

启用多租户后所有执行的method的sql都会进行处理。

自写的sql请按规范书写(sql涉及到多个表的每个表都要给别名,特别是inner join的要写标准的inner join)

<!-- Mybatis-Plus 增强CRUD -->

<dependency>

    <groupId>com.baomidou</groupId>

    <artifactId>mybatis-plus-boot-starter</artifactId>

    <version>3.5.1</version>

</dependency>

<!-- Mybatis-Plus 扩展插件 -->

<dependency>

    <groupId>com.baomidou</groupId>

    <artifactId>mybatis-plus-extension</artifactId>

    <version>3.5.1</version>

</dependency>

TenantLineInnerInterceptor是MybatisPlus中提供的多租户插件,其使用方法大致分为下面4步:

3.1表及实体类添加租户ID

应用添加维护一张tenant(租户表),记录租户的信息,每一个租户,有一个租户ID。

然后,在需要进行隔离的数据表上新增租户id,例如,现在有数据库表(user)如下:

租户ID一般用tenant_id


将tenantId用来隔离租户与租户之间的数据,如果要查询当前服务商的用户,SQL大致如下:

SELECT * FROM table t WHERE t.tenantId = 1;

3.2application文件中添加多租户配置和新增配置属性类

(1)设置环境变量,配置拦截规则:

tenant.enable: 可以设置是否开启多租户,

tenant.ignoreTables:需要进行租户id过滤的表名集合。

tenant.filterTables:对多租户的表设置白名单忽略多租户拦截等。例如sys_user表结构中,没有tenant_id多租户字段,那么多租户拦截器不拦截该表。

#多租户配置

tenant:

  enable: true

  column: tenant_id

  filterTables:

  ignoreTables:

    - sys_app

    - sys_config

    - sys_dict_data

    - sys_dict_type

    - sys_logininfor

    - sys_menu

    - sys_notice

    - sys_oper_log

    - sys_role

    - sys_role_menu

    - sys_user

    - sys_user_role

  ignoreLoginNames:

(2)多租户配置属性类

import lombok.Data;

import org.springframework.boot.context.properties.ConfigurationProperties;

import org.springframework.stereotype.Component;

import java.util.List;

/**

* 多租户配置属性类

*

* @author hege

* @Date 2023-08-25

*

*/

@Data

@ConfigurationProperties(prefix = "tenant")

public class TenantProperties {

    /**

    * 是否开启多租户

    */

    private Boolean enable = true;

    /**

    * 租户id字段名

    */

    private String column = "tenant_id";

    /**

    * 需要进行租户id过滤的表名集合

    */

    private List<String> filterTables;

    /**

    * 需要忽略的多租户的表,此配置优先filterTables,若此配置为空,则启用filterTables

    */

    private List<String> ignoreTables;

    /**

    * 需要排除租户过滤的登录用户名

    */

    private List<String> ignoreLoginNames;

}

3.3编写多租户处理器实现TenantLineHandler接口

在 MyBatis Plus 中,提供了 TenantLineInnerInterceptor 插件和 TenantLineHandler 接口。

其中:

TenantLineInnerInterceptor 插件用来自动向每个 SQL 的 where 后面添加判断条件“tenant_id=用户的租户ID”。

而 TenantLineHandler 接口用来给 TenantLineInnerInterceptor 插件提供租户ID、租户字段名。

TenantLineHandler 接口定义如下:

public interface TenantHandler {

  /**

  * 获取租户 ID 值表达式,支持多个 ID 条件查询

  * 支持自定义表达式,比如: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);

}

实现TenantHandler接口并实现它的方法,下面是一个例子:

import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;

import net.sf.jsqlparser.expression.Expression;

import net.sf.jsqlparser.expression.LongValue;

import net.sf.jsqlparser.expression.NullValue;

import org.springframework.security.core.Authentication;

import org.springframework.security.core.context.SecurityContext;

import org.springframework.security.core.context.SecurityContextHolder;

import java.util.List;

/**

* 多租户处理器实现TenantLineHandler接口

*

* @author hege

* @Date 2023-08-25

*/

public class MultiTenantHandler implements TenantLineHandler {

    private final TenantProperties properties;

    public MultiTenantHandler(TenantProperties properties) {

        this.properties = properties;

    }

    /**

    * 获取租户ID值表达式,只支持单个ID值 (实际应该从用户信息中获取)

    *

    * @return 租户ID值表达式

    */

    @Override

    public Expression getTenantId() {

        //实际应该从用户信息中获取

        if(SecurityUtils.getTenantLoginUser()!=null)

        {

            //SecurityUtils 从ThreadLocal里面的安全上下文 中获取 用户所归属的单位id(租户id)

            Long tenantId = SecurityUtils.getLoginUser().getUser().getRootPartyId();

            if(tenantId!=null)

            {

                return new LongValue(tenantId);

            }

        }

        return new LongValue(0);

    }

    /**

    * 获取租户字段名,默认字段名叫: tenant_id

    *

    * @return 租户字段名

    */

    @Override

    public String getTenantIdColumn() {


        //通过配置获取

        return properties.getColumn();

    }

    /**

    * 根据表名判断是否忽略拼接多租户条件

    *

    * 默认都要进行解析并拼接多租户条件

    *

    * @param tableName 表名

    * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件

    */

    @Override

    public boolean ignoreTable(String tableName) {

        //忽略指定用户对租户的数据过滤

        List<String> ignoreLoginNames=properties.getIgnoreLoginNames();


        //SecurityUtils 从ThreadLocal里面的安全上下文 中获取 用户名称

        String loginName=SecurityUtils.getTenantUsername();

        if(null!=ignoreLoginNames && ignoreLoginNames.contains(loginName)){

            return true;

        }

        //忽略指定表对租户数据的过滤

        List<String> ignoreTables = properties.getIgnoreTables();

        if (null != ignoreTables && ignoreTables.contains(tableName)) {

            return true;

        }

        return false;

    }

}

SecurityUtils 从ThreadLocal里面的安全上下文 中获取 用户名称, 用户所归属的单位id(租户id)

3.4MybatisPlus配置类启用多租户拦截插件运行sql实例:

前面讲到,在 MyBatis Plus 中,提供了 TenantLineInnerInterceptor 插件和 TenantLineHandler 接口。

其中,TenantLineInnerInterceptor 插件用来自动向每个 SQL 的 where 后面添加判断条件“tenant_id=用户的租户ID”。

TenantLineInnerInterceptor 插件 调用 TenantLineHandler 接口用来给 提供租户ID、租户字段名。

使用 @Configuration 和 @Bean 注解配置 MyBatis Plus 的多租户插件,

iimport com.baomidou.mybatisplus.annotation.DbType;

import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;

import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor;

import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;

import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;

import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;

import org.springframework.boot.context.properties.EnableConfigurationProperties;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.transaction.annotation.EnableTransactionManagement;

/**

* Mybatis Plus 配置

*

* @author hege

*/

@EnableTransactionManagement(proxyTargetClass = true)

@Configuration

@EnableConfigurationProperties(TenantProperties.class)

public class MybatisPlusConfig {

    /**

    * 如果用了分页插件注意先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor

    *

    * @param tenantProperties

    * @return

    */

    @Bean

    public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties tenantProperties) {

        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        if (Boolean.TRUE.equals(tenantProperties.getEnable())) {

            // 启用多租户插件拦截

            interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MultiTenantHandler(tenantProperties)));

        }

        // 分页插件

        interceptor.addInnerInterceptor(paginationInnerInterceptor());

        // 乐观锁插件

        interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());

        // 阻断插件

        interceptor.addInnerInterceptor(blockAttackInnerInterceptor());

        return interceptor;

    }

}

配置好之后,不管是查询、新增、修改删除方法,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 = ?

验证结果:

针对MybatisPlus提供的API、自定义Mapper中的statement均可正常拦截,会在SQL执行增删改查的时候自动加上tenant_id。

3.5特定SQL语句忽略拦截

如果在程序中,有部分SQL不需要加上租户ID的表示,需要过滤特定的sql,或者对于一些超级管理员使用的接口,希望跨租户查询、免数据鉴权时,无需多租户拦截。

怎么办?

可以通过下面几种方式实现忽略拦截:

方法1:使用MybatisPlus框架自带的@InterceptorIgnore注解,以用在Mapper类上,也可以用在方法上

方法2:添加超级用户账号白名单,在自定义的Handler里进行逻辑判断,跳过拦截

方法3:添加数据表白名单,在自定义的Handler里进行逻辑判断,跳过拦截

使用MybatisPlus框架自带的@InterceptorIgnore注解,以用在Mapper类上,也可以用在方法上, 下面是一个例子:

/**

* 使用@InterceptorIgnore注解,忽略多租户拦截 <br/>

* 注解@InterceptorIgnore可以用在Mapper类上,也可以用在方法上

*

* @param id

* @return

*/

@InterceptorIgnore(tenantLine = "true")

UserOrgVO myFindByIdNoTenant(@Param(value = "id") Long id);

参考文献

https://mp.weixin.qq.com/s/TR75wnxsXgFZ2ot1dOvX2w

https://mp.weixin.qq.com/s/CVTuEINWHCLue1oB7Yr3ng

https://mp.weixin.qq.com/s/Nl5Oll9GcF6JB8JvIb2YqA

https://zhuanlan.zhihu.com/p/420696556

https://blog.csdn.net/CSDN2497242041/article/details/132525117

原文链接:https://blog.csdn.net/qq_45038038/article/details/135575700

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容