前言

橙单目前已支持基于「混合隔离模式」的多租户业务数据库架构。

  • 逻辑隔离。每个租户业务表可以指定 tenant_id 字段,用以区分不同租户的数据。对于所有租户业务表的 CURD 操作,Mybatis 拦截器 MybatisDataFilterInterceptor 会基于 JSqlParser 解析当前的 SQL 语句,并根据实体类字段的注解 TenantFilterColumn,为该 SQL 动态添加 WHERE 过滤条件「AND tenant_id = xxxx」。
  • 物理隔离。同一个租户的所有业务数据必须位于同一个租户业务数据库中。同一个租户业务数据库可以包含多个租户的业务数据,并用 tenant_id 字段值进行逻辑隔离。橙单目前已支持多个租户业务数据库,每个租户业务数据库对应一套完整的租户运营后台服务,包括网关服务,并基于 Nginx 进行不同租户业务服务集群的前置路由转发。
  • 混合隔离。等同于物理隔离和逻辑隔离同时存在。

多租户架构

多租户属于平台化架构,在深入细节之前,我们先为大家介绍一下系统全貌。

数据库混合隔离优点

  • 弹性扩充能力。租户业务数据的分布式存储,可以极大的提升 SaaS 平台整体架构的弹性扩充能力,而数据库的横向扩展能力往往是最弱的一环。
  • 资源利用率。不同体量的租户,可以享有不同的资源配置。用户数多、访问量高、业务活跃的租户,在支付更多费用的情况下,可以得到更为优质可靠的资源配置,这显然也是情理之中。
  • 数据隔离的高可用性。当某一租户业务数据库出现故障时,只会影响到该数据库内的租户,其他数据库的租户不受任何影响。
  • 数据库维护。每个租户业务表都有 tenant_id 字段加以区分,而租户的公共数据通常会存储于独立的数据库中,这样可使租户数据的迁移风险和工作量都会将至最低,由此可有效的规避超巨库和超巨表的存在。

应用服务架构

为了突出重点,下面的架构图中仅包含了必要的业务服务组件和中间件,并没有给出监控和 ELK 日志等服务组件。

  • 租户业务数据库。专门存储租户的业务数据。橙单目前已经支持了租户数据混合隔离的模式,比如一些小的租户数据表,可通过 tenant_id 字段加以区分。而一些大的租户,既可以与其他几个大租户共享一个高配置的独立数据库,也可以拥有自己的独立业务数据库。
  • 租户通用业务数据库。主要用于存储租户通用业务数据,如租户全局编码字典、在线表单、工作流和报表打印等通用业务数据。
  • 租户后台管理数据库。存储 SaaS 平台自身的租户管理数据,租户的通用数据,以及所有租户运营的统计数据。
  • 租户业务微服务实例。基于微服务架构,主要运行租户自身运营所需的业务服务。换个视角来说,所有租户的用户业务操作,都由租户业务服务实例提供后台支持。
  • 租户后台管理服务。基于微服务架构,与租户业务微服务实例共享 Nacos 注册配置中心。然而出于网络部署架构、运行时性能和业务隔离性等方面的考虑,租户后台管理服务和租户业务服务并不共享相同的网关集群。直白的讲,他们的入口域名可以是完全独立的,一个是对外的面向租户业务的服务实例进群,另一个是对内的平台管理服务实例集群。
  • Nacos 和 Redis。在橙单缺省生成的工程架构中,上述两组服务实例使用了同一组 Nacos 和 Redis 集群。这里主要是为了方便开发者可以快速搭建,多租户工程开发所需的运行时环境。后期可以根据实际生产环境的部署架构,进行拆分。
  • RocketMQ 消息队列。用于将租户、租户权限、租户移动端入口、租户通用字典、租户在线表单和租户统计表单数据集等数据,从租户管理后台实时同步到租户业务库中。

数据库架构

我们将其划分为两类独立的数据库群组,分别是对内的租户后台管理数据库和对外的租户业务数据库。

租户后台管理数据库主要存储以下数据。

  • 所有租户的管理数据和权限数据。
  • 所有租户的运营统计分析数据。
  • 租户后台管理自身的业务数据。

