前言

在阅读本章之前,我们假定您已经非常熟悉橙单多租户管理后台的基本配置操作,如仍有疑问可参考开发文档 租户管理入门章节。本章将深入介绍多租户权限模型的设计与实现细节,以及租户权限表的分布式存储,对系统性能提升的重要价值。

权限管理

在开始之前,我们再次强调,本章节介绍的是「租户权限」,而非用户权限。租户权限是指,租户企业内的所有用户,在多租户 SaaS 平台中可以使用的权限上限。下图以橙单多租户演示工程为例,这里所有和租户管理相关的操作,都是在「租户管理」菜单栏内完成。

数据初始化

在橙单默认生成的多租户工程中,会包含权限数据的初始化脚本,执行后即可批量插入到租户管理数据库和租户业务数据库的租户菜单表中,具体文件可参考以下截图。

租户权限表数据会存在于「所有的」租户业务数据库中,并以 zz_sys_ 开头。同时还会存在于租户后台管理数据库中,然而为了和租户后台管理系统的用户权限表加以区分,租户权限表在该库中则以 zz_sys_tenant_ 开头。尽管表名前缀不同,但是他们与所有租户运营数据库中的数据则完全相同 (zz_sys_tenant_menu 除外,这个会在后面介绍)。在下面的表格中,我们给出了这四个 SQL 脚本文件的差异性比对。

脚本文件名 功能 操作数据库 权限表
init-tenant-admin-data.sql 批量插入租户后台管理系统的权限数据和租户业务系统的租户权限数据。 租户后台管理数据库。 租户权限表均以 zz_sys_tenant_ 开头。租户后台管理系统的用户权限表均以 zz_sys_ 开头。
init-upms-data-script.sql 批量插入租户业务系统的租户权限数据。 「所有的」租户业务数据库。 全部为以 zz_sys_ 开头的租户用户权限表。
rollback-tenant-admin-data.sql 逐条回滚 init-tenant-admin-data.sql 中插入的数据。 同 init-tenant-admin-data.sql 同 init-tenant-admin-data.sql
rollback-upms-data-script.sql 逐条回滚 init-upms-data-script.sql 中插入的数据。 同 init-upms-data-script.sql 同 init-upms-data-script.sql

橙单每次生成的权限数据主键 ID 都是不变的,因此,init-upms-data-script.sql 和 rollback-upms-data-script.sql、init-tenant-admin-data.sql 和 rollback-tenant-admin-data.sql 两对脚本可以反复交替执行。然而这里需要注意的是,必须保证他们的版本是一致的,既为同一次生成后工程中所含的两个文件。否则一旦不同版本间权限数据出现变化,会导致之前已经插入的初始化权限数据,没有被全部回滚删除,下次再执行 init-upms-data-script.sql 时,可能会出现主键重复的插入错误。

后台服务

下面的所有操作,需要依赖 tenant-admin 和 tenant-sync 两个服务的启动。tenant-admin 服务为租户后台管理系统提供了后台接口,tenant-sync 服务,会将以下操作所产生的租户权限变化数据,从租户后台管理数据库,实时同步到多个租户业务数据库中。见下图。

租户角色

这里的租户角色与权限管理系统中的用户角色,在逻辑上是完全一致的。租户和租户角色是多对多的关联关系,租户角色和租户菜单也是多对多的关联关系,租户在 SaaS 平台中所拥有的权限上限,就是该租户所属的租户角色,所关联的租户菜单权限的合集,这同样也是该租户所有操作用户的权限上限。租户角色通常会按照租户的类型、租户的等级、以及平台业务的通用性等去划分。

  • 按照租户类型划分。比如为 SaaS 平台中的供应商、销售商和物流商等不同业务类型的租户,可以创建不同的租户角色,那么他们最终可以看到的菜单,绝大部分都是不同的。
  • 按照租户等级划分。再比如,为不同付费级别的销售商租户,也可以创建不同的租户角色,付费多的租户所属的租户角色,可以关联更多的租户菜单,因此该类租户可以使用的 SaaS 平台功能也就越多。
  • 按照功能的通用性划分。在多租户 SaaS 平台中,往往都会存在一些非常通用的功能,比如,告警设置,字典数据编辑等,我们可以将这些通用功能的菜单权限分配给某一租户角色,那么所有引用该租户角色的租户,都可以使用这些通用的平台功能了。

