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

概念

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

特性

历史版本不可变更

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

可以还原实体,即将最新版本的 deletedAt 置为 NULL

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

分表分库

以 Article 这种 Content Model 为例,同一篇文章可以存在多个活跃的版本(isActive == true),如当前已发布(published),草稿(draft),审核中(pending),和众多的不活跃的历史版本(isActive == false)。大多数时候用户只会使用到 (isActive == true) 的数据,所以进行版本分离存储有助于减少对系统资源的消耗,也能在一定程度上隔离误操作对数据的影响。

设计

“实体标识”,“版本号”,“生成时间”,“删除时间”

  • 以 {contentModel}Id 作为实体标识,以 version 作为版本号,({contentModel} + version) 作为唯一键,指向某一实体的某一版本。生成时间 createdAt 和 删除时间 deletedAt 是版本使用过程中的必要信息。

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

    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...

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

  • 关于环节个数设定后是否可以变更,暂未考虑。

频繁更新保护

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

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

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 ""