租户业务通用数据库主要存储以下数据。

  • 租户全局编码字典数据。
  • 租户在线表单和流程的配置数据。
  • 租户报表打印的配置数据。

租户运营业务数据库主要存储以下数据。

  • 租户用户数据、租户权限数据和租户用户的权限数据。
  • 租户运营的业务数据。

数据隔离实现

由橙单低代码工具生成的多租户工程,已经支持了租户数据混合隔离的存储机制。通俗的讲,就是可以让一部分数据量较大的租户数据存放于独立的租户数据库中,既物理隔离。而其他小的租户数据可以存储于共享的租户数据库中,租户业务表数据通过 tenant_id 字段加以区分,既逻辑隔离。我们同时支持了这两种租户数据的隔离方式,既混合隔离。

  • 逻辑隔离。在创建租户时,需要为租户指定唯一的租户编码 tenantCode,租户用户登录时输入该编码值,成功登录后,即可在用户会话的 TokenData 中存储该用户的租户Id (tenantId) 字段数据。在随后的请求中,所有租户业务数据的「增删改查」操作,均会被橙单内置的 Mybatis 拦截器 MybatisDataFilterInterceptor 进行统一的处理,即在已有的过滤从句中,自动补偿添加「AND tenant_id = xxxx」的过滤条件,以此可以减轻程序员手动添加的开发工作量,同时也能有效的避免遗漏。
  • 物理隔离。租户管理后台,可以创建多个租户业务数据库,在创建租户时,为其指定租户业务数据库。这里需要重点说明的是,如本章前面的架构图所示,每增加一台租户业务数据库,就需要相应增加一套租户运营业务服务,从而保证租户业务数据和服务的充分隔离,以及并发分载。

租户运营默认数据库架构

  • 在橙单生成器中,多租户工程目前仅支持创建两个数据库链接,分别是「租户管理」和「租户运营」,该结构为橙单多租户工程的最简化数据库架构。下图为橙单生成器中多租户工程的配置示例。
  • 上图中只有一个租户运营业务数据库,因此在默认生成的多租户工程中,我们将租户公用业务数据库「tenant-common」和租户业务数据库「tenant-business」合二为一了。下面的截图分别介绍「租户管理 tenant-admin」和「租户业务服务 upms」的多数据源配置,更多信息可详见截图中的注释说明。
  • 租户数据同步服务「tenant-sync」,主要用于将租户管理中操作的租户配置数据同步到租户业务数据库,如租户菜单、权限、移动端九宫格、在线表单页面、统计页面数据集和数据字典等。在该服务的配置文件中,仅包含租户管理数据库「tenant-admin」和租户业务公用数据库「tenant-common」的配置,至于每个独立的租户业务数据库链接配置,均为实时动态更新机制,因此无需写入到服务的配置文件中,具体实现可见本章后面的 租户同步服务的动态数据源小节

租户运营公用数据库拆分

在本小节主要介绍如何将上一小节中的租户业务公用数据库「tenant-common」与租户业务数据库「tenant-business」进行拆分。创建独立的租户业务公用数据库「tenant-common」,同时将租户业务基础配置表及其相关数据 copy 到该数据库。这里需要重点说明的是,不同的配置表需要迁移的公用数据也不同,因此下面我们将拆分为多步分别进行介绍。

创建租户业务公用数据库

  • 创建独立的租户业务公用数据库「tenant-common」。
  • 将上一小节中介绍的所有服务的「tenant-common」配置,改为指向新建独立的租户业务公用数据库。

创建数据同步基础表

  • 下图所示的三张表均为数据同步服务「tenant-sync」所需的基础表,可从当前工程的数据库脚本 upms-script.sql 文件中获取。
  • 在新建的租户业务公用数据库中执行上图所示的脚本,其中 zz_data_sync_producer_mark 的 INSERT 语句必须要执行。

租户全局编码字典表迁移

将 zz_tenant_global_dict 和 zz_tenant_global_dict_item 两张全局编码字典配置表及其数据,全部迁移到新建的租户业务公用数据库「zzdemo-multi-tenant-common」中,同时可以将这两张配置表从原有的租户业务数据库「zzdemo-multi-tenant」中删除,因为所有租户的全局编码字典数据均存储于租户业务公用数据库中。