租户权限配置

在完成租户权限数据初始化后,租户平台的后台管理人员开始创建租户并分配权限。这里我们只是介绍最基本且常用的操作流程。

  • 为租户角色并绑定租户。
  • 为租户角色绑定菜单。

租户菜单权限配置

上一小节,我们介绍了租户平台管理人员配置租户菜单的基本操作。本小节将介绍租户菜单与租户前后端权限的配置规则,其中多租户工程与单体和微服务完全一致,因此具体配置可参考用户权限管理章节的 菜单前端权限编码菜单后台权限字列表 两个小节中的配置说明,这里的所有配置都会被「tenant-sync」服务同步到每个租户运营数据库中的 zz_sys_menu 表中。

权限设计

本小节主要介绍多租户系统中租户管理权限设计的具体思路,与普通的 RBAC 用户权限设计相比,其原理较为相似,但是细节处理则更为复杂。

权限表结构

在深入了解实现机制和代码逻辑之前,如果没有很好的理解租户权限表模型的设计,那么后续的学习一定会是事倍功半的。在下图中,橙色边框为租户权限数据表,绿色边框的是租户权限数据表之间的多对多关联表,关联表只是包含了两个权限数据表的主键 ID。

权限表存储结构

不同于普通权限表的存储结构,多租户系统中,租户权限表的存储架构一定是基于分布式多库的,是需要一定数量上的冗余。这样可以为整个复杂的多租户平台,带来更好的代码可读性和可维护性、更高的运行时性能,同时也能带来更为出众的租户数据物理隔离能力和横向扩展能力。下面所有的租户权限表的操作入口都在租户后台管理系统中,同时也会直接保存到租户后台管理数据库,然后再根据需要,实时同步到租户业务数据库中。

租户权限表 存储方案 存储份数 是否需要同步
zz_sys_tenant (租户表) 租户管理数据库和租户的主业务数据库 两个库各 1 份 需要
zz_sys_tenant_role (租户角色表) 租户管理数据库 1 份 不需要
zz_sys_tenant_menu (租户菜单表) 租户管理数据库和租户所在的业务数据库 租户管理库 1 份,租户业务库存多份,业务库有多少租户,就存多少份。 需要

代码详解

通过上文,我们已经清楚了该如何进行租户权限管理的操作,同时也了解到了租户权限数据的设计模型,以及他们之间的关联关系。下面开始分享一下橙单中,与租户权限管理相关的代码实现。在开始之前,我们先介绍一下租户权限管理数据的操作流程,见下图。

由上图可知,所有的租户权限管理操作,都是在租户后台管理服务 (tenant-admin) 中完成的,该服务在操作本地数据库的同时,还会将发生变化的数据,实时发送到 RocketMQ。租户权限同步服务 (tenant-sync) 作为 RocketMQ 的消费群组,会根据收到的消息类型执行相应的同步操作,并将变化的租户权限数据,同步写入到租户业务数据库的权限表中。如增加新租户、增加新菜单、为租户角色添加租户、从租户角色中移除菜单等。

租户用户登录

在深入讲解租户权限管理的流程处理细节和代码实现逻辑之前,下面我们先介绍一下租户的管理员用户登录时,获取其用户权限的代码细节。下面的代码片段,会被租户的用户登录接口所调用,请留意代码注释。

