技术开发 频道

从银行转账失败到分布式事务:总结与思考

  【IT168 技术】思考这个问题的初衷,是有一次给朋友转账,结果我的钱被扣了,朋友没收到钱。而我之前一直认为银行转账一定是由事务保证强一致性的,于是学习、总结了一下分布式事务的各种理论、方法。

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

  关系型数据库事务

  大多数人可能和我一样,第一次听说事务是在学习关系型数据库(mysql、sql server、Oracle)的时候,在关系型数据库中,如果一组操作满足ACID特性,那么称之为一个事务。关于关系型数据库的ACID特性,不管是教材还是网络上都有大量的资料,这里只简单介绍。

  A(Atomic):原子性,构成事务的所有操作,要么都执行完成,要么全部不执行,不可能出现部分成功部分失败的情况 C(Consistency):一致性,在事务执行前后,数据库的一致性约束没有被破坏。这里的一致性含义后面会详细解释 I(Isolation):隔离性,数据库中的事务一般都是并发的,隔离性是指并发的两个事务的执行互不干扰,一个事务不能看到其他事务运行过程的中间状态 D(Durability):持久性,事务完成之后,该事务对数据的更改会被持久化到数据库,且不会被回滚。

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

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

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

  一致性说一起来大家都懂,但是深究起来也是似懂非懂。ACID中的一致性,网络上的介绍都很模糊,都是说要处于一致的状态,那什么是一致的状态呢,比如转账操作中,A扣钱,B加钱,AB的钱的综合是一定的,这个是否属于ACID中的Consistency呢?我觉得不是的,Wiki Transaction_processing和Wiki: ACID分别是这么描述的

  Consistency: A transaction is a correct transformation of the state. The actions taken as a group do not violate any of the integrity constraints associated with the state.

  The consistency property ensures that any transaction will bring the database from one valid state to another. Any data written to the database must be valid according to all defined rules, including constraints, cascades, triggers, and any combination thereof. This does not guarantee correctness of the transaction in all ways the application programmer might have wanted (that is the responsibility of application-level code), but merely that any programming errors cannot result in the violation of any defined rules.

  上面黑色加粗的部分指出,ACID中的一致性是指完整性约束不被破坏,完整性包含实体完整性(主属性不为空)、参照完整性(外键必须存在原表中)、用户自定义的完整性。用户自定义的完整性比如列值非空(not null)、列值唯一(unique)、列值是否满足一个bool表达式(check语句,如性别只能有两个值、岁数是一定范围内的整数等),例如age smallint CHECK (age >=0 AND age <= 120).数据库保证age的值在[0, 120]的范围,如果不在这个范文,那么更新操作失败,事务也会失败。另外,向mysql中的cascade,以及触发器(trigger)都属于用户自定义的完整性约束。在MongoDB3.2中document validation就是用户自定义的完整性约束,在插入或者更新docuemnt的时候检查,不过用户可以自行设定validationAction,确定当数据不符合约束时的表现,默认为error,即拒绝数据写操作。

  因此,用户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,在《CAP理论与MongoDB一致性、可用性的一些思考》一文中有详细介绍。简而言之,由于网络分割(P: Network Partition)的存在,用户不得不在一致性(C Consistency)与可用性(A: Avaliable)之前做权衡。如果要保证强一致性(主要是应用层面的强一致性),那么在网络分割的时候,系统就不可用;如果要保证高可用性,那么就只能提供弱一致性,保证最终一致。下面提到的各种实现分布式事务的方法、协议都需要在一致性与可用性之间权衡。

  2PC

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

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

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

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

  wiki上给出了简要流程:

从银行转账失败到分布式事务:总结与思考

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

  而下图(来自slideshare)详细描述了整个流程:

从银行转账失败到分布式事务:总结与思考

  在刘杰的《分布式原理介绍中》,有非常详细的流程介绍,可以配合上图一起看,另外还介绍了在各种异常情况下(比如Coordinator、Participant宕机,网络分割导致的超时)两阶段协议的工作情况、工作效率。另外,在这篇文章中也有比较清晰的流程介绍。在这里只讨论2PC的优缺点:

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

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

  这篇文章描述了为什么两阶段提交协议在分布式系统中不适用:

  系统“水平”伸缩的死敌。基于两阶段提交的分布式事务在提交事务时需要在多个节点之间进行协调,最大限度地推后了提交事务的时间点,客观上延长了事务的执行时间,这会导致事务在访问共享资源时发生冲突和死锁的概率增高,随着数据库节点的增多,这种趋势会越来越严重,从而成为系统在数据库层面上水平伸缩的"枷锁", 这是很多Sharding系统不采用分布式事务的主要原因。

  所言甚是!

  3PC

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

从银行转账失败到分布式事务:总结与思考

  3PC只是解决了在异常情况下2PC的阻塞问题,但导致一次提交要传递6条消息,延时很大。具体流程描述可参见《关于分布式事务、两阶段提交协议、三阶提交协议 》一文。

  TCC

  TCC是Try、Commit、Cancel的缩写,在国内由于支付宝的布道而广为人知,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并报警,人工介入。

  1PC

  1PC(one phase commit)这个概念,我是在《Distributed systems for fun and profit》一文中看到的,应该是对标2PC,3PC。在wiki中并没有正式的词条,在google上的文章也不是很多。在我的理解中,1PC适用于分布式存储系统的复制集,即复制集中多个节点的数据提交,。一般来说,这些节点存储同样的数据,只要单个节点能提交,其他节点理论上也应该可以提交。 在《Distributed systems for fun and profit》中是这么描述的:

  Having a second phase in place before the commit is considered permanent is useful, because it allows the system to roll back an update when a node fails. In contrast, in primary/backup ("1PC"), there is no step for rolling back an operation that has failed on some nodes and succeeded on others, and hence the replicas could diverge.

  即对于分布式存储中使用非常广泛的中心化复制集协议Primary Secondary,在部分节点失败、部分节点成功的情况下没有回滚操作,可能会导致不一致。不过这些分布式存储系统都竭力保证,这些不一致是暂时的,会通过重试等手段保证最终的一致。

  1PC的优点是性能非常好,而且只有在出现物理故障的时候才会出现不一致。

  比如在MongoDB中,更新操作会写入Primary节点以及oplog collection,Secondary节点从Primary节点的oplog collection拉取操作日志并执行,这是一个异步的过程。及时Secondary节点因为故障执行oplog失败,Promary节点的数据也不会回滚。在《带着问题学习分布式系统之中心化复制集》中也提到过,为了提高数据可靠性(避免极端情况下数据被回滚),设定WriteConcern为w:Majority,(shard有一个Primary 一个Secondary 一个Arbiter组成)。如果这个时候由于其中一个secondary挂掉,写入操作是不可能成功的。因此,在超时时间到达之后,会向客户端返回出错信息。但是在这个时候数据是持久化到了primary节点,不会被回滚。如果此时Secondary重启,那么是会从Primary拉取日志并执行。所以当客户端返回的出错信息包含WriteResult.writeConcernError 时,应该谨慎处理

  对于分布式文件系统GFS、haystack,如果Secondary节点失败,也会采取简单粗暴的重试,并通过一些机制(cheksum,offset)来保证最终能读到正确的数据

  思考与总结

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

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

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

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

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

  之前一直以为像银行转账这种场景,一定是强一致性的。后来自己遇到这么一回事,我给朋友转账,我这边显示转账成功,但朋友并没有收到钱。我以为是需要一定时间,结果24小时之后还没有收到。我自己重新比对转账单,才发现是把对方的开户银行写错了。因此可见,转账这个操作肯定不是强一致性,具体怎么搞的在网上也没有查到。更坑爹的是,转账失败,我的钱被扣了,朋友也没有收到钱,但是我没有收到任何消息,也没有给我把钱退回来,在我打电话到银行去咨询之后才退回来。这个体验真的很差,但银行是大爷,没办法!


0
相关文章