租户运营在线表单表迁移

  • 如果该多租户工程的「租户运营应用」配置了支持在线表单的功能,这里就需要迁移所有以 zz_online 开头的在线表单内置数据表到新建的租户业务公用数据库中。
  • 这里需要重点说明的是,表结构及其数据全部 copy 到新库,同时在原库中仍然保留这些表及其原有数据。
  • 在新建的「租户业务公用数据库」中,执行以下 SQL 语句,删除 zz_online_page、zz_online_page_datasource、zz_online_form 和 zz_online_form_datasource 内置表中与具体租户相关的配置数据。
-- 删除 OnlinePage 和数据源关联的数据
DELETE FROM zz_online_page_datasource WHERE page_id IN (SELECT page_id FROM zz_online_page WHERE tenant_id IS NOT NULL)

-- 删除租户的 OnlinePage 配置数据
DELETE FROM zz_online_page WHERE tenant_id IS NOT NULL

-- 删除 OnlineForm 和数据源关联的数据
SELECT * FROM zz_online_form_datasource WHERE form_id IN (SELECT form_id FROM zz_online_form WHERE tenant_id IS NOT NULL)

-- 删除租户的 OnlineForm 配置数据
DELETE FROM zz_online_form WHERE tenant_id IS NOT NULL

租户运营统计表单表迁移

  • 如果该多租户工程的「租户运营应用」配置了支持报表打印的功能,这里就需要迁移所有以 zz_report 开头的报表打印内置数据表到新建的租户业务公用数据库中。
  • 这里需要重点说明的是,表结构及其数据全部 copy 到新库,同时在原库中仍然保留这些表及其原有数据。

在新建的「租户业务公用数据库」中,执行以下 SQL 语句,删除 zz_report_tenant_dataset 内置表中与具体租户相关的配置数据。

-- 直接清空该表数据即可,在租户业务公用数据库中,不会包含该表的数据。
TRUNCATE TABLE zz_report_tenant_dataset

租户运营业务数据库拆分

将一个租户业务数据库拆分为多个独立的租户业务数据库,每个数据库存储不同租户的业务数据。这里需要重点说明的是,在进行租户业务数据库拆分之前,应确保「租户业务公用数据库」为独立的数据库,具体操作方式可参考本章的上一小节 租户公用数据库拆分

公用组件表迁移

将下图红框圈住的数据表,连同数据一起 copy 到新建的租户业务数据库。这里需要注意的是两个全局编码字典表 zz_tenant_global_dict 和 zz_tenant_global_dict_item 不用迁移到新增的租户业务数据库。

权限表迁移

将原有租户业务数据库中权限表 copy 到新增的租户业务数据库,仅迁移表结构即可,无需迁移数据。

移动端表迁移

将原有租户业务数据库中移动端入口表 copy 到新增的租户业务数据库,仅迁移表结构即可,无需迁移数据。

流程表初始化

在新增的租户业务数据库中,执行 tenant-flow-script.sql 脚本,将与工作流相关的内置表和数据在新库中创建。如默认生成的工程中并不包含该数据库脚本,可忽略该操作。

租户业务表迁移

这里是指租户本身的业务数据表,需要从原有的租户业务数据库 copy 到新建的租户业务数据库中。通常而言,仅做表结构迁移即可,至于是否迁移数据,需要视具体的业务需求而定。

应用服务搭建

每新增一台租户业务数据库,就需要同时搭建一套租户业务的微服务环境,然后再在 Nginx 中配置前置转发路由。后面我们会考虑支持多租户路由的网关插件。目前阶段推荐如下部署方式。

  • 为不同租户数据库内的租户分配不同的二级域名。
  • 在 Nginx 中,可以根据域名将请求转发给不同的 Spring Cloud Gateway。
  • 后续操作与单一租户业务数据库处理无异。

租户同步动态数据源

租户数据同步服务「tenant-sync」主要用于将租户管理中配置的租户业务数据,如租户菜单权限、移动端入口、数据字典和在线表单页面等基础配置数据,同步到所有的租户运营业务数据库中。如果同时存在多个租户运营业务数据库,该服务会将配置数据全部同步到所有租户业务数据库。本小节的重点是介绍,在租户管理后台「增删改」租户业务数据库时,租户数据同步服务「tenant-sync」无需重启,即可实时感知租户业务数据库的变化并动态刷新。