@Override
public Collection<SysMenu> getMenuListByTenantId(Long tenantId) {
   SysMenu filter = new SysMenu();
   // 只是返回当前租户Id的菜单。
   filter.setTenantId(tenantId);
   // 由此可见,tenantAvailable是非常重要的过滤条件,如果该值为false,租户的管理员
   // 也是无法看到该菜单的。
   filter.setTenantAvailable(true);
   QueryWrapper<SysMenu> queryWrapper = new QueryWrapper<>(filter);
   queryWrapper.orderByAsc(this.safeMapToColumnName("showOrder"));
   queryWrapper.in(this.safeMapToColumnName("menuType"),
           Arrays.asList(SysMenuType.TYPE_MENU, SysMenuType.TYPE_DIRECTORY));
   return sysMenuMapper.selectList(queryWrapper);
}

下面为「租户普通用户」在登录时,获取该租户用户所有可用菜单数据的 SQL 语句。请留意代码注释。

SELECT
   -- 出于性能考虑,这么没有做 DISTINCT 计算,因为实际区分唯一性的只有menu_id,但是这里必须
   -- 要对menu的所有字段进行唯一性区分,性能太低。特别是对于多租户系统的登录查询,一旦出现峰值
   -- 时刻集中登录的场景,就会直接拉低数据库的计算能力。因为我们将其优化,放到Java代码,仅对menuId
   -- 进行唯一性处理,这样可使系统运行时性能更具弹性。
   m.*
FROM
   zz_sys_user_role ur,
   zz_sys_role_menu rm,
   zz_sys_menu m
WHERE
   ur.user_id = #{userId}
   AND ur.role_id = rm.role_id
   AND rm.menu_id = m.menu_id
   -- 注意下面两个过滤条件,要求必须为该租户的,且tenantAvailable为true的菜单。
   AND m.tenant_id = #{tenantId}
   AND m.tenant_available = 1
ORDER BY m.show_order

租户角色

租户角色数据,只会存储于租户后台管理数据库,并不会同步到租户业务数据库中。其中租户和租户角色、租户角色和租户菜单的关联关系,可以决定租户有哪些租户菜单是可见的,即租户业务数据库中 zz_sys_menu 表的 tenant_available 字段值为 TRUE。下面是添加租户角色的代码。

@Transactional(rollbackFor = Exception.class)
@Override
public SysTenantRole saveNew(SysTenantRole sysTenantRole) {
   // 相比于上面的代码片段,可以看到租户角色数据,并没有被发送到 RocketMQ消息队列。
   // 而仅仅是保存到当前的租户后台管理数据库中了。
   sysTenantRole.setTenantRoleId(idGenerator.nextLongId());
   sysTenantRole.setDeletedFlag(GlobalDeletedFlag.NORMAL);
   MyModelUtil.fillCommonsForInsert(sysTenantRole);
   sysTenantRoleMapper.insert(sysTenantRole);
   return sysTenantRole;
}

租户角色添加菜单

当给租户角色添加菜单的时候,该租户角色下的所有租户,均将有权访问该菜单,见如下场景。

  • 为租户角色「高级供货商」, 添加租户菜单「高级业务分析」。
  • 租户角色「高级供货商」包含三个租户,分别是「供货商-A」、「供货商-B」 和「供货商-C」。
  • 租户「供货商-A」的数据位于独立的「大租户-A」数据库中,而租户「供货商-B」和「供货商-C」的数据位于「共享租户数据库」中。
  • 在租户数据库「大租户-A」和「共享租户数据库」的 zz_sys_menu 表中,都会存在「高级业务分析」的租户菜单数据。
  • 再执行本次操作后,「大租户-A」数据库的 zz_sys_menu 表中,「供货商-A」的 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值将被更新为 TRUE。与此同时,「共享租户数据库」的 zz_sys_menu 表中,「供货商-B」和「供货商-C」的两个 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值均被更新为 TRUE。

实现逻辑见如下数据处理流程图。

租户角色移除菜单

