历史与版本

希望目标实体以及数据可以留存下来,并且恢复到指定的版本。

使用版本号

版本号是一种常见的版本设计方案,就是在要进行历史数据保留的表上面增加一个版本号字段,该字段可以是DateTime类型,也可以是int类型,每进行数据操作时,都是创建一个新的版本,版本是只增不减的,所以只需要拿到最大一个版本号,就能得到最新的业务数据。

版本号除了能够用于留存历史数据外,还有一个功能就是避免并发编辑操作。比如我们有一个对象A,当前的版本是1,两个用户同时打开了该对象的编辑页面,进行数据更改。先是甲用户提交更改,这个时候系统把对象的ID和版本进行查询,发现要修改的数据最新版本是1,所以成功修改,保存了对象A的新版本2。这个时候用户乙也提交了修改。系统把对象的ID和版本1进行查询,发现要修改的数据最新版本是2,不符合要求,所以拒绝用户乙的修改。用户乙只有刷新界面,拿到最新的版本2,再进行修改。

ID
单号
金额
版本号

1

EXP123

100

1

2

EXP123

120

2

但是,使用这种方式也有很明显的缺点:

  1. 历史版本信息与当前版本信息放在一张表中,容易导致表数据过大。解决办法:

    1. 限制存储历史版本的数量,比如一个实体最多存储10条历史记录,如果超过10条,删除最老的版本,再新增一个新的版本;或者直接更新最老的版本为新版本

    2. 对表中的数据进行分表操作

  2. 查询列表、或者是分页信息的代码将会变得很难写,导致代码很难维护

    1. 增加标记字段(newtest),标记当前条目是最新的版本,在查询时增加这个条件逻辑

  3. 没法设置中间态,某些版本可能需要先入库,但是并不是立即生效的,比如实体处于审批中的状态,使用这种方式没法区分生效失效的情况

    1. 使用业务状态判断,比如业务实体的审批状态,如果处于审批中,获取时就将这个条目排除

    2. 单独增加增加一个状态,用以表示该条目是否生效

使用生效时间,失效时间

保存历史数据的第二办法是使用生效失效时间来表示一个版本。要进行历史数据记录的表增加“生效时间”“失效时间”两个字段,两个字段不允许为空。对于刚创建的数据,生效时间是创建该数据的时间,失效时间是9999-12-31。现在对这条数据进行了修改,那么我们只需要将当前时间设置为上一个版本的失效时间,同时创建一条新数据,生效时间是当前时间,失效时间是9999-12-31即可。

ID
单号
金额
生效时间
失效时间

1

EXP123

100

2013/9/1 15:30:00

2013/9/9 15:00:00

2

EXP123

120

2013/9/9 15:00:00

9999/12/31 23:59:59

缺点:

  1. 历史版本信息与当前版本信息放在一张表中,容易导致表数据过大

  2. 查询时需要带着生效时间以及失效时间判断

  3. 无法直观知晓历史版本有多少个

使用版本历史表

使用历史表其实就是建立完全相同Schema的表(当然,也可以添加更多的字段用于记录额外的历史版本信息),该表只保留历史版本的数据。这有点像一个归档逻辑,所有历史版本我们认为都应该是不经常访问的,所有可以扔到单独的表,对于现有生效的版本,仍然保留在原表中,如果需要查询历史版本,那么就从历史表中查询。

使用单独的历史表有以下好处:

  • 业务数据表的数据量不会因为历史版本记录而膨胀。因为历史数据都记录到了另外一个表中,所以业务数据表只记录了一份数据。

  • 业务数据表的Schema不需要调整,增加额外的版本字段。由于对原有数据表不做Schema变更,所以原有查询逻辑也不用更改。对于一个现有的数据库设计,在增加历史数据记录功能时更简单。

  • 业务数据表可以直接进行update操作,不会生成新的ID。由于ID不会变,所以我们并需要业务主键应用到程序逻辑中。

使用历史表记录历史版本主要是要对数据操作方法(增加、删除、修改)进行修改,使得每次数据操作时,先在历史表中留痕,然后再进行数据操作。另外就是对查询历史版本功能进行修改,因为历史数据在另外一个表中,所以对于的SQL是不一样的。当然,我们也可以创建历史版本数据库,里面保存了所有的历史表。

使用文档记录版本历史(不支持版本并发)

使用文档记录每个单据的版本历史,文档的内容格式可以是任意一种可序列化的数据结构。比如常用的Java、XML、YAML等。

不支持版本并发:某个单据不可能存在两个版本同时发生变更,故初始状态暂存只会有一个,只有当当前版本的生命周期结束后,才能新增加一个版本。

版本信息存储:t_change

t_change 中记录的是某个单据的某个版本,不同的版本,他的版本号version以及版本编号changeCode不同,根据businessId以及businessType两个字段可以找到该笔业务单据的所有版本历史。

其中单条版本的内容如下:

版本的状态

一个版本也有他的生命周期,版本在创建后,如果发生了不同的操作,那么版本的状态转换也会发生不同,在遇到的实际项目设计中,版本的状态机图如下:

版本状态机图

注意:

  1. 所有动作的执行都会校验当前change的状态,只有指定状态下才可做指定的操作

  2. stash 暂存数据时,如果暂存数据是新的一条,那么会自动生成编号(唯一),并且生成version

版本的额外信息存储 Metadata

要想支持复杂的版本记录场景,单据记录版本历史是无法满足所有的需求的,故增加一个Metadata字段,他的类型与content字段类似,可以是任意结构。但是与content内容不同的是,metadata用来记录与版本本身相关的数据,比如:

  • 版本的生成来源是手动还是自动

  • 版本是否需要隐藏

  • 版本的变更等级

  • 版本的提交时间

  • 版本的提交人

等等等。

示例:业务单据实现版本变更

业务单据会签状态

业务单据会签状态
  1. 手动保存

  2. 自动新增

  3. 删除(略,只有为会签数据可被删除)

  4. 提交

  5. 审批撤回

  6. 审批拒绝

  7. 审批通过

业务的每个操作都会进行版本记录。

业务单据变更状态

业务单据变更状态
  1. 变更保存

  2. 变更提交

  3. 变更审批撤销

  4. 变更审批拒绝

  5. 变更审批通过

变更与审批的关系

业务进行会签、变更操作时,有可能发生审批。如果该版本经过了审批,那么就会在metadata中设置一个标签:

如果workflowCode不为空,那么代表该版本经过了审批,有审批履历。

审批的发起是使用t_changeid + workflowCode 作为唯一标识;审批的workflowCode在审批一发起的时候就进行设置,后续无论进行任何操作,都不做更新。

变更等级

在业务发起变更时,根据变更内容的不同,需要决定不同的审批流;变更等级分为四种:

变更等级的计算是当前版本与上个published的版本相对比,以找出对应的等级。找出等级后,为了避免实时对比对性能有所损耗,会将变更等级存储在标签中:

业务自动新增和手动新增

决策在审批通过后,会生成关联的业务信息,自动生成的业务,即使是未会签也需要生成版本:

设置业务阶段为生成阶段,是为了区分该操作对应的业务操作阶段,以支持变更历史以及审批履历的标签信息,目前将阶段分为三个阶段:

设置生成来源,是为了标记业务的创建来源是什么,有手动创建与自动生成两种:

手动创建的业务会在暂存的时候设置生成来源为手动创建。

版本来源

一个版本的升级,可能是他本身发生变化导致的,也可能是由其他版本影响导致的。如果是受其他版本影响所导致的,需要设置来源的changeId

比如,当决策发生变更时,其关联的业务会发生变更,这时候就需要记录这个版本的变更来源版本的changeId,已确保清楚每个变更的来源。

在决策影响点到业务处,会设置决策当前的changeId到业务影响版本。

作废来源

作废来源,代表这个版本被作废的原因,设置改元数据字段的主要意图有:

  1. 无论是审批撤回还是审批拒绝,change的状态都会变为void已作废状态。为了区分作废倒是是因为什么操作作废的。

  2. 撤销的版本在版本历史中隐藏,故需要此字段进行标记。

撤销与状态回退

需求要求在撤销后,重新生成暂存记录,并且业务的变更状态、审批状态,要回到上个状态中;比如,上个变更状态为变更拒绝,审批状态为已拒绝,用户发起变更后,再撤销,状态需要回到这两个状态中。

实现方式为,在版本生命周期结束时,记录业务的变更状态到metadata中:

撤销时,会查询上个版本,拿到状态与审批状态,并回退:

版本的隐藏

有些版本需要进行隐藏,比如业务变更,决策需要进行版本升级,就需要对版本进行隐藏处理。故增加metadata隐藏标记:

隐藏标记不会记录在版本历史中,具体参考ChangeByStageHistoryModel

版本历史核心类ChangeByStageHistoryModel

ChangeByStageHistoryModel是一个以阶段为主要标记的版本历史帮助容器类:

图 1
  1. 历史记录是一个链表,链表以version字段正序排序

  2. 一个节点中可能包含多个版本,版本记录是一个TreeSet,也已字段version正序排序

  3. 节点的状态是由节点内的版本决定的:

    1. changeId、changeCode、version、changeStatus,取节点内最大版本的信息

    2. workflowCode则是从所有节点内的版本中取有workflowCode的第一条

  4. position用于确定版本节点在历史中的位置,比如如果Node是位于第一位置的,列表则不显示版本编号

  5. 变更次数:统计变更阶段的Node数量

  6. 拥有隐藏标记的版本,不会记录到版本历史中。但是会存在于 originModels中,因为隐藏版本有可能需要使用,比如查询上个生效版本应该包含隐藏版本。

版本依赖

