前言

本章重点介绍的内容如下。

  • 全量缓存。所有的多租户全局编码字典数据,全部存入 Redis 缓存,其中「租户ID」和「字典编码值」会作为缓存键的一部分,以使不同租户的编码字典值可以被独立缓存。
  • 租户公用编码字典。该类字典的数据,将由租户管理后台统一维护,所有租户的「全局公用编码字典」的字典值均相同。
  • 租户非公用编码字典。初始字典数据由租户后台管理系统统一维护,并写入到租户运营通用数据库的 zz_tenant_global_dict 和 zz_tenant_global_dict_item 两个全局编码字典表中。不同租户的字典数据会以 tenant_id 字段加以区分,因此,租户管理员可根据自身的实际业务需求,任意「增删改」非公用编码字典的字典值,该类字典数据在租户间不会相互干扰。
  • 租户字典数据同步。创建租户时,会将当前租户平台中,所有非公用全局编码字典的初始化数据,全部同步到租户运营通用数据库的 zz_tenant_global_dict_item 表中。基于新租户克隆的非公用编码字典数据的 tenant_id 为当前租户的 tenantId。

基本概念

所有字典数据位于同一张数据表中,并通过不同的字典编码值 (dictCode) 加以区分。在橙单中,与多租户全局编码字典相关的数据表是 zz_global_dict 、zz_global_dict_item、zz_tenant_global_dict 和 zz_tenant_global_dict_item。

多租户编码字典表

  • zz_global_dict。存储所有的编码字典,但是不存储具体的字典数据,如年级、科目、教材版本等。因此该表只是用于全局编码字典的管理,而并不参与实际的业务应用。在多租户工程中,该表所包含的字典,仅用于租户后台管理系统的自身业务,不会应用于租户运营业务系统之中。
  • zz_global_dict_item。存储所有的编码字典数据,并用 dict_code 字段区分不同字典的数据,该表的 dict_code 字段值一定要与 zz_global_dict 表中的 dict_code 字段值保持一致。该表为系统中其他业务表的字典 ID 数据,提供关联的字典显示值数据。在多租户工程中,该表所包含的字典数据,仅用于租户后台管理系统的自身业务,不会应用于租户运营业务系统之中。
  • zz_tenant_global_dict。存储所有租户的编码字典,但是不存储具体的字典数据,如年级、科目、教材版本等。因此该表只是用于租户全局编码字典的管理,而并不参与实际的业务应用。在多租户工程中,该表所包含的字典,仅应用于租户运营业务系统。
  • zz_tenant_global_dict_item。存储所有租户的编码字典数据,并用 dict_code 字段区分不同字典的数据,该表的 dict_code 字段值一定要与 zz_tenant_global_dict 表中的 dict_code 字段值保持一致。该表为系统中其他业务表的字典 ID 数据,提供关联的字典显示值数据。在多租户工程中,该表所包含的字典数据,仅应用于租户运营业务系统。

基本操作

相比于普通系统的全局编码字典,多租户全局编码字典是要明显复杂一个量级的。因此我们需要先介绍一下,租户平台的管理人员是如何操作和管理全局编码字典的。下图中的操作,都是在租户后台管理系统 (tenant-admin) 中完成的。

  • 下图中操作的全局编码字典,是租户后台管理系统自身使用的字典数据,这一点与普通单体服务中全局编码的使用完全相同。
  • 下图操作的是租户业务运营系统中使用的租户全局编码字典数据。

字典类别

从上图可以看到,在橙单可生成的多租户工程架构中,支持了租户公用字典和非公用字典。

  • 租户公用全局编码字典。

该类型字典在整个多租户 SaaS 平台中只会保存一份,并由租户后台系统的管理人员负责维护,所有租户均使用相同的字典数据。

  • 租户非公用全局编码字。

该类型字典在整个多租户 SaaS 平台中,会为每个租户保存一份字典数据,并以 tenant_id 字段值加以区分。租户后台系统的管理人员负责新字典的创建,以及字典初始化数据的维护,所有租户均可对租户自己的字典数据进行修改,修改后并不会影响到其他租户的字典数据。

深入详解