当将租户菜单从租户角色中移除时,该租户角色下的所有租户,均有可能失去访问该菜单的权限。然而当某一租户同时属于多个租户角色时,被移除的菜单,在该租户所属的其他角色中也存在时,那么本次菜单移除操作,对该租户不会产生影响,见如下场景。

  • 租户角色「高级供货商-A」和「高级供货商-B」同时关联租户菜单「高级业务分析」。
  • 将租户菜单「高级业务分析」从租户角色「高级供货商-A」中移除。
  • 租户角色「高级供货商-A」包含三个租户,分别是「供货商-A」、「供货商-B」和「供货商-C」。
  • 租户角色「高级供货商-B」同时也包含了租户「供货商-B」。
  • 租户「供货商-A」的数据位于独立的「大租户-A」数据库中,而租户「供货商-B」和「供货商-C」的数据位于「共享租户数据库」中。
  • 在租户数据库「大租户-A」和「共享租户数据库」的 zz_sys_menu 表中,都会存在「高级业务分析」的租户菜单数据。
  • 再执行本次操作后,「大租户-A」数据库的 zz_sys_menu 表中,「供货商-A」的 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值将被更新为 FALSE。与此同时,「共享租户数据库」的 zz_sys_menu 表中,「供货商-C」的 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值被更新为 FALSE。
  • 由于租户角色「高级供货商-B」同时关联了租户「供货商-B」和租户菜单「高级业务分析」,「共享租户数据库」的 zz_sys_menu 表中,「供货商-B」的 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值没有被更新,仍然保留为 TRUE。

实现逻辑见如下数据处理流程图。

租户角色添加租户

当将租户添加到租户角色之后,该租户角色所关联的租户菜单,对该租户均可见,见如下场景。

  • 为租户角色「高级供货商」, 添加租户「供货商-A」、「供货商-B」和「供货商-C」。
  • 租户角色包含租户菜单「高级业务分析」。
  • 租户「供货商-A」的数据位于独立的「大租户-A」数据库中,而租户「供货商-B」和「供货商-C」的数据位于「共享租户数据库」中。
  • 在租户数据库「大租户-A」和「共享租户数据库」的 zz_sys_menu 表中,都会存在「高级业务分析」的租户菜单数据。
  • 再执行本次操作后,「大租户-A」数据库的 zz_sys_menu 表中,「供货商-A」的 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值将被更新为 TRUE。与此同时,「共享租户数据库」的 zz_sys_menu 表中,「供货商-B」和「供货商-C」的两个 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值均被更新为 TRUE。

实现逻辑见如下数据处理流程图。

租户角色移除租户

当将租户从租户角色中移除时,该租户角色所关联的租户菜单,均有可能对该租户失去可用性。然而当该租户同时属于多个租户角色时,上述被移除的租户菜单,在该租户所属的其他角色中也存在时,那么本次租户移除操作,将不会对该租户的租户菜单可见性产生任何影响,见如下场景。

  • 租户角色「高级供货商-A」和「高级供货商-B」同时关联租户菜单「高级业务分析」。
  • 租户角色「高级供货商-A」包含三个租户,分别是「供货商-A」、「供货商-B」和「供货商-C」。
  • 租户角色「高级供货商-B」同时也包含了租户「供货商-B」。
  • 将租户「供货商-A」、「供货商-B」和「供货商-C」从租户角色「高级供货商-A」中移除。
  • 租户「供货商-A」的数据位于独立的「大租户-A」数据库中,而租户「供货商-B」和「供货商-C」的数据位于「共享租户数据库」中。
  • 在租户数据库「大租户-A」和「共享租户数据库」的 zz_sys_menu 表中,都会存在「高级业务分析」的租户菜单数据。
  • 再执行本次操作后,「大租户-A」数据库的 zz_sys_menu 表中,「供货商-A」的 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值将被更新为 FALSE。与此同时,「共享租户数据库」的 zz_sys_menu 表中,「供货商-C」的 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值被更新为 FALSE。
  • 由于租户角色「高级供货商-B」同时关联了租户「供货商-B」和租户菜单「高级业务分析」,「共享租户数据库」的 zz_sys_menu 表中,「供货商-B」的 tenant_id 关联的「高级业务分析」菜单的 tenant_available 字段值没有被更新,仍然保留为 TRUE。

