其他分享
首页 > 其他分享> > 分布式事务简谈

分布式事务简谈

作者:互联网

    前几天线上因为一些缘故,出现了分布式事务的问题。现在问题基本解决了,也应该来回顾总结一下了。


事务

    在聊分布式事务之前,先看看事务是什么。

    关于这个问题,Stack Overflow上有一个高票答案,是这么说的:


“A transaction is a unit of work that you want to treat as "a whole." It has to either happen in full or not at all.

“A classical example is transferring money from one bank account to another. To do that you have first to withdraw the amount from the source account, and then deposit it to the destination account. The operation has to succeed in full. If you stop halfway, the money will be lost, and that is Very Bad.”


    简单翻译一下,他是这么说的:

    “事务是你期望当成‘整体’来处理的一个工作单元。一个事务要么全部完成、要么全不完成。事务的一个典型例子是从银行账户A向银行账户B进行转账。要完成这笔转账,首先要从账户A中支取一笔金额,然后将它存入账户B。这两个操作必须都成功了(转账才算成功)。如果半途而止,这笔钱就丢失了——这是非常糟糕的结果。(后略)”

    其中的核心就是:事务就是必须“All or Nothing”的一个或一组操作(通常是一组)。如果不能保证这一点,这些操作所处理的业务或者数据就会出现很糟糕的问题。例如前面所说银行转账:如果从账户A中支取金额的操作成功了,但是向账户B中存入金额的操作失败了,那么这笔钱就凭空消失了。无论对账户A、账户B、还是对银行来说,这都是无法接受的。


    “All or Nothing”,这就是事务的核心属性之一:原子性。一般我们会说,事务有四个重要属性:原子性、一致性、隔离性和持久性,也就是闻名遐迩的事务的ACID属性:

    事务中的操作要么全执行、要么全不执行,永远不会出现“执行一半”这种问题。有些文章中提到原子性是“变更瞬间完成”,但这不是重点。事务有可能因为一些原因而执行很长时间,例如在发生锁竞争时,数据库事务可能要等待很久才能完成。


    一致性是原子性所产生的结果:系统必须从一个正确的状态迁移到另一个正确的状态。所谓“正确的”状态,也就是一个事务中的所有操作全部成功、或者全不成功的状态。如果出现了部分成功——例如只从账户A转出、没有向账户B转入——那么系统实际上就处于一个“中间状态”,也就出现了不一致问题。


    隔离性是对一致性的一个补充。原子性强调的是事务执行完毕后的一致性——全部,或者全不。隔离性强调的是事务执行过程中的一致性——即使事务还没有执行完成,系统也必须保持一致性,不能出现“中间状态”。


    持久性说的是事务的结果必须是永久性的。这是对一致性的又一补充:即使系统崩溃,系统也必须处于一个一致状态之下。但是并不是所有事务都严格支持这一点——如果Redis或者MQ没有开启持久化支持,那么系统一旦崩溃,此前提交的事务就全部丢失了。不过,尽管会全部丢失,系统也仍然处在一致性状态下——不过是一个没有提交过任何事务的一致性状态。

    持久性这个属性暗示了事务一定更新系统数据或状态。如果一个业务只需要从系统中读取、而完全不需要写入任何数据,那么这个业务就没有必要开启事务。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1


    ACID这四个属性,最核心的是原子性,但是我们最关注的是一致性,因为一致性是最容易度量的一个属性。


    在技术层面上,有数据库事务、MQ事务、Redis事务等各种事务。但是在业务层面上,一个业务事务可能会包含多个技术事务——例如在转账之前借助redis来为两个账户加锁,完成转账操作之后,向MQ中发送一条消息以通知短信系统,并操作redis以释放锁、并更新两个账户的缓存等。

    ps,严格来说,redis事务不是指“数据库事务完成后操作缓存”,不过这里简化一下描述。

    数据库操作是系统中最常见的一种操作。数据库被应用最广、受关注最高的一种事务,也是对ACID研究最深入、支持最到位的一种事务。也因为这些原因,本地数据库事务成为了业务事务的核心,其它事务都要与数据库事务的结果保持一致。例如,如果数据库事务成功提交了,那么MQ消息必须保证发送成功、缓存数据也必须保证正确更新;而数据库事务一旦回滚,则MQ消息一定不能发送出去,缓存数据也不能被更新。

    MQ事务一般是指消息收/发结果与本地数据库事务保持一致。技术上来讲,MQ事务也包含了同一个connection中收/发的消息一定全部成功或全部失败的语义。例如,如果系统从MQ接收到一条消息、然后又用同一个connection向同一个MQ发送了一条消息,那么这两条消息要么都处理成功、要么都处理失败。可以参见《Jms的MessageListener中的Jms事务 - 技术部博客 - Confluence.note》。

    Redis事务除了与本地数据库事务保持一致之外,还通过MULTI 、 EXEC 、 DISCARD 和 WATCH 等命令来保证多条命令全部或全不执行。其中,MULTI相当于数据库事务中的BEGIN,EXEC相当于COMMIT,DISCARD相当于ROLLBACK,WATCH相对复杂一些:它是一种乐观锁的实现机制,有点类似MySQL中的行锁。不过,Redis事务虽然能保证原子性和隔离性,但是对一致性和持久性的支持不太好。