本小节主要介绍橙单在多租户架构中数据字典的具体实现细节。

字典表差异比对

前面已经提过,多租户的全局编码字典和普通系统相比,其复杂度会明显高出一个量级。为了有助于大家理解后面的代码和流程图,这里我们先给出多租户 SaaS 平台中,全局编码字典的数据表存储分布,以及每个字典表所存字典数据项的详细比对和说明。

字典表 所在数据库 应用于 字典数据
zz_global_dict 租户管理数据库 租户管理系统 租户后台管理系统的编码字典。
zz_global_dict_item 租户管理数据库 租户管理系统 租户后台管理系统的编码字典的数据项列表。
zz_tenant_global_dict 租户运营通用数据库 租户运营业务系统 租户运营系统的全部 (公用和非公用) 编码字典。其中 initial_data 字段,以 JSON 格式存储非公用编码字典的初始化数据项列表。
zz_tenant_global_dict_item 租户运营通用数据库 租户运营业务系统 租户运营系统的全部 (公用和非公用) 编码字典数据项,公用字典只保存一份数据项列表,而非公用编码字典,会为平台内的每个租户生成一份字典数据项列表,并用 tenant_id 加以区分。

在上述表格中,zz_global_dict 和 zz_global_dict_item 两张字典表的数据,完全应用于租户后台管理系统的自身使用,因此和普通单体服务架构下的全局编码字典完全一致,这里我们就不在做过的赘述了。与数据字典相关的更多细节可参考开发文档 数据字典章节

在橙单缺省生成的基础架构代码中,zz_tenant_global_dict 和 zz_tenant_global_dict_item 两张租户全局编码字典表,应存储于租户通用业务数据库中,他们包含整个 SaaS 平台全部的 (公用和非公用) 租户全局编码字典数据。由于该类字典数据是全量缓存的,因此当前的存储架构,轻易不会出现性能瓶颈问题。

字典数据维护

租户公用编码字典的数据在整个多租户 SaaS 平台中仅保存一份,并由租户管理后台统一维护,字典数据对所有租户保持一致。而租户非公用编码字典的数据,会为每个租户克隆一份字典数据,并由 tenant_id 字段值加以区分。在创建新租户时,非公用编码字典会根据 zz_tenant_global_dict 表 initial_data 字段中的 JSON 数据,作为该字典的初始化数据列表,此后,每个租户的管理员可以按照自己的业务需求,任意修改字典的数据项值。

公用字典数据操作

租户公用编码字典的数据,只能在租户后台管理服务中,进行增删改和缓存同步的操作,每个租户是无权修改这些公用字典数据的。

  • 假设给字典编码为 grade 的年级字典,新增 item_id = 8 / item_name = '八年级' 的字典数据项。
  • 在 zz_tenant_global_dict_item 表中,判断相同字典编码 (grade) 中,是否存在相同的字典项Id (item_id = 8)。
  • 在新增字典数据之前,先清空该公用编码字典的缓存。缓存键为 GLOBAL_DICT:grade。
  • 插入字典项数据到租户业务数据库的 zz_tenant_global_dict_item 表中。
  • 新增的字典数据,对所有租户立即可见,并可直接应用于业务数据的字典关联中。

非公用字典数据操作

与租户公用编码字典最大的不同就是,这里新增的字典数据项,修改的只是编码字典的初始化模板数据项。因此是不会同步到现有租户的编码字典数据中的。仅当下次添加新租户时,该新租户使用的该非公用编码字典的初始化数据中,将包含本次新增的字典项数据。

  • 假设给字典编码为 grade 的年级字典,新增 item_id = 8 / item_name = '八年级' 的字典数据项。
  • 在 zz_tenant_global_dict 表中查询字典编码 (grade) 的数据记录,并在 initial_data 字段所包含的 JSON 数据中,判断是否存在相同的字典项Id (item_id = 8)。
  • 修改 zz_tenant_global_dict 表 initial_data 字段的 JSON 数据,把当前新字典项目添加到 JSON 中。
  • 执行 UPDATE 操作,更新  zz_tenant_global_dict 表 initial_data 字段的数据。
  • 下次新增租户时,如 NEW-TENANT-A 租户,该租户 NEW-TENANT-A 的非公用编码字典 grade 的初始化字典数据中,会包含本次添加的数据项「item_id = 8 / item_name = ‘八年级’」。
  • 每个租户的管理员,可在自己的全局编码字典管理页面,修改租户自身的非公用编码字典数据,并不会对其他租户的字典数据,产生任何影响。