后台操作

下面我们先介绍一下如何动态「增删改」租户业务数据库的后台操作,以下操作均在租户管理后台服务「tenant-admin」中完成。

  • 为新增的租户业务数据库添加租户数据源信息。
  • 添加新租户,并将其指定到本示例中新增的租户业务数据源。

技术实现

本小节仅介绍多租户动态多数据源的处理流程和实现机制,具体代码实现相对简单,可根据以下流程图并结合相关代码自行研究即可。

  • 以下流程目前仅适用于租户数据同步服务「tenant-sync」。
  • 在服务启动时,利用 Spring Boot 的服务监听器机制,从租户管理数据库中读取所有的租户业务数据源信息,并填充到 Spring Boot 动态多数据源的 Map 对象中,以供运行时的动态切换。

逻辑隔离代码详解

在本小节,我们将以代码的方式,介绍租户数据逻辑隔离的实现细节。示例代码中,我们也会给出较为详细的代码注释,以方便大家理解。

用户登录

租户用户登录,首先需要根据登录请求中的 tenantCode 参数,查询该租户的完整对象信息。在橙单中,我们也推荐将租户表的数据,全部加载到 Redis 缓存中,毕竟租户原始数据的变化频度极低,然而该查询动作,会在每个租户用户登录时调用一次,因此该优化非常值得。

@OperationLog(type = SysOperationLogType.LOGIN, saveResponse = false)
@PostMapping("/doLogin")
public ResponseResult<JSONObject> doLogin(
       @MyRequestBody String loginName,
       @MyRequestBody String password,
       @MyRequestBody String tenantCode,
       @MyRequestBody String captchaVerification) throws Exception {
 
   // ... ... 这里省略部分参数验证和验证码验证的逻辑。
 
   // 在下面的方法中,会根据tenantCode先从Redis缓存中获取租户对象数据,
   // 如果不存在再去租户后台管理数据库的sys_tenant表中查询,并同步到Redis缓存中,以备后续查询时使用。
   SysTenantVo tenant = sysTenantAdminService.getSysTenantByTenantCode(tenantCode);
   if (tenant == null) {
       return ResponseResult.error(ErrorCodeEnum.INVALID_TENANT_CODE);
   }
   // 判断当前租户是否处于可用状态。如果当前租户被锁定,该租户内的所有用户就都不能正常登录了。
   if (!tenant.getAvailable()) {
       return ResponseResult.error(ErrorCodeEnum.INVALID_TENANT_STATUS);
   }
   // 这里一定要指定租户Id去查询当前的登录名,因为相同登录名可能出现在多个租户中。
   SysUser user = sysUserService.getSysUserByLoginName(loginName, tenant.getTenantId());
   if (user == null) {
       return ResponseResult.error(ErrorCodeEnum.INVALID_USERNAME_PASSWORD);
   }
 
   // ... ... 此处省略用户身份验证的逻辑。
 
   TokenData tokenData = new TokenData();
   // 构建Session会话Id的时候,需要将租户Id也一起拼接进来,避免loginName的重复问题。
   String sessionId = user.getTenantId() + "_" + user.getLoginName() + "_" + deviceType + "_" + MyCommonUtil.generateUuid();
   tokenData.setSessionId(sessionId);
   tokenData.setUserId(user.getUserId());
   tokenData.setDeptId(user.getDeptId());
   tokenData.setTenantId(user.getTenantId());
   tokenData.setLoginName(user.getLoginName());
   tokenData.setShowName(user.getShowName());
 
   // ... ... 省略其余登录处理代码。
 
   return ResponseResult.success(jsonData);
}

逻辑隔离实现

本小节将以橙单内部测试工程中的代码为例。

@Override
public List<Teacher> getTeacherListWithRelation(Teacher filter, String orderBy) {
   // 该Mapper方法(getTeacherList)所执行的SQL语句,会在当前租户数据所在的数据库中执行。
   List<Teacher> resultList = teacherMapper.getTeacherList(null, null, filter, orderBy);
   int batchSize = resultList instanceof Page ? 0 : 1000;
   this.buildRelationForDataList(resultList, MyRelationParam.normal(), batchSize);
   return resultList;
}