分布式事务

    其实,在介绍前面几种事务时,我们就已经聊到了分布式事务了,也就是业务系统与MQ、Redis等系统之间的事务。MQ、Redis等系统虽然强大,但是就操作类型、数据关系而言,却未必比我们的业务系统复杂。因而,在分布式事务的控制上,业务系统其实还更麻烦些。

    如前所述,在事务的ACID属性中,最容易度量、也最受人关注的就是一致性。由于分布式系统的事务一致性不像单体系统那么简单,而是按照达成一致性状态的时间,被分为了三种:强一致性、弱一致性和最终一致性。


强一致性

    强一致性是指分布式系统的一致性像单体系统一样,在事务完成之后能够立即达成一致。当然由于网络延迟等原因,这里的立即还是会比单体系统慢那么一点。但是对整个系统和事务而言,这一点延迟可以忽略。分布式系统要达成强一制性会非常困难,而且付出的成本也太高。所以现在很少有分布式系统会追求强一致性。如果业务事务就要求强一致性,那我们就应当认真的考虑一下分布式第一法则的建议:能不分布就不分布。

    保证分布式系统强一致性的方法有很多。

    早期的方法是借助XA分布式事务管理协议,借助事务管理器和本地资源管理器,直接在数据库层面上管理分布式事务。XA事务的机制很好理解,它有两阶段和三阶段两种协议。就两阶段而言,一个XA事务分为预备和提交两个阶段。在预备阶段下,各系统中的本地资源管理器检查自己的数据库是否可以执行,如果发现不能执行——例如不能得到必要的锁等情况,则返回失败,此时整个XA事务全部回滚;如果可以执行,则返回就绪,并准备进入第二阶段。在所有系统全部准备就绪之后,事务管理器发起提交操作,各本地资源管理器进入第二阶段。由于在预备阶段已经做过检查,提交阶段的操作一定会成功,操作完成后,XA事务就提交完成了。三阶段协议主要是引入了预提交和超时等机制,在一定程度上提高了XA事务的性能和可靠性。

    显然,XA事务的思路是简单地把单体系统的事务扩展到分布式系统中。这种方式确实能够很好地保证分布式事务的ACID,并且只要本地事务提交或回滚,整个业务事务可以很快达到一致性状态。因而,XA事务能够很好地保证强一致性。而且,由于Oracle、MySQL和DB2等主流数据都实现了XA事务的接口,在业务系统中使用XA事务所需要的工作量也不大,应用成本较低。    

    但是,XA事务有一个致命的缺点:性能很差。分布式系统中的一个业务操作相比本地事务来说,耗时常常要高出几倍、甚至几十倍。而XA事务却要求在数据库从预备阶段开始锁定相关数据、甚至长时间占用数据库链接。这就极大地降低了数据库操作的性能,使得本来就容易形成瓶颈的数据库更加不堪重负。此外,MySQL虽然支持XA事务,但是支持得并不算好,在主从数据库切换时可能出错。这都导致了XA事务在实践中应用得并不算广泛。


    XA事务的思路并没有错,只是它在数据库层面来处理预备阶段的做法不太可取。TCC事务机制延续了同样的思路、但是把预备阶段放到了应用层来处理。

    TCC是指Try-Confirm-Cancel,其中的Try就类似XA事务中的预备阶段。不过,在这一阶段,由应用系统、而非数据库来检查事务是否可以成功提交。例如,一些账户、或账务系统会通过分布式锁来锁定账户或者锁定一定的余额,以供后续操作。如果锁定成功,则分布式事务继续执行。此后,当事务成功提交时,调用try接口对应的confirm接口来提交事务;如果事务回滚了,则调用cancel接口来撤销try阶段加的锁。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1


    TCC与XA事务一脉相承,对强一致性的支持也比较好。而且,由于Try接口是在业务层、而非数据库层进行锁定,TCC事务机制对数据库的压力较小,相对的性能也会更好一些。

    但是,也正因为TCC机制是在业务层进行事务控制,因此,尽管实际业务只需要Confirm接口,但我们还是要编写Try和Cancel接口。此外,调用Try、Confirm和Cancel接口时的重试机制等也需要额外的处理。这会带来非常多的额外工作量。因为这个缘故,很多时候我们也并不愿意应用TCC机制来管理分布式事务。