流程图

在开始深入代码之前,可以先看一下这张完整的流程图,该流程图覆盖三个阶段,也是多租户全局编码字典数据维护的全部流程。

  • 租户后台系统管理员,新增租户全局编码字典。
  • 租户后台系统管理员,新增租户。
  • 租户业务系统管理员用户,修改租户非公用全局编码字典数据。

代码示例

下面的代码来自于租户后台管理服务 tenant-admin 的 TenantGlobalDictController 接口类。下面的接口代码同时包含了「租户公用字典」和「租户非公用字典」数据项的添加操作,并且给出了非常非常详细的代码注释,以帮助大家理解。

@PostMapping("/addItem")
public ResponseResult<Long> addItem(@MyRequestBody TenantGlobalDictItemDto tenantGlobalDictItemDto) {
   // 这里都是数据验证的代码,为了示例代码的上下文完整性,我们没有略过。
   String errorMessage = MyCommonUtil.getModelValidationError(tenantGlobalDictItemDto);
   if (errorMessage != null) {
       return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage);
   }
   ResponseResult<TenantGlobalDict> verifyResult =
           this.doVerifyTenantGlobalDict(tenantGlobalDictItemDto.getDictCode());
   if (!verifyResult.isSuccess()) {
       return ResponseResult.errorFrom(verifyResult);
   }
   TenantGlobalDict dict = verifyResult.getData();
   TenantGlobalDictItem dictItem = MyModelUtil.copyTo(tenantGlobalDictItemDto, TenantGlobalDictItem.class);
   Long id = null;
   // 对于租户的全局编码字典,由于公用字典全系统只存在一份,而非公用字典会为每个租户存储一份,
   // 并用tenant_id加以区分,因此他们的实现是有差别的。这里的示例代码只是给出公用字典的处理方式。
   if (dict.getTenantCommon()) {
       // 因为所有的字典项数据都是逻辑删除的,因此不能使用 (字典编码 + 字典项Id) 的唯一键索引。
       // 那样的话,某个字典编码的字典项Id一旦被删除,新增字典数据就不能使用该字典项Id了,这显然
       // 是不太合理的,所以我们这里没有给 zz_tenant_global_item 表创建任何唯一所以,而是通过
       // 代码的方式尽量保证唯一,尽管我们知道这样会有并发的时间窗口导致数据重复,但是考虑到字典数据
       // 的修改是非常低频低并发的操作,所以就选择了这种唯一值验证的实现方式。
       if (tenantGlobalDictItemService.existDictCodeAndItemId(dict, tenantGlobalDictItemDto.getItemId())) {
           errorMessage = "数据验证失败,该租户字典编码的项目Id已存在!";
           return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage);
       }
       // 直接将数据出入到租户后台管理数据的zz_tenant_global_dict_item表中,同时更新移除该字典的缓存。
       tenantGlobalDictItemService.saveNew(dict, dictItem);
       id = dictItem.getId();
   } else {
       if (this.existItemForTenantDict(dict, dictItem)) {
           errorMessage = "数据验证失败,该租户字典编码的项目Id已存在!";
           return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, errorMessage);
       }
       // 对于多租户非公共字典,这里添加的字典项数据,都是该编码字典的初始化数据,只会在新建租户的时候,
       // 将这里的数据同步到租户的编码字典中,后面所有的修改,都不会再同步到已有租户的字典数据了,也避免
       // 了数据冲突。
       // zz_tenant_global_dict表中,包含一个initial_data字段,专门以JSON的格式存储租户非公用字典
       // 的初始化数据。
       this.addInitialData(dict, dictItem);
       MyModelUtil.fillCommonsForUpdate(dict, dict);
       // 在租户后台管理应用中,新增的非公用租户字典项,不会像租户公用字典项那样,在zz_tenant_global_dict_item
       // 表中插入一条字典数据。而是在zz_tenant_global_dict的initial_data字段中,插入一个JSON的数据,
       // 因此这里就是对zz_tenant_global_dict表的更新操作了。
       tenantGlobalDictService.updateById(dict);
   }
   return ResponseResult.success(id);
}