场景:业务单据存在包含关系,且单据之间会互相影响。

假设决策模型下包含多个合同模型对象:

解决方案1,在父单据中冗余子的业务单据,缺点明显:

  1. 需要做数据刷新同步

  2. 冗余数据造成额外磁盘以及内存的空间消耗

  3. 父单据版本与子单据版本的对应关系,只能通过子单据版本的 fromChangeId 决定,如果想找到当前版本父业务单据找到关联的子业务单据的对应版本,是无法做到的

解决方案2,父单据只存储自己的信息,通过版本id关联业务版本:

  1. 单据版本信息解耦,不需要做数据刷新同步

  2. 不会产生冗余数据

  3. 可以通过版本索引找到每个子业务单据的版本以及版本的全部内容

  4. 代码开发复杂度变高,保存父业务单据需要提前存储其关联的所有业务单据后,并将他们的id刷入到父业务单据中,才可以完成版本刷新操作

  5. 版本在序列化、返序列化时,需要做拆分、组装等操作,对性能有一定的影响

方式一:依赖索引信息存储到content,并使用一个特殊的对象表示

这种方式可以做到代码上的无感知:

  1. 定义业务模型时,定义业务的所有字段

  2. 查询业务模型是,会自动将所有的版本引用替换为完整的业务信息

也拥有一定的缺点:

  1. 版本关联关系被耦合到业务模型中(问题不大)

  2. 如果子单据发生变更,需要更新父单据的变更content,更新时需要拿到父单据的全量信息(繁琐,mysql是这样,mongo应该可以少拿一些)

假设决策模型下包含多个业务模型对象:

在执行序列化时,需要将ContractModel的内容替换为引用id,也就是说,序列化后的内容如下:

而将上述json反序列化后,需要还原成完整的数据对象信息:

冗余业务信息,以实现目标数据定位

子业务更新后,可能需要刷新父业务中自己的版本id,比如需要刷新 cotnractId100的数据的版本从2变为 4,由于父业务中指存储了引用id,他无法定位具体的更新位置的,故冗余change的其他关键信息:businessIdbusinessType,用于定位具体的更新位置:

引用标识接口 ChangeReference

定义引用标识接口,所有需要做引用处理的业务模型类,都需要实现该接口:

还提供了一个基本实现类,用来表示一个没有任何业务信息的简单引用,该类适合只需要记录版本关系的情况:

版本嵌套的层级限制

版本和版本之间可以相互关联是由前提得:

  1. 版本与版本的依赖不能出现循环依赖

  2. 版本嵌套的层级不能超过三层,比如决策依赖业务 这是二层, 决策依赖业务、业务依赖土地信息,这是三层

使用序列化、返序列化以支持版本嵌套

ChangeManager中提供两个方法,分别用于序列化、反序列化:

如果有任何类实现了ChangeReference接口,并且他引用是一个有效的引用信息,就认为这是一个有效的版本索引;那么在序列化时,只会存储版本信息,其他业务信息不会做存储;同样的,在反序列化时,如果目标类型是一个ChangeReference类型,并且返序列化后的对象是一个有效的版本引用,那么就会通过反射的方式收集所有的引用id,批量查询content,然后将content反序列化为对应的类型。

嵌套版本的单据的各种操作

假设父业务单据为决策,子业务单据为业务,决策操作时会携带所有的业务信息,模拟他们的操作时的版本行为如下:

  1. 决策保存,决策含有完整的业务信息

决策操作时不会携带所有的业务信息,他们是分开操作的,版本行为如下:

  1. 决策保存,决策只有业务的版本信息,不包含完整的业务信息

序列化、返序列化代码实现样例(Jackson 与 反射)

序列化时,如果当前对象是一个有效的版本引用,那么序列化时,忽略该对象的其他所有字段,只序列化版本引用信息入库:

反序列化时,在Jackson处理后,使用反射 + 递归的方式获取所有的 changeReferenceId,然后从库中查询到所有的版本信息,并填充到目标对象中:

方式二:依赖索引信息单独存储到一个字段中

t_change表中增加字段 relationship,用以记录当前版本关联的所有版本:

字段名
字段类型

references

json

他的类型是一个json类型,你可以设计该类型为任意一种结构,比如设计为一个id数组:

也可以这样:

这种方式的缺点为:

  1. 业务端需要自己将索引信息填充到业务信息中

  2. 在替换子业务(业务)的版本,需要在父业务(决策)版本维度操作,将所有的id生成好后再重新刷入

  3. 如果是数组方式,业务端还要判断每个索引的id是属于什么类型的业务的,并对他们进行分组填入

  4. 从父单据可以很容易的通过relationship找到关联子单据的对应的版本,但是从子单据,很难获取父单据的版本。所以数组方式所表现的数据场景有限,可以使用自定义的json结构来存储表示,比如:

    具体处理方式由业务端决定。

最后更新于

这有帮助吗?