vlambda博客
学习文章列表

分布式事务思考(一)

今天思考了一下分布式事务里边常见的两阶段和三阶段算法,这里稍微记录一下自己的一点点思考。进入分布式系统之后,系统需要在多个节点上进行数据的事务操作,这就面临着常见的所谓的网络故障,机器故障等等一系列挑战。分布式事务有一种特殊的场景,就是一致性kv场景。但是一致性kv的场景可以根据自己的业务特点有特殊的解法,例如一致性读之类的算法,同时也演化出了如raft,paxos,zab之类的算法,算是解决了一种特殊的分布式事务场景,在这里就不描述了。

问题场景

   通常的分布式事务,在提交时,主要面临的是两个方面的挑战:
  1. 业务资源不足
  2. 机器故障/并发问题
  举最简单的例子,一个应用程序,需要扣除数据库A中AA表10元钱,同时也要增加数据库B中BB表10元钱。不做任何分布式事务的管控,就是依次(并发)向两个db分别发送两个单机事务:

  

A: begin   select balance from AA where id=xxxxxx for update   /*增加业务代码逻辑判断,判断balance里有超过10元钱,根据结果进行commit或者rollback    */  commit/rollbackB: begin      update BB set balance=balance+10 where id=xxxxxx   commit
      这里最常见的到是AA表里的账户余额不足问题,进而引起回滚操作。在整个过程中还会伴随数据不一致的情况。这个就是不引入分布式事务的场景下,同时修改多个库会遇到的问题。

两阶段

        两阶段算法,我个人理解解决的主要是业务资源不足,以及由业务资源不足产生的数据不一致问题。主要就是通过引入一个“准备阶段”来解决的,在“准备阶段”会判断所谓的业务资源是否充足,如果不充足就不会进行commit,这样就减少了上文描述的部分数据库需要回滚的场景,也降低了数据不一致的问题。解决了业务资源不足的问题,就要处理机器故障/并发场景带来的问题,注意我这里用的是“处理”,而不是解决,因为例如短时间内的数据不一致是不可解决的,只要存在最终的一个“commit”,就会有数据不一致的窗口。并发带来的影响,只能降低,不能解决。一般认为两阶段算法会遗留三个问题:
      1. 阻塞场景,这个问题其实是无解的。整个提交只要会分阶段,那么就会阻塞到“commit”请求到。这是个伪问题,分布式事务就是会减低系统整体的吞吐。
      2.单点故障,这里主要说的是协调者会在commit命令过程中发生了故障。导致部分节点commit,部分阻塞。这是一个主要问题,也是三阶段较之两阶段的主要改进点。至于在commit命令前故障,也只是会造成参与者阻塞,降低系统吞吐,引起回滚,不算问题。
      3. 数据不一致问题,就是commit报文到达各参与者时间有先后,这个也不算问题。因为这个是单纯靠分布式事务无法解决的。

三阶段

       三阶段较之两阶段基本没解决什么问题场景,唯一解决的就是协调者在commit命令过程中故障,引起部分节点commit,部分超时rollback。也就是这里会引起状态不一致,事务部分成功/部分失败。所以就引入了三阶段,但是三阶段是怎么解决的呢,就是在can和commit中间,再引入了一个中间操作,姑且叫他prepare操作。以参与者收到prepare为界,prepare之前协调者单点故障了,操作超时回滚,收到prepare之后,操作超时提交(这里翻了很多资料,没看到明确的说明在收到prepare会改变超时默认操作(提交或回滚),但总不能不改变吧,不然要这个操作干啥呢,又不能降低阻塞时间。)。算法设计者认为这样可以降低单点故障。。。。我个人觉得是纯属扯淡,因为在发送prepare操作过程中,也是可能会故障的,这个估计也是为什么三阶段协议不流行的原因。分布式一致性,靠增加确认阶段来解决问题,不是正确的方法,这也是为什么说拜占庭问题是无解问题。至于某些资料写的,3pc的第一个阶段不进行for update/update的锁定资源,等到prepare阶段再锁定的方式,明显非常不靠谱。不锁定资源的话,这个阶段能有什么意义呢?如果can之后,prepare之前,资源又变了呢?这不是一个可行的方案