下面的代码来自于橙单基础组件 comm-dict 的 TenantGlobalDictServiceImpl 服务实现类。

@Override
public TenantGlobalDictItem saveNew(TenantGlobalDict dict, TenantGlobalDictItem dictItem) {
   // 因为该编码的字典添加了新的字典项,比如字典编码为(grade)的年级字典,新增了一条字典数据(八年级)。
   // 由于同一编码字典的所有字典项数据,都缓存到一个redis数据结构中,因此该编码字典有任何数据变化,
   // 都需要主动移除缓存。
   // 租户公用编码字典的redis键是,GLOBAL_DICT:grade, 而租户非公用编码字典的redis键,会在末尾拼上租户Id,
   // 如:GLOBAL_DICT:grade-xxxxxxx。
   tenantGlobalDictService.removeCache(dict);
   // 如果是租户非公用字典,需要自动补上租户Id的值。
   if (!dict.getTenantCommon()) {
       dictItem.setTenantId(TokenData.takeFromRequest().getTenantId());
   }
   dictItem.setId(idGenerator.nextLongId());
   dictItem.setDeletedFlag(GlobalDeletedFlag.NORMAL);
   dictItem.setStatus(GlobalDictItemStatus.NORMAL);
   MyModelUtil.fillCommonsForInsert(dictItem);
   // 最后是插入到租户后台管理数据的zz_tenant_global_dict_item表中。
   // 值得注意的是,这里并没有直接同步缓存,只是完成的数据库的插入操作。
   // 缓存的同步,是在查询字典数据时,如果缓存中不存在,会查询数据库并同步缓存的。
   tenantGlobalDictItemMapper.insert(dictItem);
   return dictItem;
}

字典数据缓存

为了显著提升系统运行时效率,降低数据库访问次数,减少数据表关联,橙单将全局编码字典和字典表字典的数据全量缓存到 Redis。

手动同步缓存

当系统出现某些崩溃级异常,或是有些极小概率的并发边界窗口被触发时,都有可能导致缓存中的字典数据与数据库中的原始字典数据不一致,再有就是当一些可预见的请求洪峰到来之前,需要提前将所有的字典数据,全部进行缓存预热。在下图中,对于租户公用编码字典,前端页面会根据后台返回的两组数据列表「字典的数据库完整数据列表 (fullResultList)」和「字典的当前缓存数据列表 (cachedResultList)」进行比对,如果出现不一致的情况,会给出明显的提示警告。此时可以点击右上角的「同步缓存」强制同步即可。

缓存数据格式

在橙单的基础架构中,为了保证系统的整体运行时效率,所有的全局编码字典数据都会被存入到 Redis 缓存,缓存键格式定义如下。

  • 租户公用编码字典的 Redis 缓存键为:GLOBAL_DICT:${字典编码}。
  • 租户非公用编码字典的 Redis 缓存键为:GLOBAL_DICT:${字典编码}-${租户Id}。

每个租户公用编码字典的数据,会存储在同一个 Redis 缓存键指向的数据结构中,因此字典数据值有任何「增删改」的变化,都会清空其所属编码字典的缓存键数据。字典编码为年级「grade」的缓存结构如下。

// Redis的缓存键为GLOBAL_DICT:grade。
// 缓存值是该编码字典全部数据的JSON化格式存储。
[
  {
    "item_id" : 1,
    "item_name" : "一年级"
  },
  {
    "item_id" : 2,
    "item_name" : "二年级"
  },
  {
    "item_id" : 3,
    "item_name" : "三年级"
  },
  ... ...  
]