实现逻辑见如下数据处理流程图。

租户菜单

租户平台的后台操作员,在创建租户时,通过租户角色为租户分配租户菜单。租户菜单是指租户在 SaaS 平台内,所有可用的功能菜单。比如整个 SaaS 平台包含 300 个菜单功能,而给该租户分配了其中的 200 个,那么该租户中拥有最高权限的管理员用户,将只能访问到为该租户分配的 200 个功能菜单,租户管理员再给租户其他用户分配菜单权限时,也只能从这 200 个菜单中分配。

重要差别

相比于非多租户系统的菜单表数据,多租户系统的菜单表,会为每个租户保存一份完整的菜单数据,并用 tenant_id 字段加以区分。比如整个多租户平台包含 300 个功能菜单,当前的租户业务数据库中含有 10 个租户的数据,那么该数据库的菜单表 zz_sys_menu 将总共存储 300 * 10 条数据。说到这里,可能会让有些开发者感到奇怪了,比如某一租户,只有 200 个菜单功能,但是 zz_sys_menu 表中与该租户 ID 关联的菜单数据仍然是 300 条,这是因为 zz_sys_menu 表中带有 tenant_available 字段。对于此例而言,该租户 ID 所关联的 300 个菜单中,只有其中 200 个菜单的 tenant_available 字段值为 TRUE。在租户用户登录时,我们也仅仅是返回当前租户 ID 关联的菜单中,tenant_available 字段为 TRUE 的菜单列表。这样的冗余,可以极大的简化多租户权限系统的开发难度和代码逻辑的复杂度。

添加菜单流程图

添加菜单后台代码

以下代码来自 tenant-admin 服务的 SysTenantMenuServiceImpl 类,操作的数据库是「租户后台管理数据库」。这里需要重点提及的是,存储到租户后台管理数据库和发送同步消息到 RocketMQ,必须是同一事务内的原子性行为。该功能已由橙单基础组件 common-datasync 实现。

@Transactional(rollbackFor = Exception.class)
@Override
public SysTenantMenu saveNew(SysTenantMenu sysMenu, Set<Long> permCodeIdSet) {
    sysMenu.setMenuId(idGenerator.nextLongId());
    MyModelUtil.fillCommonsForInsert(sysMenu);
    // 插入当前租户菜单数据到租户后台管理数据库。
    sysTenantMenuMapper.insert(sysMenu);
    // 将参数中的租户菜单和菜单与权限字多对多关联数据,打包成消息,
    // 一并发送到RocketMQ,并由后面的 tenant-sync 同步服务消费,
    // 并实时同步到租户业务数据库。
    JSONObject messageJsonData = new JSONObject();
    messageJsonData.put("sysTenantMenu", sysMenu);
    dataSyncProducer.sendOrderly(
            sysMenu.getMenuId(),
            applicationConfig.getTenantSyncTopic(),
            modelClass.getSimpleName(),
            DataSyncCommandType.INSERT.name(),
            messageJsonData.toJSONString(),
            TenantConstant.MESSAGE_QUEUE_SELECTOR_KEY);
    return sysMenu;
}

添加菜单同步代码

以下代码来自 tenant-sync 服务的 SysTenantMenuServiceImpl 类。

