分布式事务解决方案汇总:2PC、3PC、消息中间件、TCC、状态机+重试+幂等(转)
数据一致性问题非常多样,下面举一些常见例子。比如在更新数据的时候,先更新了数据库,后更新了缓存,一旦缓存更新失败,此时数据库和缓存数据会不一致。反过来,如果先更新缓存,再更新数据库,一旦缓存更新成功,数据库更新失败,数据还是不一致;
比如数据库中的参照完整性,从表引用了主表的主键,对从表来说,也就是外键。当主表的记录删除后,从表是字段置空,还是级联删除。同样,当要创建从表记录时,主表记录是否要先创建,还是可以直接创建从表的记录;
比如数据库中的原子性:同时修改两条记录,一条记录修改成功了,一条记录没有修改成功,数据就会不一致,此时必须回滚,否则会出现脏数据。
比如数据库的Master-Slave异步复制,Master宕机切换到Slave,导致部分数据丢失,数据会不一致。
发送方发送了消息1、2、3、4、5,因为消息中间件的不稳定,导致丢了消息4,接收方只收到了消息1、2、3、5,发送方和接收方数据会不一致。
从以上案例可以看出,数据一致性问题几乎无处不在。本书把一致性问题分为了两大类:事务一致性和多副本一致性。这两类一致性问题基本涵盖了实践中所遇到的绝大部分场景,本章和下一章将分别针对这两类一致性问题进行详细探讨。
随处可见的分布式事务问题
在“集中式”的架构中,很多系统用的是Oracle这种大型数据库,把整个业务数据放在这样一个强大的数据库里面,利用数据库的参照完整性机制、事务机制,避免出现数据一致性问题。这正是数据库之所以叫“数据库”而不是“存储”的一个重要原因,就是数据库强大的数据一致性保证。
但到了分布式时代,人们对数据库进行了分库分表,同时在上面架起一个个的服务。到了微服务时代,服务的粒度拆得更细,导致一个无法避免的问题:数据库的事务机制不管用了,因为数据库本身只能保证单机事务,对于分布式事务,只能靠业务系统解决。
例如做一个服务,最初底下只有一个数据库,用数据库本身的事务来保证数据一致性。随着数据量增长到一定规模,进行了分库,这时数据库的事务就不管用了,如何保证多个库之间的数据一致性呢?
再以电商系统为例,比如有两个服务,一个是订单服务,背后是订单数据库;一个是库存服务,背后是库存数据库,下订单的时候需要扣库存。无论先创建订单,后扣库存,还是先扣库存,后创建订单,都无法保证两个服务一定会调用成功,如何保证两个服务之间的数据一致性呢?
这样的案例在微服务架构中随处可见:凡是一个业务操作,需要调用多个服务,并且都是写操作的时候,就可能会出现有的服务调用成功,有的服务调用失败,导致只部分数据写入成功,也就出现了服务之间的数据不一致性。
分布式事务解决方案汇总
接下来,以一个典型的分布式事务问题——“转账”为例,详细探讨分布式事务的各种解决方案。
以支付宝为例,要把一笔钱从支付宝的余额转账到余额宝,支付宝的余额在系统A,背后有对应的DB1;余额宝在系统B,背后有对应的DB2;蚂蚁借呗在系统C,背后有对应的DB3,这些系统之间都要支持相关转账。所谓“转账”,就是转出方的系统里面账号要扣钱,转入方的系统里面账号要加钱,如何保证两个操作在两个系统中同时成功呢?
1. 2PC
(1)2PC理论。在讲MySQL Binlog和Redo Log的一致性问题时,已经用到了2PC。当然,那个场景只是内部的分布式事务问题,只涉及单机的两个日志文件之间的数据一致性;2PC是应用在两个数据库或两个系统之间。
2PC有两个角色:事务协调者和事务参与者。具体到数据库的实现来说,每一个数据库就是一个参与者,调用方也就是协调者。2PC是指事务的提交分为两个阶段,如图10-1所示。
阶段1:准备阶段。协调者向各个参与者发起询问,说要执行一个事务,各参与者可能回复YES、NO或超时。
阶段2:提交阶段。如果所有参与者都回复的是YES,则事务协调者向所有参与者发起事务提交操作,即Commit操作,所有参与者各自执行事务,然后发送ACK。
如果有一个参与者回复的是NO,或者超时了,则事务协调者向所有参与者发起事务回滚操作,所有参与者各自回滚事务,然后发送ACK,如图10-2所示。
所以,无论事务提交,还是事务回滚,都是两个阶段。
(2)2PC的实现。通过分析可以发现,要实现2PC,所有参与者都要实现三个接口:Prepare、Commit、Rollback,这也就是XA协议,在Java中对应的接口是javax.transaction.xa.XAResource,通常的数据库也都实现了这个协议。开源的Atomikos也基于该协议提供了2PC的解决方案,有兴趣的读者可以进一步研究。
(3)2PC的问题。2PC在数据库领域非常常见,但它存在几个问题:
问题1:性能问题。在阶段1,锁定资源之后,要等所有节点返回,然后才能一起进入阶段2,不能很好地应对高并发场景。
问题2:阶段1完成之后,如果在阶段2事务协调者宕机,则所有的参与者接收不到Commit或Rollback指令,将处于“悬而不决”状态。
问题3:阶段1完成之后,在阶段2,事务协调者向所有的参与者发送了Commit指令,但其中一个参与者超时或出错了(没有正确返回ACK),则其他参与者提交还是回滚呢? 也不能确定。
为了解决2PC的问题,又引入了3PC。3PC存在类似宕机如何解决的问题,因此还是没能彻底解决问题,
2PC除本身的算法局限外,还有一个使用上的限制,就是它主要用在两个数据库之间(数据库实现了XA协议)。但以支付宝的转账为例,是两个系统之间的转账,而不是底层两个数据库之间直接交互,所以没有办法使用2PC。
不仅支付宝,其他业务场景基本都采用了微服务架构,不会直接在底层的两个业务数据库之间做一致性,而是在两个服务上面实现一致性。
正因为2PC有诸多问题和不便,在实践中一般很少使用。
2. 3PC(三阶段提交)
三阶段提交协议(3PC)主要是为了解决两阶段提交协议的阻塞问题,2pc存在的问题是当协作者崩溃时,参与者不能做出最后的选择。因此参与者可能在协作者恢复之前保持阻塞。三阶段提交(Three-phase commit),是二阶段提交(2PC)的改进版本。
与两阶段提交不同的是,三阶段提交有两个改动点。
也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。
1、CanCommit阶段
之前2PC的一阶段是本地事务执行结束后,最后不Commit,等其它服务都执行结束并返回Yes,由协调者发生commit才真正执行commit。而这里的CanCommit指的是 尝试获取数据库锁 如果可以,就返回Yes。
这阶段主要分为2步
- 事务询问 协调者 向 参与者 发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待 参与者 的响应。
- 响应反馈 参与者 接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No
2、PreCommit阶段
在阶段一中,如果所有的参与者都返回Yes的话,那么就会进入PreCommit阶段进行事务预提交。这里的PreCommit阶段 跟上面的第一阶段是差不多的,只不过这里 协调者和参与者都引入了超时机制 (2PC中只有协调者可以超时,参与者没有超时机制)。
3、DoCommit阶段
这里跟2pc的阶段二是差不多的。
总结
相比较2PC而言,3PC对于协调者(Coordinator)和参与者(Partcipant)都设置了超时时间,而2PC只有协调者才拥有超时机制。这解决了一个什么问题呢?
这个优化点,主要是避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,
自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。
另外,通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的。
以上就是3PC相对于2PC的一个提高(相对缓解了2PC中的前两个问题),但是3PC依然没有完全解决数据不一致的问题。
3. 最终一致性(消息中间件)
一般的思路是通过消息中间件来实现“最终一致性”,如图10-3所示。
系统A收到用户的转账请求,系统A先自己扣钱,也就是更新DB1;然后通过消息中间件给系统B发送一条加钱的消息,系统B收到此消息,对自己的账号进行加钱,也就是更新DB2。
这里面有一个关键的技术问题:
系统A给消息中间件发消息,是一次网络交互;更新DB1,也是一次网络交互。系统A是先更新DB1,后发送消息,还是先发送消息,后更新DB1?
假设先更新DB1成功,发送消息网络失败,重发又失败,怎么办?又假设先发送消息成功,更新DB1失败。消息已经发出去了,又不能撤回,怎么办?或者消息中间件提供了消息撤回的接口,但是又调用失败怎么办?
因为这是两次网络调用,两个操作不是原子的,无论谁先谁后,都是有问题的。
下面来看最终一致性的几种具体实现思路:
a.最终一致性:错误的方案0
有人可能会想,可以把“发送加钱消息”这个网络调用和更新DB1放在同一个事务里面,如果发送消息失败,更新DB自动回滚。这样不就可以保证两个操作的原子性了吗?
这个方案看似正确,其实是错误的,原因有两点:
(1)网络的2将军问题:发送消息失败,发送方并不知道是消息中间件没有收到消息,还是消息已经收到了,只是返回response的时候失败了?
如果已经收到消息了,而发送端认为没有收到,执行update DB的回滚操作,则会导致账户A的钱没有扣,账户B的钱却被加了。
(2)把网络调用放在数据库事务里面,可能会因为网络的延时导致数据库长事务。严重的会阻塞整个数据库,风险很大。
b.最终一致性:第1种实现方式(业务方自己实现)
假设消息中间件没有提供“事务消息”功能,比如用的是Kafka。该如何解决这个问题呢?
消息中间件实现最终一致性示意图如图10-4所示。
(1)系统A增加一张消息表,系统A不再直接给消息中间件发送消息,而是把消息写入到这张消息表中。把DB1的扣钱操作(表1)和写入消息表(表2)这两个操作放在一个数据库事务里,保证两者的原子性。
(2)系统A准备一个后台程序,源源不断地把消息表中的消息传送给消息中间件。如果失败了,也不断尝试重传。因为网络的2将军问题,系统A发送给消息中间件的消息网络超时了,消息中间件可能已经收到了消息,也可能没有收到。系统A会再次发送该消息,直到消息中间件返回成功。所以,系统A允许消息重复,但消息不会丢失,顺序也不会打乱。
(3)通过上面的两个步骤,系统A保证了消息不丢失,但消息可能重复。系统B对消息的消费要解决下面两个问题:
问题1:丢失消费。系统B从消息中间件取出消息(此时还在内存里面),如果处理了一半,系统B宕机并再次重启,此时这条消息未处理成功,怎么办?
答案是通过消息中间件的ACK机制,凡是发送ACK的消息,系统B重启之后消息中间件不会再次推送;凡是没有发送ACK的消息,系统B重启之后消息中间件会再次推送。
但这又会引发一个新问题,就是下面问题2的重复消费:即使系统B把消息处理成功了,但是正要发送ACK的时候宕机了,消息中间件以为这条消息没有处理成功,系统B再次重启的时候又会收到这条消息,系统B就会重复消费这条消息(对应加钱类的场景,账号里面的钱就会加两次)
问题2:重复消费。除了ACK机制,可能会引起重复消费;系统A的后台任务也可能给消息中间件重复发送消息。
为了解决重复消息的问题,系统B增加一个判重表。判重表记录了处理成功的消息ID和消息中间件对应的offset(以Kafka为例),系统B宕机重启,可以定位到offset位置,从这之后开始继续消费。
每次接收到新消息,先通过判重表进行判重,实现业务的幂等。同样,对DB2的加钱操作和消息写入判重表两个操作,要在一个DB的事务里面完成。
这里要补充的是,消息的判重不止判重表一种方法。如果业务本身就有业务数据,可以判断出消息是否重复了,就不需要判重表了。
通过上面三步,实现了消息在发送方的不丢失、在接收方的不重复,联合起来就是消息的不漏不重,严格实现了系统A和系统B的最终一致性。
但这种方案有一个缺点:系统A需要增加消息表,同时还需要一个后台任务,不断扫描此消息表,会导致消息的处理和业务逻辑耦合,额外增加业务方的开发负担。
c.最终一致性:第二种实现方式(基于RocketMQ事务消息)
为了能通过消息中间件解决该问题,同时又不和业务耦合,RocketMQ提出了“事务消息”的概念,如图10-5所示。
RocketMQ不是提供一个单一的“发送”接口,而是把消息的发送拆成了两个阶段,Prepare阶段(消息预发送)和Confirm阶段(确认发送)。具体使用方法如下:
步骤1:系统A调用Prepare接口,预发送消息。此时消息保存在消息中间件里,但消息中间件不会把消息给消费方消费,消息只是暂存在那。
步骤2:系统A更新数据库,进行扣钱操作。
步骤3:系统A调用Comfirm接口,确认发送消息。此时消息中间件才会把消息给消费方进行消费。
显然,这里有两种异常场景:
场景1:步骤1成功,步骤2成功,步骤3失败或超时,怎么处理?
场景2:步骤1成功,步骤2失败或超时,步骤3不会执行。怎么处理?
这就涉及RocketMQ的关键点:RocketMQ会定期(默认是1min)扫描所有的预发送但还没有确认的消息,回调给发送方,询问这条消息是要发出去,还是取消。发送方根据自己的业务数据,知道这条消息是应该发出去(DB更新成功了),还是应该取消(DB更新失败)。
对比最终一致性的两种实现方案会发现,RocketMQ最大的改变其实是把“扫描消息表”这件事不让业务方做,而是让消息中间件完成。
至于消息表,其实还是没有省掉。因为消息中间件要询问发送方事物是否执行成功,还需要一个“变相的本地消息表”,记录事务执行状态和消息发送状态。
同时对于消费方,还是没有解决系统重启可能导致的重复消费问题,这只能由消费方解决。需要设计判重机制,实现消息消费的幂等。
d.人工介入
无论方案1,还是方案2,发送端把消息成功放入了队列中,但如果消费端消费失败怎么办?
如果消费失败了,则可以重试,但还一直失败怎么办?是否要自动回滚整个流程?
答案是人工介入。从工程实践角度来讲,这种整个流程自动回滚的代价是非常巨大的,不但实现起来很复杂,还会引入新的问题。比如自动回滚失败,又如何处理?
对应这种发生概率极低的事件,采取人工处理会比实现一个高复杂的自动化回滚系统更加可靠,也更加简单。
4. TCC
说起分布式事务的概念,不少人都会搞混淆,似乎好像分布式事务就是TCC。实际上TCC与2PC、3PC一样,只是分布式事务的一种实现方案而已。
TCC(Try-Confirm-Cancel)又称补偿事务。其核心思想是:"针对每个操作都要注册一个与其对应的确认和补偿(撤销操作)"。它分为三个操作:
Try阶段:主要是对业务系统做检测及资源预留。
Confirm阶段:确认执行业务操作。
Cancel阶段:取消执行业务操作。
2PC通常用来解决两个数据库之间的分布式事务问题,比较局限。现在企业采用的是各式各样的SOA服务,更需要解决两个服务之间的分布式事务问题。
为了解决SOA系统中的分布式事务问题,支付宝提出了TCC。TCC是Try、Confirm、Cancel三个单词的缩写,其实是一个应用层面的2PC协议,Confirm对应2PC中的事务提交操作,Cancel对应2PC中的事务回滚操作,如图10-6所示。
(1)准备阶段:调用方调用所有服务方提供的Try接口,该阶段各调用方做资源检查和资源锁定,为接下来的阶段2做准备。
(2)提交阶段:如果所有服务方都返回YES,则进入提交阶段,调用方调用各服务方的Confirm接口,各服务方进行事务提交。如果有一个服务方在阶段1返回NO或者超时了,则调用方调用各服务方的Cancel接口,如图10-7所示。
这里有一个关键问题:TCC既然也借鉴2PC的思路,那么它是如何解决2PC的问题的呢?也就是说,在阶段2,调用方发生宕机,或者某个服务超时了,如何处理呢?
答案是:不断重试!不管是Confirm失败了,还是Cancel失败了,都不断重试。这就要求Confirm和Cancel都必须是幂等操作。注意,这里的重试是由TCC的框架来执行的,而不是让业务方自己去做。
下面以一个转账的事件为例,来说明TCC的过程。假设有三个账号A、B、C,通过SOA提供的转账服务操作。A、B同时分别要向C转30元、50元,最后C的账号+80元,A、B各减30元、50元。
阶段1:分别对账号A、B、C执行Try操作,A、B、C三个账号在三个不同的SOA服务里面,也就是分别调用三个服务的Try接口。具体来说,就是账号A锁定30元,账号B锁定50元,检查账号C的合法性,比如账号C是否违法被冻结,账号C是否已注销。
所以,在这个场景里面,对应的“扣钱”的Try操作就是“锁定”,对应的“加钱”的Try操作就是检查账号合法性,为的是保证接下来的阶段2扣钱可扣、加钱可加!
阶段2:A、B、C的Try操作都成功,执行Confirm操作,即分别调用三个SOA服务的Confirm接口。A、B扣钱,C加钱。如果任意一个失败,则不断重试,直到成功为止。
从案例可以看出,Try操作主要是为了“保证业务操作的前置条件都得到满足”,然后在Confirm阶段,因为前置条件都满足了,所以可以不断重试保证成功。
TCC事务的处理流程与2PC两阶段提交类似,不过2PC通常都是在跨库的DB层面,而TCC本质上就是一个应用层面的2PC,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据库操作的粒度,使得降低锁冲突、提高吞吐量成为可能。
而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口还必须实现幂等。
TCC的具体原理图如
最新文章
- 第12天 android studio
- Android学习一:文件操作
- 深入学习golang(4)—new与make
- POJ 1984 Navigation Nightmare
- 《view programming guide for iOS 》之可以使用动画效果的属性
- Data Base MySQL的常用命令
- POJ 3159 Candies 差分约束dij
- Mac下Shell快捷键
- Maven自定义Archetype
- Android模拟器的文件目录介绍
- Atitit.web三编程模型 Web Page Web Forms 和 MVC
- 测试redis+keepalived实现简单的主备切换【转载】
- 异常解决:Caused by: com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure
- 关于linux下的文件权限
- docker 常用命令记录
- 利用rman duplicate重建oracle dataguard standby数据库
- python-django(创建项目、应用、运行)
- @property和@score.setter的用法
- TZOJ 4712 Double Shortest Paths(最小费用最大流)
- Python实现屏幕截图的两种方式