分布式事务一些总结与思考

时间:2022-09-05 11:12:17

       对于程序员,事务等价于Transaction,是指一组连续的操作,这些操作组合成一个逻辑的、完整的操作。即这组操作执行前后,系统需要处于一个可预知的、一致的状态。因此,这一组操作要么都成功执行,要么都不能执行;如果部分成功,部分失败,成功的部分需要回滚(rollback)。


关系型数据库事务

大多数人可能和我一样,第一次听说事务是在学习关系型数据库(mysql、sql server、Oracle)的时候,在关系型数据库中,如果一组操作满足ACID特性,那么称之为一个事务。

  • A(Atomic):原子性,构成事务的所有操作,要么都执行完成,要么全部不执行,不可能出现部分成功部分失败的情况

  • C(Consistency):一致性,在事务执行前后,数据库的一致性约束没有被破坏。这里的一致性含义后面会详细解释

  • I(Isolation):隔离性,数据库中的事务一般都是并发的,隔离性是指并发的两个事务的执行互不干扰,一个事务不能看到其他事务运行过程的中间状态

  • D(Durability):持久性,事务完成之后,该事务对数据的更改会被持久化到数据库,且不会被回滚。


我们举一个简单的转账的例子,用户A给玩家B转100块钱,那么涉及到两个操作:玩家A的账户扣100元,玩家B的账户加100元。即

UserA.account -= 100
UserB.account += 100

原子性很好理解,这两个操作要么都成功,要么都不执行(更准确的是从效果上来看等价于都没有执行)。不可能出现用户A的钱减少了而用户B的钱没增加的情况,用户是不允许的;更不可能出现用户B的钱增加 而 用户A的钱没有减少的情况,银行是绝对不干的。


一致性是指完整性约束不被破坏,完整性包含实体完整性(主属性不为空)、参照完整性(外键必须存在原表中)、用户自定义的完整性。用户自定义的完整性比如列值非空(not null)、列值唯一(unique)、列值是否满足一个bool表达式(check语句,如性别只能有两个值、岁数是一定范围内的整数等),例如age smallint CHECK (age >=0 AND age <= 120).数据库保证age的值在[0, 120]的范围,如果不在这个范文,那么更新操作失败,事务也会失败。另外,向mysql中的cascade,以及触发器(trigger)都属于用户自定义的完整性约束。

因此,用户A,B在这次事务操作前后,账户的总和一定,是应用层面的一致性,而不是数据库保证的一致性,应用层面的一致性事实上是由原子性来保证的。


隔离性说起来简单,但事实上背后的事情很复杂,数据库的隔离性依赖于加锁或者多版本控制。简单来说,如果UserA.account初始值为500,执行完第一条指令(即减去100),但事务还没有提交,其他的事务是不能读到这个中间结果(UserA.account的值为400)的。这就是避免了脏读(Drity Read),对应的隔离级别就是READ_COMMITTED。在SQL标准中,定义了四个隔离级别:

  READ_UNCOMMITTED
  READ_COMMITTED
  REPEATABLE_READ
  SERIALIZABLE

来解决事务并发中带来的一下几个问题脏读(Dirty Read)、不可重复读(Non-repeatable Read)、幻读(Phantom Read)

不同的数据库或者说存储引擎默认支持不同的隔离级别,比如InnoDB存储引擎默认支持REPEATABLE_READ,而Mongodb只支持READ_UNCOMMITTED


持久性需要考虑到一个事务在执行过程中的各种情况的异常。一个事务的流程是这样的:

开启一个事务
执行一组操作
如果都执行成功,那么提交并结束事务
如果任何操作失败,那么回滚已经执行的操作,结束事务

在事务执行过程中,如果出现故障,比如断电、宕机,这个时候就要利用日志(redo log或者undo log) 加上 checkpoint来保证事务的完整结束。


分布式事务

当数据的规模越来越大,超出了单个关系型数据库的处理能力,这个时候就出现了关系型数据的垂直分表或者水平分表,也出现了天然支持水平扩展(sharding)的NoSql。另外,大型网站的服务化(SOA)以及这两年非常火的微服务,往往将服务进行拆分,单独部署,自然也使用独立的数据库,甚至是异构的数据库。这个时候,关系型数据库保证事务的手段,比如加锁、日志就行不通了。当然,本文讨论的不仅仅是数据库,也包含分布式存储、消息队列,以及任何要保证原子性、持久性的逻辑。