在上述 teacherMapper.getTeacherList 方法所关联的 SQL 语句被执行前,会先行调用橙单自定义的 Mybatis 拦截器 MybatisDataFilterInterceptor 对象中的拦截方法。拦截器方法中,需要判断实体对象中是否存在被 TenantFilterColumn 注解标记的字段,因此我们这里先给出 Teacher 实体对象中的代码片段。从下面的代码中,可以看到 tenant_id 字段就是 zz_teacher 表租户逻辑隔离字段。

@Data
@TableName(value = "zz_teacher")
public class Teacher {

   // 主键Id。
   @TableId(value = "teacher_id")
   private Long teacherId;

   // 租户Id。
   @TenantFilterColumn
   @TableField(value = "tenant_id")
   private Long tenantId;

   // 教师名称。
   @TableField(value = "teacher_name")
   private String teacherName;
   
   // ... ... 这里省略若干其他字段的定义。
}

最后介绍一下多租户逻辑隔离中最为关键的一环,Mybatis 拦截器 MybatisDataFilterInterceptor 对象中拦截方法的代码实现。该方法会拦截 Mybatis Mapper 中待执行的 SQL 语句,并通过 JSqlParser 组件解析该 SQL,然后判断当前 Mapper 对应的实体类中是否存在被 TenantFilterColumn 注解标记的字段,如以上代码中的 tenantId 字段。如存在,就会在该 SQL 的 WHERE 从句中,自动添加过滤条件「AND tenant_id = xxxx」。

// 该私有方法是多租户逻辑隔离中最为核心的实现,会被Mybatis拦截器的重载方法intercept调用。
private Statement processTenantFilter(
       String className, String methodName, BoundSql boundSql, SqlCommandType commandType) {
   ModelTenantInfo info = cachedTenantMap.get(className);
   if (info == null || CollUtil.contains(info.getExcludeMethodNameSet(), methodName)) {
       return null;
   }
   // sql变量,是即将执行的 SQL 语句。
   String sql = boundSql.getSql();
   // 通过JSqlParser组件,解析该sql为Statement对象。
   Statement statement = CCJSqlParserUtil.parse(sql);
   StringBuilder filterBuilder = new StringBuilder(64);
   // 从下面的代码中可以看到,我们是通过TokenData.tenantId字段,获取当前会话的租户Id值的。
   // 下面变量拼接后的结果就是 zz_teacher.tenant_id = xxxxxx。
   filterBuilder.append(info.tableName).append(".")
           .append(info.columnName)
           .append("=")
           .append(TokenData.takeFromRequest().getTenantId());
   String dataFilter = filterBuilder.toString();
   // 这里需要先判断SQL语句的类型,update/delete会有各自不同的添加方式。
   // 在下面的多个buildWhereClause方法中,会将自动补偿的dataFilter条件字符串,
   // 添加到原有where过滤从句的末尾。具体可参考橙单开源代码中的相关实现。
   if (commandType == SqlCommandType.UPDATE) {
       Update update = (Update) statement;
       this.buildWhereClause(update, dataFilter);
   } else if (commandType == SqlCommandType.DELETE) {
       Delete delete = (Delete) statement;
       this.buildWhereClause(delete, dataFilter);
   } else {
       // 对于select语句,这里需要考虑子查询的场景。
       Select select = (Select) statement;
       PlainSelect selectBody = (PlainSelect) select.getSelectBody();
       FromItem fromItem = selectBody.getFromItem();
       if (fromItem != null) {
           PlainSelect subSelect = null;
           if (fromItem instanceof SubSelect) {
               subSelect = (PlainSelect) ((SubSelect) fromItem).getSelectBody();
           }
           if (subSelect != null) {
               dataFilter = replaceTableAlias(info.getTableName(), subSelect, dataFilter);
               buildWhereClause(subSelect, dataFilter);
           } else {
               dataFilter = replaceTableAlias(info.getTableName(), selectBody, dataFilter);
               buildWhereClause(selectBody, dataFilter);
           }
       }
   }
   // 最后,将自动拼接好的sql,通过反射的方式,重新赋值给mybatis的sql执行对象。
   ReflectUtil.setFieldValue(boundSql, "sql", statement.toString());
   return statement;
}

结语

赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。