每个租户非公用编码字典的数据,会根据字典编码和租户 ID 的不同,在 Redis 缓存中存为多个缓存键,因此每个租户字典数据值的「增删改」变化,都只会清空该租户的编码字典缓存,并不会影响到其他租户的字典缓存。其缓存结构和上面的公用编码字典完全一致,只是缓存数据键值,存在以下两点差别。

  • 缓存键格式为「GLOBAL_DICT:${字典编码}-${租户Id}」,和公用编码字典相比,多出了「租户 ID」作为后缀。
  • 缓存的字典数据,仅为当前租户的个性化字典编码数据。

代码示例

  • 下面的代码片段取自于 common-dict 基础组件的 TenantGlobalDictServiceImpl.java 文件。该方法是移除租户 (公用和非公用) 编码字典缓存数据的唯一操作方法。
@Override
public void removeCache(TenantGlobalDict dict) {
   if (StrUtil.isBlank(dict.getDictCode())) {
       return;
   }
   // 这里先根据待移除字典的编码构建Redis缓存键值。
   String key = RedisKeyUtil.makeGlobalDictKey(dict.getDictCode());
   // 再次判断是否为租户公用编码字典,如果不是,就会在缓存键末尾,加上 "-${tenantId}" 的后缀。
   // tenantId值可从当前请求的TokenData中获取。
   if (!dict.getTenantCommon()) {
       key = this.appendTenantSuffix(key);
   }
   // 从redis缓存中移除该编码字典数据。
   redissonClient.getMap(key).delete();
}
  • 下面的代码片段同样取自于 common-dict 基础组件的 TenantGlobalDictServiceImpl.java 文件。该方法是从缓存中获取租户 (公用和非公用) 编码字典数据的唯一操作方法。详见如下代码和注释。
@Override
public List<TenantGlobalDictItem> getGlobalDictItemListFromCache(
       TenantGlobalDict dict, Set<Serializable> itemIds) {
   // 这里先根据编码字典的dictCode值构建Redis缓存键值。
   String key = RedisKeyUtil.makeGlobalDictKey(dict.getDictCode());
   // 再次判断当前字典是否为租户公用字典,如果不是,就会在缓存键末尾,加上 "-${tenantId}" 的后缀。
   // tenantId值可从当前请求的TokenData中获取。
   if (!dict.getTenantCommon()) {
       key = this.appendTenantSuffix(key);
   }
   List<TenantGlobalDictItem> dataList;
   RMap<Serializable, String> cachedMap = redissonClient.getMap(key);
   // 判断是否存在这个缓存键,如果存在,就是直接获取缓存中的数据。
   if (cachedMap.isExists()) {
       Map<Serializable, String> dataMap =
               CollUtil.isEmpty(itemIds) ? cachedMap.readAllMap() : cachedMap.getAll(itemIds);
       // 并将缓存中的JSON数据,反序列换为租户全局编码字典项(TenantGlobalDictItem)对象后,直接返回。
       dataList = dataMap.values().stream()
               .map(c -> JSON.parseObject(c, TenantGlobalDictItem.class)).collect(Collectors.toList());
   } else {
       // 如果缓存中没有存在,就要先去数据库中查询该编码字典的所有字典数据列表。
       dataList = tenantGlobalDictItemService.getGlobalDictItemList(dict);
       // 再将刚刚从数据库中获得的字典数据集,同步到缓存。
       this.putCache(dict, dataList);
       if (CollUtil.isNotEmpty(itemIds)) {
           // 如果本地方法只是获取部分字典数据,这里再进行一下过滤,取出不要的字典项后返回。
           Set<Serializable> tmpItemIds = itemIds;
           dataList = dataList.stream()
                   .filter(c -> tmpItemIds.contains(c.getItemId())).collect(Collectors.toList());
       }
   }
   return dataList;
}

字典数据翻译

这里主要介绍的就是如何使用租户全局编码字典。这里我们仍以橙单多租户教学版工程为例。

  • 这里我们先在「知识点 (Knowledge)」实体对象中定义「教材版本 (editionId)」字段,关联了租户全局编码字典,字典编码为 (edition)。
@Data
@TableName(value = "zz_knowledge")
public class Knowledge {

   @TableId(value = "knowledge_id")
   private Long knowledgeId;

   @TableField(value = "knowledge_name")
   private String knowledgeName;