分布式事务的最大挑战在于CAP。简而言之,由于网络分割(P: Network Partition)的存在,用户不得不在一致性(C Consistency)与可用性(A: Avaliable)之前做权衡。如果要保证强一致性(主要是应用层面的强一致性),那么在网络分割的时候,系统就不可用;如果要保证高可用性,那么就只能提供弱一致性,保证最终一致。下面提到的各种实现分布式事务的方法、协议都需要在一致性与可用性之间权衡。

2PC

提到分布式事务,首先想到的肯定是两阶段提交(2pc, two-phase commit protocol),2pc是非常经典的强一致性中心化的原子提交协议。中心化是指协议中有两类节点:一个中心化协调者节点(coordinator)和N个参与者节点(participant、cohort)。


顾名思义,两阶段提交协议的每一次事务提交分为两个阶段:


在第一阶段,协调者询问所有的参与者是否可以提交事务(请参与者投票),所有参与者向协调者投票。


在第二阶段,协调者根据所有参与者的投票结果做出是否事务可以全局提交的决定,并通知所有的参与者执行该决定。在一个两阶段提交流程中,参与者不能改变自己的投票结果。两阶段提交协议的可以全局提交的前提是所有的参与者都同意提交事务,只要有一个参与者投票选择放弃(abort)事务,则事务必须被放弃。

两阶段提交协议也依赖与日志,只要存储介质不出问题,两阶段协议就能最终达到一致的状态(成功或者回滚)


2PC的优缺点:

  • 优点:强一致性,只要节点或者网络最终恢复正常,协议就能保证顺利结束;部分关系型数据库(Oracle)、框架直接支持

  • 缺点:两阶段提交协议的容错能力较差,比如在节点宕机或者超时的情况下,无法确定流程的状态,只能不断重试;两阶段提交协议的性能较差, 消息交互多,且受最慢节点影响

3PC

三阶段提交协议(3pc Three-phase_commit_protocol)主要是为了解决两阶段提交协议的阻塞问题,从原来的两个阶段扩展为三个阶段,并且增加了超时机制。


分布式事务一些总结与思考

3PC只是解决了在异常情况下2PC的阻塞问题,但导致一次提交要传递6条消息,延时很大。


TCC

