1.问题背景
最近项目中遇到一个场景。
为了减少单库的数据量,系统采用了分库的方式,分为1个主库和N个分库。
现在,在分库中的A表,需要收敛成一个汇总的数据,并写入主库中的B表。需要保证分库更改A表的处理状态和插入主库B表两个动作具有原子性,那么,这就涉及到了跨库的分布式事务的一致性问题。
经过一番学习了解,由于该场景是采用定时任务的方式完成,不要求实时的强一致性,最后参考了本地消息表的方式,保证事务的最终一致性。
2.本地消息表
可以通过这篇文章了解一下分布式事务,包括本地消息表。
摘取其中关于本地消息表的案例讲解。
本地消息表这个方案最初是ebay提出的 ebay的完整方案https://queue.acm.org/detail.cfm?id=1394128。
此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。
对于本地消息队列来说核心是把大事务转变为小事务。还是举上面用100元去买一瓶水的例子。
1.当你扣钱的时候,你需要在你扣钱的服务器上新增加一个本地消息表,你需要把你扣钱和写入减去水的库存到本地消息表放入同一个事务(依靠数据库本地事务保证一致性。
2.这个时候有个定时任务去轮询这个本地事务表,把没有发送的消息,扔给商品库存服务器,叫他减去水的库存,到达商品服务器之后这个时候得先写入这个服务器的事务表,然后进行扣减,扣减成功后,更新事务表中的状态。
3.商品服务器通过定时任务扫描消息表或者直接通知扣钱服务器,扣钱服务器本地消息表进行状态更新。
4.针对一些异常情况,定时扫描未成功处理的消息,进行重新发送,在商品服务器接到消息之后,首先判断是否是重复的,如果已经接收,在判断是否执行,如果执行在马上又进行通知事务,如果未执行,需要重新执行需要由业务保证幂等,也就是不会多扣一瓶水。
本地消息队列是BASE理论,是最终一致模型,适用于对一致性要求不高的。实现这个模型时需要注意重试的幂等。
3.解决方案
在本地消息表方案的基础上,本案例可以再简化。
本案例写入分库和主库的操作,是在同一个定时任务里,没有使用消息中间件异步解耦,因此,可以把上文图中的kafka省略。或者理解为,定时任务已经起到了kafka的作用,在分库写完消息后,就把消息通知了主库。
因此可以得出如下方案:
第1步,在分库中更新A表数据的状态为处理中(类比上图中的写业务数据),并在分库中的未提交日志表写入一条记录(类比上图中的写消息数据),要求开启事务,保证原子性;
第2步,在主库中插入或更新B表的数据(类比上图中的写业务数据),并在主库记录A表数据ID和B表数据ID的关系,用于后续判断事务成功与否。本步骤是同一个定时任务的操作,因此省略了消息中间件传递消息的环节;
第3步,在分库中更新A表的数据状态为处理成功,并将对应的分库的未提交日志表删除。
以上3步对应第2大点案例的前3步,都需要开启事务保证原子性。
那么对于异常的场景,需要有一个事务协调器进行事后问题的处理。本例采用一个补偿定时任务作为这个协调器。
补偿任务会扫描分库中的未提交日志表,若表为空,说明事务要么不存在,要么是成功的,不用处理;否则表示有事务异常的场景,需要细化分析是在哪一步失败了。
根据分库未提交日志表的信息,在主库查找A表数据ID和B表数据ID的关系,以该关系是否正常写入作为事务成功或失败的标识。若存在,说明事务是成功的,是在第3步失败了,那么重做第3步即可;否则,说明事务是失败的,可以选择回滚或者重新发起事务(即重做第2和第3步。本例因为是简单的收敛,不考虑回滚,所以是重新发起事务)。
整体流程图如下:
4.扩展思考
进一步思考,我发现这个方案与MySql InnoDB的bin log和redo log的写入相似,其采用两阶段提交的方式,即2PC。
第一阶段,写入redo log并置于prepare阶段,不写bin log;
第二阶段,即commit阶段,redo log和bin log同时提交。
在故障恢复过程中,以bin log是否完整写入为标准,判定事务是否真正提交,并进行对应的提交或回滚操作。
之所以我觉得两个方案相似,是因为可以这样看:把第1步看作是prepare阶段,把第2和第3步看是commit阶段,在故障恢复中以其中一个作为事务成功判断标识(关系表 vs bin log完整写入)。
不知道这样的理解是否正确,还望大牛不吝赐教。