   @TableField(value = "edition_id")
   private Integer editionId;
   // ... ... 为了便于演示,我们省略了其他字段的定义。
   
   // 该注解参数,给出如下含义。
   // 1. 提取实体对象的editionId字段值。
   // 2. 查询编码为(edition)的全局编码字典数据。
   // 3. 将当前对象editionId的字段的值和字典项(itemId)去匹配。
   // 4. 匹配之后,将字典的键和值都写入到当前被注解的变量(editionIdDictMap)。
   // 5. editionIdDictMap包含两个数据项,分别是id和name。
   @RelationGlobalDict(
           masterIdField = "editionId",
           dictCode = "edition")
   @TableField(exist = false)
   private Map<String, Object> editionIdDictMap;
}
  • 在查询「知识点」数据列表或详情时,由于 Knowledge.editionIdDictMap 被标记了 @RelationGlobalDict 注解,因此橙单的底层架构会根据注解参数,自动完成字典数据翻译和组装的工作。下面的服务实现类方法,会被查询列表接口调用。
@Override
public List<Knowledge> getKnowledgeListWithRelation(Knowledge filter, String orderBy) {
   // 先从数据库中,以单表的形式查询主表(知识点)的过滤、分页和排序后的数据集。
   List<Knowledge> resultList = knowledgeMapper.getKnowledgeList(null, null, filter, orderBy);
   int batchSize = resultList instanceof Page ? 0 : 1000;
   // 下面的方法会实现。
   // 1. 从缓存中批量获取编码字典数据。
   // 2. 并将字典数据与当前实体对象的字典字段(如:editionId)的值进行匹配。
   // 3. 并逐条将匹配后的数据,赋值给注解标记的字段(如:editionIdDictMap)。
   this.buildRelationForDataList(resultList, MyRelationParam.normal(), batchSize);
   return resultList;
}
/**
 * 为实体对象参数列表数据集成全局字典关联数据。
 *
 * @param resultList   主表数据列表。
 */
private void buildGlobalDictForDataList(List<M> resultList) {
   if (CollUtil.isEmpty(resultList)) {
       return;
   }
   for (LocalRelationStruct relationStruct : this.relationGlobalDictStructList) {
       // 根据当前的实体对象结果集,从中提取出字典Id的集合,对应本例的editionId的集合。
       Set<Object> masterIdSet = resultList.stream()
               .map(obj -> ReflectUtil.getFieldValue(obj, relationStruct.masterIdField))
               .filter(Objects::nonNull)
               .collect(toSet());
       if (CollUtil.isNotEmpty(masterIdSet)) {
           // 下面是通过反射的方法进行调用。实际等同于
           // tenantGlobalDictService.getGlobalDictItemListFromCache("edition", editionIdSet)。
           // 由此可以看出,我们是批量获取字典数据的,因此效率很高。
           Map<Serializable, String> dictMap = ReflectUtil.invoke(
                   // 这common-dict组件中的localService,指向TenantGlobalDictServiceImpl对象
                   relationStruct.localService,
                   // 下面的反射方法,就是前面示例代码中,通过缓存获取全局编码字典数据的方法。
                   // getGlobalDictItemListFromCache
                   relationStruct.globalDictMethd,
                   // 注解中的字典编码参数,对应本例的“edition"。
                   // masterIdSet主表中的字典数据集合,对应本例的 editionId Set。
                   relationStruct.relationGlobalDict.dictCode(), masterIdSet);
           // 利用通过的工具方法,实现数据的组装。
           MyModelUtil.makeGlobalDictRelation(
                   modelClass, resultList, dictMap, relationStruct.relationField.getName());
       }
   }
}

租户字典同步

前面已经多次介绍,租户非公用编码字典,会为每个租户保存一份字典数据,并以 tenant_id 字段加以区分。下面我们从业务场景入手,解释一下该功能的实现逻辑。

  • 基于多租户的 SaaS 平台要新增一个租户。
  • 现有平台中已经包含 10 个租户非公用编码字典。
  • 在创建租户的同时,还需要为该租户克隆一份这 10 个非公用编码字典的数据。新增字典数据的 tenant_id 使用新租户的 tenantId 值。
  • 每个字典所用的初始化数据,来自于字典表 zz_tenant_global_dict 的 initial_data 字段中的 JSON 数组数据。
  • 最后补充一句,删除租户时,也会采用相同的方式,删除与该租户Id关联的非公用编码字典数据。

