分库分表带来的完整性和一致性问题
在最近做的一个项目中,由于每天核算的数据量过于庞大,需要把数据库进行分库保存。当数据分散到各个库之后,带来的数据更新操作就会存在一个一致性和完整性的问题。下面是一个典型的场景
假设目前存在三个物理库,现在有一个文件,里面有1W条数据,根据分库的规则,可以把文件里面的数据分到三个库中,现在需要保证这1W条数据要要完整的保存到这三个库里面,并且数据是一致性的,也就是说 三个库里面已导入的数据完全和文件里面的数据一致。
正常情况下,我们先把文件里面的数据按照所属的数据库分成三份,然后针对每一份数据库进行保存,在单库的情况下,可以保证单库的数据完整性。但是三个库要保证一致性,就是非常复杂的一项工作,很有可能第一个库的数据保存成功了,但是后面三个库的数据保存失败了,导致整个文件的里面的数据在数据库里面不完整。
如何解决这种问题,目前想到的有几个办法:
方案1
使用类似JTA提供的分布式事物机制,也就是说需要相关的数据库提供支持XA的驱动。( XA 是指由 X/Open 组织提出的分布式交易处理的规范)。这个需要依赖特定的数据库厂商,也是比较简单的方案。毕竟复杂的事务管理都可以通过提供JTA服务的厂商和提供XA驱动的数据库厂商来完成。目前大多数实现了JTA的服务器厂商比较多,比如JBOSS,或者开源的JOTM(Java Open Transaction Manager)——ObjectWeb的一个开源JTA实现。但是引入支持XA的数据库驱动会带来很多潜在的问题,在 《java事务设计策略》里面:在Java事务管理中,常常令人困惑的一个问题是什么时候应该使用XA,什么时候不应使用XA。由于大多数商业应用服务器执行单阶段提交(one-phase commit)操作,性能下降并非一个值得考虑的问题。然而,非必要性的在您的应用中引入XA数据库驱动,会导致不可预料的后果与错误,特别是在使用本地事务模型(Local Transaction Model)时。因此,一般来说在您不需要XA的时候,应该尽量避免使用它。” 所以这个是一个可选的方案,也是最简单的一个方案
方案2
建立一张文件批次表(放在一个独立的数据库里面),保存待处理的文件批次信息(不是明细数据,简单说的就是要处理的文件名和所在路径),在每次处理文件数据的时候,先往表里面插入一条文件批次信息,并且设置文件的状态为初始状态,在文件中的数据全部成功的保存到三个分库里面之后,在更新文件的批次状态为成功。如果保存到分库的过程中出现异常,文件批次的状态还是初始状态。而后台启动一个定时机制,定时去扫描文件批次状态,如果发现是初始状态,就重新执行文件的导入操作,直到文件完全导入成功。这个方案看起来没有问题,但是可能存在重复导入的情况,比如批次导入到第一个分库成功了,后面两个库失败了,重新导入的话,可能会重复把数据重复导入第一个分库。我们可以在导入之间进行判断,如果导入过,就不进行导入,但是极端的情况,我们无法判断数据是否导入过,也是一个有缺陷的方案,并且如果每次导入之前,都进行数据是否导入的操作,性能会有一些影响。我们也可以通过异常恢复机制来进行,如果发现文件导入失败了,我们删除已经导入入库的流水,但是这也引入了错误处理带来的一致性问题,比如我们已经导入成功2个分库的数据,在导入第三个分库失败的情况下,要删除掉前面两个分库的数据,这也没有办法保证是一致的。
在这个方案里面,我们可以在进行一定的优化,让它看起来运作起来是没有问题的。首先再建立一张子批次表(和文件批次表放在同一个库),在进行处理的时候,我们把大的文件的数据按照分库规则拆成三个子文件,每一个子文件里面的数据对应一个分库。这样就产生三条子批次信息,由于文件批次信息和子批次信息 在同一个库里面,可以保证一致性。这样每个待处理的文件就分成了四条记录,一条主文件批次信息,三条子批次信息,在导入数据之前,这些批次的信息的状态都是初始状态。这样一个文件的导入就分解为三个子文件,分别导入到对应库里面去。对于每个子文件批次,我们可以保证子文件数据的都是在同一个库里面,保证每个子文件里面数据的一致性和完整性,然后导入成功之后,在更新子批次的状态为成功,如果所有的子文件的批次状态都为成功,那么对应的文件批次状态就更新为成功。这样看起来非常完美,解决了问题。但是仔细考虑一下,有一个小的细节问题:子批次信息和一个独立的库,要导入的数据是和子批次信息可能不再一个库,没有办法保证这两个操作是一致性的,也就是说 子文件里面的数据成功的导入到分库,但是可能子批次信息状态没有更新。那子批次信息能不能放在每个分库里面了,这样的话,又回到刚开始提出的问题了(这里面就不解释,可以去自己去想想)。
下面一副图简单的演示的设计思想:
方案3
第2个方案的基础上,可以继续加以优化。首先我们保留第二个方案的文件批次信息表和子文件批次信息表,而且我们必须把这两个表放在同一个库里面(这里假设分配到主库),保证我们拆分任务时的一致性。然后在各个分库里面,我们建立一张各个分库的子文件批次表。这个表模型基本上是和主库的子文件批次信息表一样。当拆分任务的时候,先保证主库数据的完整性,也就是产生了一条文件批次信息记录和三条子文件批次记录,然后把这三条子文件批次信息分别复制到对应的分库中的子文件批次信息表里面,然后更新主库的子文件批次信息状态为“已同步”。当然,这个过程是无法保证一致性的。解决方案启动一个定时任务,定期的把主库重点的子文件批次表信息中初始状态的记录 同步到各个分库的子文件批次表里面,这里面可能导致两种情况
1 分库子批次信息表已经存在相同的信息(这个可以通过唯一性主键保证),说明已经同步,直接更新主库的子文件批次信息状态为 “已经同步”
2 分库子批次信息不存在,则往对应的分库insert一条数据,然后更新主库的子文件批次信息状态为 “已经同步”
然后各个分库 就是先导入子文件中的数据,在更新分库的子文件批次表的状态为处理成功 ,这两个操作由于都是分库的上的操作,可以保证一致性。最后,在更新主库的子批次信息表的状态为 “处理成功”。同样,更新主库的子批次信息状态如果失败,可以采取类似的定时机制,同步分库子文件批次信息表和主库的子文件批次信息表的状态。通过这种努力重试型机制,保证了主库中的子文件批次表和分库的子文件批次表是一致的。等所有的主库子文件批次信息表状态全部更新为“处理成功”,则文件批次状态就更新为“处理成功”。
相比第二个方案,我们在两个库里面增加了数据的同步,用这种机制,保证了主库分库数据的一致性。
这里简单的介绍一下第二个方案的简单实现细节:
首先是数据库之间表结构关联关系
下面用脚本的方式简单的演示一下这个过程
我们假设有四个库,一个主库MAIN,三个字库SUB1,SUB2,SUB3
MAIN库两张表:
FILE_BATCH_NO,主要关注status状态 I(初始)->S(成功)
SUB_BATCH_NO,主要关注status状态 I(初始)->R(同步成功)->S(处理成功)
SUB库两张表
DATA_DEAIL:保存明细数据,也就是业务逻辑主要处理的表
SUB_BATCH_NO:主要关注status状态,I(初始)->S(处理成功)
1 拆分文件批次的过程
begin declare file_name,batch_no,sub_batch_no; insert into MAIN.FILE_BATCH_INFO(id,file_name,batch_no,status) values(seq.FILE_BATCH_INFO,#file_name#,#batch_no#,'I'); insert into MAIN.SUB_BATCH_INFO(id,file_name,main_batch_no,status) values(seq.SUB_BATCH_INFO,#file_name#,#batch_no#,#sub_batch_no#,'I'); insert into MAIN.SUB_BATCH_INFO(id,file_name,main_batch_no,status) values(seq.SUB_BATCH_INFO,#file_name#,#batch_no#,#sub_batch_no#,'I'); insert into MAIN.SUB_BATCH_INFO(id,file_name,main_batch_no,status) values(seq.SUB_BATCH_INFO,#file_name#,#batch_no#,#sub_batch_no#,'I'); commit; end;
2 同步MAIN库的子批次信息到分库的各个SUB库中对应的子批次信息表,同步成功,更新MAIN库对应的子批次信息状态为同步成功。
##分库的操作,从MAIN库SUB_BATCH_INFO表中获取对应的数据插入到SUB1库里面 begin transaction in SUB1 declare file_name,batch_no,sub_batch_no; select SUB_BATCH_INFO.ID into SUB_ID from MAIN.SUB_BATCH_INFO where SUB_BATCH_INFO.DATA_BASE = SUB1 //判断分库数据是否存在,存在就返回true if(select * from SUB1.SUB_BATCH_INFO where SUB_ID = SUB_BATCH_INFO.ID) return SUCCESS insert into SUB1.SUB_BATCH_INFO(id,file_name,main_batch_no,status) values(SUB_ID,#file_name#,#batch_no#,#sub_batch_no#,'I'); commit; end; ##SUB1库的操作完成之后,开始进行MAIN库SUB_BATCH_INFO表对应的update操作 begin transaction in MAIN declare SUB_ID; ## R代表已经同步的状态,这里面可以判断status的状态,不过意义不大 update MAIN.SUB_BATCH_INFO set status ='R' where ID = SUB_ID commit; end;
上面只是一个SUB库的操作,如果有多个库,循环进行操作。如果某一个库没有同步成功,有定时恢复机制。定时恢复机制的对应的SQL就是从MAIN中提取出是状态的SUB_BATCH_INFO记录,重复进行上述处理的过程
3 SUB库处理子批次信息,对流水进行保存,然后更新SUB库对应的SUB_BATCH_INFO记录状态为处理成功。然后在更新MAIN库的对应的SUB_BATCH_INFO记录状态为成功。
##分库的流水操作 begin transaction in SUB1 declare file_name,batch_no,sub_batch_no; select SUB_BATCH_INFO.status into SUB_ID from MAIN.SUB_BATCH_INFO where SUB_BATCH_INFO.DATA_BASE = SUB1 //判断状态是否是初始 if status == 'I' insert into SUB1.DATA_DETAIL update SUB1.SUB_BATCH_INFO.status ='S' end if commit; end; ##SUB1库的操作完成之后,开始进行MAIN库SUB_BATCH_INFO表对应的update操作 begin transaction in MAIN declare SUB_ID; ## R代表已经同步的状态,这里面可以判断status的状态,不过意义不大 update MAIN.SUB_BATCH_INFO set status ='S' where ID = SUB_ID commit; end;
这里的情况一样,就是SUB库和MAIN库也存在状态同步的问题,这里也需要一个定时对MAIN库的 SUB_BATCH_INFO表状态进行同步更新
4 判断MAIN库对应的SUB_BATCH_INFO所有状态是否已经为成功,如果成功,更新MAIN库的FILE_BATCH_NO 的状态为成功。
在这四个过程中,需要三个定时器。有两个定时器保证MAIN库和SUB库之间的数据一致性问题,另外一个定时器负责异步更新MAIN库 批次和子批次的一致性问题。
对于第三个方案,可以抽取出通用的逻辑,来解决后续类似的场景。比如根据条件,删除各个分库中满足条件的流水,或者批量更新各个分库中满足条件的流水。我们可以把这些作为一个任务来抽象出来,一个具体的任务由N个子任务组成(N为分库的个数),系统要保证N个子任务要么全部成功,要么全部失败,不允许部分成功。我们可以在方案三的思想上,建立总任务表和子任务表,文件导入的处理只是其中的一个任务类型而已,批量删除,批量更新以及其他类似的操作,都可以当做具体的任务类型。
4 第四种方案就是经典的分布式事务设计中的 两阶段提交思想。两阶段提交的有三个重要的子操作:准备提交,提交,回滚。
继续拿文件导入来举例子,各个分库作为一个事务参与者 , 我们需要设计各个分库的准备提交操作,提交,回滚操作。
准备提交阶段:各个分库可以把要处理的文件明细保存到一张临时表里面,并且记住这一次事务中上下文信息。
提交阶段:把这一次事务上下文中对应的临时表数据同步到对应的明细表中
回滚阶段:删除本次事务相关的临时表流水信息。
通过设计一个两阶段的提交的事务管理器,我们可以在导入文件的时候启动一个分布式事务,生成一个事务上下文(这个上下文信息要保存到数据库里面),然后在调用各个子参与者的时候,需要把这个上下文信息传递下去,分库先进行准备工作(就是保存明细到临时表),如果成功,就返回准备成功。等所有的参与者成功了,事务管理器就提交这个事务,这个分库完成提交动作,把数据从临时表插入到正式表。如果某一个准备操作失败,所有的分库执行回滚操作,删除导入的流水。
这里面最重要的就是,如果某分库准备阶段返回成功,那么提交一定要成功,否则只能做数据订正或者人工处理了。这个是在两阶段中事务中没有办法解决。
对于不同的操作,要设计对应的准备提交,提交,回滚操作,开发量比较大,而且分布式事务管理器的实现也需要一定的功底。
上面四种方案,能够保证完整性和一致性的只有第三种和第四种方案。其实这两种方案的设计思想是一致的。就是通过努力重试以及异步确认进行的。严格的说,第三种方案会有一定的问题,因为在整个处理过程中,只能保证最终一致性,而没有办法保证ACID里面的孤立性。因为存在部分提交的情况,而这一些数据有可能后续会进行回滚。不过可以就第三种方案在进行优化,加上一个锁机制,不过扩展下来就比较复杂了。