TCC

       不管对于两阶段/三阶段来说,真正的吐槽,或者需要解决的问题主要其实就两个:
      1. 算法阻塞时间过长,因为主要就是调用for update/update加记录锁/间隙锁,阻塞了操作,降低吞吐
      2. 协调者的故障会引起“确认”操作分割,部分收到/部分没收到,引起状态不一致(转账:接收方收到钱,转出方没扣除)。
  分析到这里,可以看到,单纯依靠“改进消息确认”可靠性的方式或者说单纯在系统层面进行改进,是没什么效果空间的了。需要通过一些业务方面的配合才能更好的处理分布式事务(注意:也不是解决,只是处理,只能提升,不能解决)。tcc呢,可以认为是一个变种的两阶段,差别主要是try阶段,是一些业务自定义的操作。业务需要向外暴露出try/commit/rollback接口给tm使用。tcc期望这些接口可以降低对数据库的阻塞时间,注意这个“期望”二字,这个是依赖业务本身模型和程序员功底的。它没有提供一种通用的解决方式来处理分布式事务,tcc只是一种解决问题思路,但不是完整方案。把它和2pc/3pc放在一起是不合适的,但它确实是一种思路,就是不要在系统层面,而是在业务层面去解决分布式事务。在实现tcc时候,要注意降低对数据库的阻塞,不然和2pc就没什么区别了。

解决单点故障

   为了解决协调者的单点故障,是不是这里需要把协调者单独抽出来作为一个组件存在(XA协议,协调者和应用服务是同一个程序)。再增加分布式一致性协议来保存事务推进的状态。同时,使用分布式kv的一致性读操作,来保证读到的是最新的数据。这样一番操作之后,单点故障引起的状态不一致就是解决了,当然带来的代价在故障场景下,会有比较大的状态不一致时间窗口。这样对于上述的转账问题,结合2pc,可以归结为以下流程:
  1. 协调者主节点在集群中记录该事务,包括各个单机事务,以及该分布式事务的状态为“init”
  2. 协调者主节点向A.AA表,以及B.BB表,发带有for update/update的语句,但是不提交
      3. 参与者返回资源锁定状态,这里存在协调者也要进行账户是否充足检查的场景。如果通过,在集群中记录该事务状态为“can”,如果不通过记录为“try_rollback”
      4. 在以上场景中,是否使用协调者集群管理事务状态都还没有区别。区别主要是向参与者发送“commit”命令的场景
      5. 在集群确认为“can”状态后,向参与者发送“commit”命令,这里存在给部分参与者发送完"commit"后,协调者主节点故障的场景。
      6. 协调者集群重新选举主节点,主节点重新向全部参与者发送"commit"报文,这里因为参与者重新commit是有幂等性,不受影响。未进行"commit"的节点,继续推进事务,完成事务
  7. 协调者在收到全部参与者的确认报文后,确认该分布式事务成功,否则一直重试,直至该参与者节点返回成功。同时,修改状态为“commit”
  8. 如果在can之后,commit之前,协调者主节点有任何故障,都重新提交全部单机事务给参与者。
可以看到,引入有一致性读的分布式协调者集群之后,系统单点故障引起的状态不一致是解决了,但是整个请求非常繁琐,非常大的降低了系统的吞吐量,各种阻塞操作。当然,这个协调者集群,也演化成了hadoop里的zookeeper,演化成了etcd。此外,发现单纯从系统层面解决分布式事务的尝试,终是有尽头,所以也引入了TCC,在业务层面尝试解决分布式事务的原因。

总结

   分布式事务首先解决的是业务资源不足导致的问题,在引入了2pc之后,业务资源不足的问题解决了,但是遗留了:1.阻塞时间过长,减低了系统吞吐;2. 单点故障,导致系统状态不一致两个问题。为了解决这两个问题,引入了类似3pc的算法,但是3pc其实也并没有改善上边两个场景(据说可以降低阻塞时间,但我没看出来),所以这个算法并不流行。为了解决单点问题,继而将协调者单独拆出,形成一个协调者集群,也就是分布式事务管理器集群,来解决单点故障引起的问题。但这又极大的降低了系统的吞吐量,使得系统整体的瓶颈点从数据库演变成了事务管理器(还没办法通过增加机器来增加服务能力)。这个协调者集群也变成了zk/etcd。协调者集群之外,还引入了类似tcc,在业务层面进行操作的方法,但终归不是一个完整的方案,需要增加大量的业务开发,以及对程序员的功底也有要求。那有什么方式,能很好的解决单点故障,同时又不会阻塞系统过长时间呢,我这看了很多资料也没有很明确的结论,后边再仔细分析一下基于消息中间件,seata,tidb的percolate的方式,看看有没有什么思路