弱一致性

    弱一致性指的是分布式系统承诺达成、但不承诺什么时候达成一致性状态。由于保证强一致性确实比较困难,所以,我们会尽量地把分布式系统中的强一致性转为弱一致性。


    如果绝大多数情况下,业务事务都能提交成功的话,TCC事务可以简化为CC两个步骤。即第一次调用时就调用Confirm接口,如果事务回滚再调用Cancel接口。这样一来工作量就减少了三分之一。如果能保证事务不会因为业务原因而失败的话——例如除非网络超时否则事务一定成功——那么,甚至连Cancel接口都可以省去,当Confirm失败时重试即可。这样一来,工作量就又减少了三分之一。这样一来,实际需要编码的就只有Confirm接口和一些重试管理相关代码了。

    把TCC机制转化为Confirm+重试机制时,实际上它就不再保证强一致性、而只能保证弱一致性了。


    与简单重试类似的机制,是消息表机制,也就是在执行事务时,并不直接调用服务,而是把服务调用的相关信息记录——如接口地址、参数列表等——记录在消息表中。如果事务回滚,那就不再调用这些接口;但如果事务成功提交,则自动调用、重试这些接口。这种方式就是eBay提出的BASE事务机制,也叫基本一致性(Basically,Available,Soft state,Eventually consistent四个词组的首字母,即基本可用+软状态+最终一致性 )。

    不过,消息表方式需要我们自己处理消息存储、超时重试等问题。使用消息中间件,不仅可以保证BASE,而且可以借助MQ自身的事务消息、持久化、重试等机制,减少我们的工作量、同时增加系统的可靠性。这也是现在系统管理分布式事务的一种最常见方式。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1



    不过,弱一致性并非万能选项。一般来说,只有不影响本地事务的服务调用可以转化为弱一致性事务。如果某个服务调用的返回值影响到了本地事务的处理流程,那么它就只能按强一致性来处理了。例如下图中,左边的服务调用就可以用弱一致性事务来处理;右边的服务调用由于参与到了本地事务的处理流程中,一般只能按强一致性事务来处理——除非利用React等异步回调机制来处理,不过这样一来处理逻辑就变得更加复杂了。

640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1


最终一致性

    最终一致性是弱一致性的一种特例,是指系统保证在没有后续更新的前提下,系统最终返回上一次更新操作的值。对于弱一致性的常见方案,前面已经简单说过,这里不再啰嗦。


    从字面上理解,还有一种“防御式”的一致性方案也可以称为“最终一致性”,即用一个定时任务系统,定时检查各系统中数据是否一致。如果发现了不一致,则可以通过报警、甚至自动重试等方式进行修复。不过这种方式属于最后一道防线,而且大多数情况下除了报警也做不了什么,一般也很少用到。



qrcode?scene=10000004&size=102&__biz=MzUzNzk0NjI1NQ==&mid=100000495&idx=1&sn=f0817536e05c534d4a414f2106a98bac&send_time=1563121023

微信扫一扫
关注该公众号


标签:事务,简谈,XA,系统,一致性,数据库,分布式
来源: https://blog.51cto.com/winters1224/2420203