@Override
public void doHandle(String transId, String messageCommand, JSONObject messageJsonData) {
    // 通过自定义的消息命令类型,可判断该消息是新建或更新租户菜单的消息。
    if (StrUtil.equalsAny(messageCommand, DataSyncCommandType.INSERT.name(), DataSyncCommandType.UPDATE.name())) {
        // 从消息体中,按照与生产者的格式约定,解析消息中的JSON数据。
        SysTenantMenu sysTenantMenu =
                messageJsonData.getJSONObject("sysTenantMenu").toJavaObject(SysTenantMenu.class);
        // 查询租户管理数据库,获取当前系统中所有租户业务数据库的链接列表。
        List<Map<String, Object>> dataList = tenantAdminMapper.getAllTenantDatasourceList();
        // 迭代租户数据库链接列表,并在每个租户业务数据库中执行租户菜单和权限数据的同步操作。
        for (Map<String, Object> data : dataList) {
            DataSourceInfo dataSourceInfo =
                    BeanUtil.mapToBean(data, DataSourceInfo.class, true, null);
            Integer datasourceType = dataSourceInfo.getDatasourceType();
            if (messageCommand.equals(DataSyncCommandType.INSERT.name())) {
                // 执行这个方法的时候,就会切换到datasourceType所指定的租户数据库,然后再执行插入操作。
                sysTenantMenuService.saveNew(datasourceType, transId, sysTenantMenu);
            } else {
                SysTenantMenu originalSysTenantMenu =
                        messageJsonData.getJSONObject("originalSysTenantMenu").toJavaObject(SysTenantMenu.class);
                sysTenantMenuService.update(datasourceType, transId, sysTenantMenu, originalSysTenantMenu);
            }
        }
    }
    // ... ... 这里省略部分处理其他命令消息的代码。
}

租户权限同步

在基于多租户的 SaaS 平台中,所有租户权限数据的管理操作,都是由租户平台的管理人员在租户管理系统中完成的。因此租户的权限数据也都统一存储在租户后台管理数据库中,然而为了保证租户用户登录鉴权接口的高效性,我们需要将租户的权限数据,实时同步到基于混合隔离模式的多个租户业务数据库中。

基础架构

本小节的重点是介绍如何配置租户权限同步服务 tenant-sync,以支持多租户的混合隔离模式。至于多租户权限模型的更多设计细节,可参考本章前面小节的内容。

  • 高可靠性,当系统出现故障时,比如同步的目标数据库挂了,一旦恢复后,同步服务仍然可以按照原有的消息顺序,自动补齐此前积压的消息数据,并最终保证同步结果数据的业务正确性。
  • 高可用性,可以简单的理解为,同时存在多个同步服务,正常情况下只有一个服务进行消息消费和数据同步,一旦该服务崩溃,其他 STANDBY 的服务可以立即继续工作
  • 高可扩充性,系统当前只有一个处于工作状态下的同步服务,消费所有消息,并将数据同步到多个物理隔离的租户数据库。当同步消息过多时,可以同时启动多个同步服务,每个服务只面向于一个租户业务数据库。
  • 高可一致性,相当于重放级别的数据一致性。在同步消息的生产者一端,要务必保证源数据的操作顺序和所发送的同步消息顺序完全一致,这样消费端服务才能保证该级别的数据一致性。

下面是租户权限数据同步功能的架构图,具体逻辑流程和架构设计细节如下。

  • 租户后台管理人员操作租户权限数据。
  • 租户后台管理服务 tenant-admin,会将操作的租户数据存入租户后台管理数据库。
  • 与上一步操作在同一事务内,基于橙单的基础组件 common-datasync,实时同步发送「租户权限数据变化」的消息到 RocketMQ。
  • 租户权限同步服务 tenant-sync,作为该消息的消费者服务组 (作为高可用,可以启动多个),会将待同步的租户权限数据,批量插入到租户业务数据库中。
  • 在橙单现有的基础框架中,租户权限同步服务 tenant-sync 和租户业务数据库之间,可以配置为灵活的多对多关系。
  • 如果 SaaS 平台存在多个物理隔离的租户业务数据库,就可以考虑部署多个同步服务,任何一个租户业务数据库出现故障后,并不影响其他业务库的同步操作。故障数据库恢复后,同步服务会按顺序自动补齐积压的同步消息数据。

服务配置

下图为橙单的租户同步服务 tenant-sync 的配置文件,默认情况下仅有一个租户业务数据库的链接配置,如需更多可手动添加。

流程图

下面是在新增租户时,租户权限同步服务在收到同步消息后的处理流程图。

结语

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