下面是与租户字典同步相关的两段代码示例。

  • 创建租户时,会发送新建租户的消息到 RocketMQ,消息体中仅包含租户对象和与其关联的租户菜单列表数据。下面的示例代码,来自于 tenant-admin 服务的 SysTenantServiceImpl 类。
// 这个事务注解可以保证了新租户本地数据库保存和发送同步消息的操作在同一个事务中完成,
// 看到这里可能你会质疑,这很正常。然而这一次都是橙单 common-datasync组件来实现的。
// common-datasync组件可以保证数据的同步是重放式级别的。
@Transactional(rollbackFor = Exception.class)
@Override
public SysTenant saveNew(SysTenant sysTenant) {
   // 将新租户数据,先插入到租户后台管理数据库的zz_sys_tenant表中
   sysTenant.setTenantId(idGenerator.nextLongId());
   sysTenant.setAvailable(true);
   sysTenant.setDeletedFlag(GlobalDeletedFlag.NORMAL);
   MyModelUtil.fillCommonsForInsert(sysTenant);
   sysTenantMapper.insert(sysTenant);
   List<SysTenantMenu> menuList = sysTenantMenuService.getAllList();
   JSONObject messageJsonData = new JSONObject();
   messageJsonData.put("sysTenant", sysTenant);
   if (CollUtil.isNotEmpty(menuList)) {
       messageJsonData.put("menuList", menuList);
   }
   // 这里是同步发送新租户权限数据的消息。
   dataSyncProducer.sendOrderly(
           sysTenant.getTenantId(),
           applicationConfig.getTenantUpmsSyncTopic(),
           modelClass.getSimpleName(),
           DataSyncCommandType.INSERT.name(),
           messageJsonData.toJSONString(),
           MultiTenantConstant.MESSAGE_QUEUE_SELECTOR_KEY);
}
  • 同步消息的消费者服务,在接收到该消息后,会先切换到租户通用业务数据库,查询所有非共用租户字典列表,并为当前新增租户插入所有非共用编码字典的初始化数据到租户通用业务数据库的 zz_tenant_global_dict_item 表中。下面的示例代码,来自于 tenant-sync 服务的 SysTenantServiceImpl 类。
// 下面的注解会保证切换到租户通用业务数据库
@SwitchTenantDatasource(datasourceType = DataSourceType.TENANT_COMMON)
public void doHandleWithinTenantDictDatabase(String transId, String messageCommand, JSONObject messageJsonData) {
   Long tenantId = messageJsonData.getJSONObject("sysTenant").toJavaObject(SysTenant.class).getTenantId();
   if (messageCommand.equals(DataSyncCommandType.INSERT.name())) {
       TenantGlobalDict filter = new TenantGlobalDict();
       filter.setTenantCommon(false);
       List<TenantGlobalDict> dictList = tenantGlobalDictService.getGlobalDictList(filter, null);
       if (CollUtil.isEmpty(dictList)) {
           return;
       }
       dictList.stream().filter(dict -> StrUtil.isNotBlank(dict.getInitialData())).forEach(dict -> {
           List<TenantGlobalDictItem> dictItemList =
                   JSON.parseArray(dict.getInitialData(), TenantGlobalDictItem.class);
           dictItemList.forEach(dictItem -> {
               dictItem.setDictCode(dict.getDictCode());
               dictItem.setTenantId(tenantId);
               dictItem.setCreateUserId(dict.getCreateUserId());
           });
           tenantGlobalDictItemService.saveNewBatch(dictItemList);
       });
   } else if (messageCommand.equals(DataSyncCommandType.DELETE.name())) {
       TenantGlobalDictItem filter = new TenantGlobalDictItem();
       filter.setTenantId(tenantId);
       tenantGlobalDictItemService.removeBy(filter);
   }
}

结语

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