基于关系型数据库的版本管理

概念

业务层对于同一条记录可能编辑多次,为了记录用户修改数据的轨迹,以便追踪活动和还原数据,系统为每次操作生成一个带编号的副本。

设计

结构

系统数据表 DataVersionSettings

  • id // 自增
  • contentModelId // 业务层数据表标识
  • isEnabled // 是否启动版本管理功能
  • dotCount // 版本号中小数点的个数
  • maxVersionFrequency // 生成新版本的最大频率(次数/小时),若该值为6,则10分钟内的第二次更新将不生成新版本
  • createdAt
  • updatedAt

业务层数据表需要以下字段支持

  • version // 可以考虑用_version

    版本号规则,系统管理员根据业务工作流的环节个数自定义版本格式,即有多少个环节就有多少个以“.”分割的数字。

    1. 三个环节 文字小编 0.0.1, 0.0.2 插画小编 0.1.2, 0.2.2 主编 1.2.2 文字小编 1.2.3, 1.2.4, 1.2.5 插画小编 1.3.5, 1.4.5 主编 2.4.5

    2. 二个环节 文字小编 0.1, 0.2 主编 1.2 文字小编 1.3, 1.4, 1.5 主编 2.5

    3. 一个环节 编辑 1, 2, 3, 4, 5, 6...

      优势:各个环节的编辑次数独立记录;信息直观全面;兼容最基础的版本号方案。

  • createdBy

  • createdAt
  • deletedAt

特性

  1. 业务运行时不可以开启/关闭版本功能

  2. 只支持对所有数据表整体开启版本功能,后续再考虑是否需要单独给某些表配置。

  3. 历史版本不可变更

  4. 版本号的格式设定后不可变更

  5. 实体可以被删除 // 可以删除实体,即将最新版本的 deletedAt 置为 时间戳

  6. 实体可以被还原 // 即将最新版本的 deletedAt 置为 NULL

  7. 实体只有在非删除状态下,才可以增加版本

  8. 频繁更新保护

当同一个用户在n分钟内对同一条记录在相同环节进行连续保存时,可以直接更新数据库中的本条记录,减少一些无用版本的产生。具体逻辑为,若n为10,10分钟内用户对同一数据条目更新了3次,第11分钟又对这一数据条目更新了1次,那么最终数据库中会存在2个版本。每次更新数据前,比较当前时间和当前最新版本的 createdAt。若大于n则生成新条目,即生成新版本;若小于等于n则更新当前最新数据条目,不生成新条目,即不生成新版本。

系统管理者应当可以在控制台中开启和关闭“频繁更新保护”功能,以及设置保护的时间间隔n。

  1. 分表分库

  2. 不支持任何因业务层模型关联而产生的需求

关于多人同时保存一个数据组可能导致数据查询混乱问题,解决方案只有在业务层对数据组加锁。问题的本质是每个实体的保存都可能需要百毫秒级的业务层逻辑处理。

举例说明

第一步:读的时候要保证数据组处于静态,否则读出来的数据组版本号对不齐。

titan version = 10; experience1 version = 10; experience2 version = 10; experience3 version = 10;

第二步:假设当两个编辑者都读出了以上数据组,再某一时刻同时修改内容并写入,如何保证数据组的自增版本号仍然对齐?

由于每个实体的保存都可能需要百毫秒级的业务层逻辑处理,所以可能出现以下情况:

titan version = 11; experience1 version = 12; experience2 version = 11; experience3 version = 11;

titan version = 12; experience1 version = 11; experience2 version = 12; experience3 version = 12;

如果不对数据组进行加锁,也可以在业务层建立数据组颗粒度的资源竞争队列,一旦某组数据发现同时写入情况,自动加入到等待队列。

APIs

System APIs

DataVersion.updateSettings(settings) {
    isEnabled = FALSE;
    dotCount = settings.dotCount;
    maxVersionFrequency = settings.maxVersionFrequency;

    for (contentModel in contentModels) {
        // Insert a record into DataVersionSettings table.
        newObject({
            contentModel.contentModelId,
            isEnabled,
            dotCount,
            maxVersionFrequency,
        });
    }
}

DataVersion.enable() {
    // Select all records from DataVersionSettings table.
    for (dataVersionSetting in dataVersionSettings) {
        if (!hasSetVersionColumns(dataVersionSetting.contentModelId)) {
            setVersionColumns(dataVersionSetting.contentModelId); // Version columns are 'version', 'createdAt', and 'deletedAt'.
        }

        dataVersionSetting.isEnabled = TRUE;

        // Update the record.
        updateObject(dataVersionSetting);
    }
}