TCC是Try、Commit、Cancel的缩写,在国内由于支付宝的布道(http://www.sohu.com/a/124709543_468650%20)而广为人知,TCC在保证强一致性的同时,最大限度提高系统的可伸缩性与可用性


我们假设一个完整的业务包含一组子业务,Try操作完成所有的子业务检查,预留必要的业务资源,实现与其他事务的隔离;Confirm使用Try阶段预留的业务资源真正执行业务,而且Confirm操作满足幂等性,以便支持重试;Cancel操作释放Try阶段预留的业务资源,同样也满足幂等性。“一次完整的交易由一系列微交易的Try 操作组成,如果所有的Try 操作都成功,最终由微交易框架来统一Confirm,否则统一Cancel,从而实现了类似经典两阶段提交协议(2PC)的强一致性。”

与2PC协议比较 ,TCC拥有以下特点:

位于业务服务层而非资源层 ,由业务层保证原子性

没有单独的准备(Prepare)阶段,降低了提交协议的成本

Try操作 兼备资源操作与准备能力 

Try操作可以灵活选择业务资源的锁定粒度,而不是锁住整个资源,提高了并发度

当然,TCC需要较高的开发成本,每个子业务都需要有响应的comfirm、Cancel操作,即实现相应的补偿逻辑。


基于消息的分布式事务

这类事务机制将分布式事务分成多个本地事务,这里称之为主事务与从事务。首先主事务本地先行提交,然后通过消息通知从事务,从事务从消息中获取信息进行本地提交。可以看出这是一种异步事务机制、只能保证最终一致性;但可用性非常高,不会因为故障而发生阻塞。另外,主事务已经先行提交,如果因为从事务无法提交,要回滚主事务还是比较麻烦,所以这种模式只适用于理论上大概率等成功的业务情况,即从事务的提交失败可能是由于故障,而不大可能是逻辑错误。


基于异步消息的事务机制主要有两种方式:本地消息表与事务消息。二者的区别在于:怎么保证主事务的提交与消息发送这两个操作的原子性。


如果用异步消息实现转账的例子,那么操作分为四部:用户A扣钱,发消息,用户B收消息,用户B扣钱。前两步必须保证原子性,如果A扣钱成功但是没有发出消息,那么用户A损失了;如果发消息成功,但是没有扣钱,那么用户B就多得了一笔钱,银行肯定不干。

本地消息表

基于本地消息表的方案是指将消息写入本地数据库,通过本地事务保证主事务与消息写入的原子性。例如银行转账的例子,伪码如下:

 begin transaction:

  update User set account = account - 100 where userId = ‘A’
  insert into message(userId, amount, status) values(‘A’, 100, 1)

commit transaction

然后通过pull或者push模式,从业务获取消息并执行。如果是push模式,那么一般使用具有持久化功能的消息队列,从事务订阅消息。如果是pull模式,那么从事务定时去拉取消息,然后执行。


MongoDB的写入就很像本地消息表,在WriteConcern为w:1的情况下,更新操作只要写到oplog以及primary就可以向客户端返回。secondary异步拉取oplog并本地记录执行。


事务消息

事务消息依赖于支持“事务消息”的消息队列,其基本思想是 利用消息中间件实施两阶段提交,将本地事务和发消息放在了一个分布式事务里,保证要么本地操作成功并且对外发消息成功,要么两者都失败。流程如下:

主事务向消息队列发送预备消息

主事务收到ACK之后本地执行主事务

根据执行的结果(成功或失败)向消息队列发送提交或者回滚消息

详细的流程如下图所示:


  分布式事务一些总结与思考


不难看到,相比本地消息表的方式,事务消息由消息中间件保证本地事务与消息的原子性,不依赖于本地数据库存储消息。但实现了“事务消息”的消息队列比较少,还不够通用。

 

不管是本地消息表还是事务消息,都需要保证从事务执行且仅执行一次,exact once。如果失败,需要重试,但也不可能无限次的重试,当从事务最终失败的情况下,需要通知主业务回滚吗?但是此时,主事务已经提交,因此只能通过补偿,实现逻辑上的回滚,而当前时间点距主事务的提交已经有一定时间,回滚也可能失败。因此,最好是保证从事务逻辑上不会失败,万一失败,记录log并报警,人工介入。


思考与总结

更多的时候,分布式事务只需要保证原子性,这个原子性也保证了应用层面上的一致性,而由本地事务来保证隔离性、持久性。


原子性这个东西,即使不是分布式,仅仅是单进程单线程也是需要考虑的,这就是C++中的RAII,python中的with statement,以及各种语言的try…finally…。当涉及到跨进程、异步通信的时候,就很难通过语言层面的机制保证原子性了。


在分布式领域,由于网络或者机器故障,经常需要重试,因此幂等性非常重要


很多场景,比如电商、网络购票,首先要保证的是高可用,不大可能采用强一致性,因此我们也会看到‘正在处理中…‘这种中间状态,后台很可能是异步处理的,在12306买过票的话都知道,下单成功到最后是否能出票由很长一段时间。


大部分业务领域,并没有涉及到强一致性的场景,只要最终一致性就行了。上面的提到的各种办法,不管是2PC、TCC、本地消息表、事务消息,都需要引入额外的框架或者组件。所以更多的时候是采取业务补偿的方式,比如一个涉及两个进程的操作需要保证原子性,进程间RPC通信,那么一般是A进程先执行,然后RPC调用B进程接口,根据B进程的返回结果,绝对是否回滚(补偿);但如果涉及到异步RPC、或者多线程、或者两个以上进程的串联时,那么就不一定能补偿、甚至很难补偿了,这个时候只记录一个error log,然后通知人工排查。因此,事务补偿只适合业务比较简单的常见,而且很难形成通用的框架,或者说实用性不强。