Write APIs

// 创建新的实例
ContentModel.newEntity(entityObject) {
    newObject {
        ...
        version = 1,
        createdBy = user,
        createdAt = time(),
        deletedAt = NULL
    }
}

// 更新实例版本
ContentModel.newVersion(entityId, entityObject) {
    // Get the latest version number.
    latestVersionEntity = private.getLatestVersion(entityId);

    newObject {
        ...
        version = latestVersionEntity.version + 1,
        createdBy = user,
        createdAt = time(),
        deletedAt = NULL
    }
}

// 更新实例版本,但是不改变任何内容
ContentModel.newVersionWithoutChange(entityId) {}

// 删除实例
ContentModel.deleteEntity(entityId) {
    latestVersionEntity = private.getLatestVersion(entityId);
    latestVersionEntity.deletedAt = time();

    updateObject(latestVersionEntity);
}

// 恢复处于删除状态的实例
ContentModel.recoverEntity(entityId) {
    latestVersionEntity = private.getLatestVersion(entityId);
    latestVersionEntity.deletedAt = NULL;

    updateObject(latestVersionEntity);
}

// 系统私有方法
private getLatestVersion(entityId);

关于业务层关联模型(数据组)对该版本功能的使用建议:

> 业务层应首先从`数据组`中选中一个ContentModel作为MainContentModel,`数据组`的版本数量即为MainContentModel的版本数量。
> 如果这个MainContentModel是Titan,那么任何一次改动Titan的版本都要+1,如果这个MainContentModel是Resume,那么任何一次改动Resume的版本都要+1。
> 除MainContentModel之外,任何其他ContentModel的实例只有在自身发生变化时才去保存。

CURD 示例

假设在简历编辑页面中包含了 Titan 的基本信息,多个 Experiences ,每个 Experience 都要填写一个 Company ,那么在 UX 的设计上需要遵守指定规则,即每个 Content Model 的字段单独保存,以 LinkedIn 的个人简历页面设计为参考。特别注意,一种 Content Model 的每个实体都要单独保存,批量保存会产生很繁琐的“批量条目修改前后内容对比”的逻辑。

---初始数据--- Titans id = 100001, titanId = 101, version = 1.0, name = 'Cook', createdAt = '2020-01-01 11:40:00', deletedAt = NULL

Experiences id = 200001, experienceId = 201, version = 1.0, position = 'ceo', titanId = 101, companyId = 301, createdAt = '2020-01-01 11:40:00', deletedAt = NULL

id = 200002, experienceId = 202, version = 1.0, position = 'coo', titanId = 101, companyId = 302, createdAt = '2020-01-01 11:40:00', deletedAt = NULL

Companies id = 300001, companyId = 301, version = 1.0, name = 'Freescale', createdAt = '2020-01-01 11:40:00', deletedAt = NULL

id = 300002, companyId = 302, version = 1.0, name = 'Google', createdAt = '2020-01-01 11:40:00', deletedAt = NULL


新增操作 - 打开 titanId = 101 的简历,新增一条 Experience 并保存。

1. 新增条目
    `id = 200003, experienceId = 203, version = 1.0, titanId = 101, companyId = 303, createdAt = '2020-01-01 11:45:00', deletedAt = NULL`
2. 数据库中
    `id = 200003, experienceId = 203, version = 1.0, position = 'ceo', titanId = 101, companyId = 303, createdAt = '2020-01-01 11:45:00', deletedAt = NULL`
    `id = 200002, experienceId = 202, version = 1.0, position = 'coo', titanId = 101, companyId = 302, createdAt = '2020-01-01 11:40:00', deletedAt = NULL`
    `id = 200001, experienceId = 201, version = 1.0, position = 'ceo', titanId = 101, companyId = 301, createdAt = '2020-01-01 11:40:00', deletedAt = NULL`

更新操作 - 修改个人基本信息,将 name 改为 Cookie。

1. 新增条目
    `id = 100011, titanId = 101, version = 1.1, name = 'Cookie', createdAt = '2020-01-01 11:50:00', deletedAt = NULL`
2. 数据表中
    `id = 100011, titanId = 101, version = 1.1, name = 'Cookie', createdAt = '2020-01-01 11:50:00', deletedAt = NULL`
    `id = 100001, titanId = 101, version = 1.0, name = 'Cook', createdAt = '2020-01-01 11:40:00', deletedAt = NULL`

删除操作 - 公司因经营不善而倒闭,将这个公司从公司列表中删除。

1. 更新条目
    `id = 300001, companyId = 301, version = 1.0, name = 'Freescale', createdAt = '2020-01-01 11:40:00', deletedAt = '2020-01-01 11:55:00'`
2. 数据库中
    `id = 300001, companyId = 301, version = 1.0, name = 'Freescale', createdAt = '2020-01-01 11:40:00', deletedAt = '2020-01-01 11:55:00'`
    `id = 300002, companyId = 302, version = 1.0, name = 'Google', createdAt = '2020-01-01 11:40:00', deletedAt = NULL`

如果这条公司记录是测试数据,或者因其他原因需要硬删除:
1. 将所有 companyId = 301 的公司条目在当前数据库和历史数据库中均删除;
2. 将所有关联了 companyId = 301 的其他类型实体的 companyId 字段置为 NULL;

查询操作 - 执行以上系列操作后再次查看 Titan 最新内容。

1. 查询 Titan(titanId = 101 && createdAt 最新)
    `id = 100011, titanId = 101, version = 1.1, name = 'Cookie', createdAt = '2020-01-01 11:50:00', deletedAt = NULL`

2. 查询 Experiences(技术细节见版本关联查询算法)
    `id = 200003, experienceId = 203, version = 1.0, position = 'ceo', titanId = 101, companyId = 303, createdAt = '2020-01-01 11:45:00', deletedAt = NULL`
    `id = 200002, experienceId = 202, version = 1.0, position = 'coo', titanId = 101, companyId = 302, createdAt = '2020-01-01 11:40:00', deletedAt = NULL`
    `id = 200001, experienceId = 201, version = 1.0, position = 'ceo', titanId = 101, companyId = 301, createdAt = '2020-01-01 11:40:00', deletedAt = NULL`

版本关联查询算法

  1. 获取与最新版本 titan(titanId = 101) 关联的 experiences

    分析:与 titanId = 101 关联的所有的经过 experienceId 去重(保留 createdAt 最大的条目)后的 experiences,只要 deletedAt != NULL,就一定是 titanId = 101 实例最新版本所关联的。

    Step1: select * from Experiences where titanId = 101 groupBy experienceId orderBy createdAt desc;

    Step2: loop each group to get the first experience, if deletedAt != NULL, then drop;

    Step3: return resultExperiences;

  2. 获取与 version = x 的 titan(titanId = 101) 关联的 experiences

    Step1: upperTimeLimit = select createdAt from Titans where (titanId = 101 && version = x+1); (if upperTimeLimit == NULL then Step2a else Step2b)

    Step2a: select * from Experiences where (titanId = 101) groupBy experienceId orderBy createdAt desc;

    Step2b: select * from Experiences where (titanId = 101 && createdAt < upperTimeLimit) groupBy experienceId orderBy createdAt desc;

    Step3: loop each group to get the first experience, if deletedAt != NULL, then drop;

    Step4: return resultExperiences;

  3. 获取与 resultExperiences 关联的 companies

    Step1: get relative companyIds from resultExperiences;

    Step2: upperTimeLimit = select createdAt from Titans where (titanId = 101 && version = x+1); (if upperTimeLimit == NULL then Step3a else Step3b)

    Step3a: select from Companies where (companyId in companyIds) groupBy companyId orderBy createdAt desc; Step3b: select from Companies where (companyId in companyIds && createdAt < upperTimeLimit) groupBy companyId orderBy createdAt desc;

    Step4: loop each group to get the first company, if deletedAt != NULL, then drop;

    Step5: return resultCompanies;

    逻辑陷阱: 此处切不可使用递归思维,通过 experience 的版本获取 upperTimeLimit 。所有与 titan 直接关联或间接关联的实体的获取,都要通过 titan 的版本获取 upperTimeLimit

upperTimeLimit 误差的避免

> 由于同一批次的不同实例的数据保存时,标记的时间戳,在精度和顺序上都不一定如主观意愿(顺序为主要影响因素)。为了避免因时间戳误差(影响 `upperTimeLimit` ),导致版本关联查询结果混乱,业务层代码要遵守一定规范,示例如下:

业务场景的主内容为 titan,每个 titan 关联多个 experiences,每个 experience 关联一个 company。

假设由于业务需求,用户只有一个保存按钮,在用户视角下多个不同类型的实例同时进行保存。业务层开发者应该根据这批实例形成的树形图,自顶向下进行树节点的遍历保存,遍历方式可以是`前序遍历`,也可以是`层次遍历`。

results matching ""

    No results matching ""