当前位置: 首页 > 科技观察

闺蜜问敖丙:什么是分布式事务?

时间:2023-03-22 11:07:09 科技观察

本文转载自微信公众号《三太子敖丙》,作者:三太子敖丙。转载本文请联系三太子敖丙公众号。前言上一篇已经讲完了分布式事务。暖暖要是说要聊分布式事务,他肯定会聊的。不过估计大家都没想到暖暖好得这么快吧?事务一定不陌生,至于什么是ACID也是老生常谈了。不过为了保证文章的完整性,保证大家能够看懂,还是要先说一下ACID,然后再介绍一下什么是分布式事务,常见的分布式事务包括2PC、3PC、TCC、本地消息表,消息事务,尽力而为通知。事务严格意义上的事务实现应该具有原子性、一致性、隔离性和持久性,简称ACID。原子性可以理解为一个事务内的所有操作要么执行要么不执行。一致性可以理解为满足完整性约束的数据,即不会有中间状态数据。比如你账户里有400,我账户里有100,你给我200元。这时候你账户里的钱应该是200,我账户里的钱应该是300,不会出现我账户里的钱已经加完你账户里的钱还没有扣的中间状态。隔离是指多个事务并发执行时不会相互干扰,即一个事务内部的数据与其他事务隔离。持久性是指在一次交易完成后,数据永久保存,后续其他操作或失败不会影响交易结果。通俗的理解,事务就是为了让一些更新操作要么成功,要么失败。说到这里,可能有人会说,不行,Redis事务不能保证所有的操作要么执行要么不执行。为什么又叫交易呢?首先要知道,一般的中间件会夸大它的作用,其他团队也想改。出名,吸引更多的人使用他们的产品,所以我们要辩证地看待。一般来说,既然敢说自己做到了,要么就是真的做到了,要么就是在某些特殊的、定制的或极端的条件下才能满足功能。让我们看看Redis是怎么说的。这句话就是告诉大家,事务中的一个命令失败了,后面的命令依然会被处理。Redis不会停止命令,这意味着它不会回滚。你不觉得这是废话吗?这偏离了业务的核心意图。别着急,让我们看看Redis是如何解释的。Redis官网解释了为什么不支持回滚。他们说,首先,如果命令错误,那是语法错误。是你自己的编程错误,这种情况应该在开发的时候检测出来,不应该出现在生产环境中。.那么Redis就是要快!无需提供回滚。下面还有一段我就不截图了,也就是说,提供回滚是没有用的,你的代码错了,回滚并不能使你免于编程错误。而且这种错误一般是不可能进入生产环境的,所以我们选择更简单快捷的方法,不支持回滚。你看,这似乎有道理,我们不提供回滚,因为我们不需要为你的编程错误买单!但似乎哪里不对?角度和位置不同,每个人都有自己的品味。就下来开始分布式事务吧。分布式事务分布式事务顾名思义就是在分布式系统中实现事务,分布式系统实际上是由多个本地事务组成的。对于分布式事务,ACID很难满足。事实上,对于单机事务,ACID在大多数情况下是不满足的。不然怎么会有四个隔离级别呢?所以更不用说分布在不同的数据库或不同的应用程序中了。正式事务。我们先来看2PC。2PC2PC(Two-phasecommitprotocol),中文称为两阶段提交。两阶段提交是一种强一致性设计。2PC引入了一个事务协调者的角色来协调和管理每个参与者(也称为每个本地资源)的提交和回滚。第二阶段是指准备(投票)和提交两个阶段。注意,这只是一个约定或理论指导,也只是说明了大方向,具体实现上还是有差异的。让我们看看接下来两个阶段的具体过程。在准备阶段,协调器会向每个参与者发送准备命令。您可以将准备命令理解为完成除提交交易之外的所有操作。同步等待所有资源响应后,进入第二阶段,commit阶段(注意commit阶段不一定是commit事务,也可能是rollback事务)。如果第一阶段所有参与者都返回准备成功,协调器向所有参与者发送事务提交命令,然后等待所有事务提交成功,然后返回事务执行成功。让我们看一下流程图。如果一个参与者在第一阶段返回失败,协调器将向所有参与者发送回滚事务的请求,即分布式事务执行失败。那么有人可能会问,如果二期提交失败了怎么办?这里有两种情况。第一个是交易操作在第二阶段被回滚,所以答案是不断重试,直到所有参与者都被回滚,否则那些在第一阶段成功的参与者将一直被阻塞。二是第二阶段是提交交易操作,所以答案是不断重试,因为有可能部分参与者的交易已经提交成功。这个时候只有一个办法,就是往前冲,重试直到提交成功。最后实在不行,只能手动干预。总的来说,二阶段提交的流程是这样的,我们来看一下细节。首先,2PC是一个同步阻塞协议。例如,第一阶段的协调者会等待所有参与者响应,然后再进行下一步。当然,第一阶段的协调器是有超时机制的,假设由于网络原因没有收到某个参与者的响应或者某个参与者挂了,超时后会判断事务失败,回滚命令将发送给所有参与者。第二阶段,协调器不能超时,因为根据我们上面的分析,只能不断重试!协调器故障分析协调器是单点的,存在单点故障问题。假设协调器在发送prepare命令之前就挂掉了,可以说明事务还没有开始。假设协调者发送完prepare命令就挂了,这样就不太好了,部分参与者相当于在事务资源锁的状态下执行。不仅交易无法执行,系统的其他操作也因为一些公共资源被锁定而被阻塞。假设协调者在发送回滚事务命令之前就挂了,那么事务就无法执行,那些在第一阶段准备成功的参与者就被阻塞了。假设协调器在发送回滚事务命令后挂掉了,这没关系,至少命令下达了,大概率会回滚成功,释放资源。但如果出现网络分区问题,部分参与者会因为无法接收命令而被阻塞。假设协调者在发送committransaction命令前就挂了,这不行,傻!现在所有资源都被封锁了。假设协调器发送完提交事务的命令就挂了,这没关系,至少命令发送了,大概率会提交成功,然后释放资源,但是如果有网络分区问题,部分参与者会因为接收不到指令而被屏蔽。协调器失效,通过选举产生新的协调器。由于协调器存在单点问题,我们可以通过选举等操作选出新的协调器来代替它。如果是第一阶段,影响不大,会回滚。在第一阶段,事务必须尚未提交。如果是在第二阶段,假设没有一个参与者挂掉,新的协调者可以向所有参与者确认自己的情况,从而推断下一步的操作。假设一个参与者挂了!这个有点死板,比如协调者发回滚命令,此时第一个参与者接收并执行,然后协调者和第一个参与者都挂了。这时候其他参与者没有收到请求,然后新的协调者来了,它让其他参与者说OK,但是不知道挂断的参与者是不是O,所以是愚蠢的。问题是每个参与者的状态只有他自己和协调者知道,所以新的协调者不能从当前参与者的状态推断挂起的参与者的状态。虽然在协议中没有提到,但是我们可以灵活的让协调者记录他发出的请求,也就是日志记录,这样当新的协调者来的时候,他就知道这个时候是否应该做。发送?但是即使协调者知道他应该发送commit请求,当参与者一起挂断时也没用,因为你不知道参与者挂断前是否提交了事务。如果在参与者挂掉之前事务提交成功,并且新的协调器确定幸存的参与者没有问题,那么它必须向其他参与者发送提交事务命令以保证数据的一致性。如果参与者挂断前事务还没有提交成功,则参与者恢复后数据将被回滚。这时,协调者必须向其他参与者发送回滚事务命令,以保持事务的一致性。因此,在极端情况下,数据不一致的问题是无法避免的。说话是便宜的让我们再看一下代码,它可能会更清楚。以下代码摘自<>。这段代码实现了2PC,但是和2PC相比,增加了写日志的动作,参与者之间会互相通知,参与者也实现了timeout。这里需要注意的是,所谓的2PC并不包括以上功能,是在实现时添加的。Coordinator:writeSTART_2PCtolocallog;//StarttransactionmulticastVOTE_REQUESTtoallparticipants;//Broadcastnotificationtoparticipantstovotewhilenotallvoteshavebeencollected{waitforanyincomingvote;iftimeout{//CoordinatortimeoutwriteGLOBAL_ABORTtolocallog;//WritelogmulticastGLOBAL_ABORTtoallparticipants;//notifytransactioninterruptionexit如果所有参与者都okifallparticipantssentVOTE_COMMITandcoordinatorvotesCOMMIT{writeGLOBAL_COMMITtolocallog;multicastGLOBAL_COMMITtoallparticipants;}else{writeGLOBAL_ABORTtolocallog;multicastGLOBAL_ABORTtoallparticipants;}参与者:writeINITtolocallog;//写日志waitforVOTE_REQUESTfromcoordinator;iftimeout{//等待超时writeVOTE_ABORTtolocallog;exit;}ifparticipantvotesCOMMIT{writeVOTE_COMMITtolocallog;//记录自己的决策sendVOTE_COMMITtocoordinator;waitforDECISIONfromcoordinator;iftimeout{multicastDECISION_REQUESTtootherparticipants;//超时通知waituntilDECISIONisreceived;/*remainblocked*/writeDECISIONtolocallog;}ifDECISION==GLOBAL_COMMITwriteGLOBAL_COMMITtolocallog;elseifDECISION==GLOBAL_ABORTwriteGLOBAL_ABORTtolocallog;}else{writeVOTE_ABORTtolocallog;sendVOTE_ABORTtocoordinator;}每个参与者维护一个线程处理其它参与者的DECISION_REQUEST请求:whiletrue{waituntilanyincomingDECISION_REQUESTisreceived;readmostrecentlyrecordedSTATEfromthelocallog;ifSTATE==GLOBAL_COMMITsendGLOBAL_COMMITtorequestingparticipant;elseifSTATE==INITorSTATE==GLOBAL_ABORT;sendGLOBAL_ABORTtorequestingparticipant;elseskip;/*participantremainsblocked*/}至此2PC的各种细节我们都详细分析过了,总结一下吧!2PC是分布式事务,尽可能保证强一致性,所以同步阻塞,同步阻塞导致长期资源锁定问题。总体来说效率低下,存在单点故障问题,极端情况下存在数据不一致的风险。当然具体实现可以变形,2PC也有变种,比如Tree2PC和Dynamic2PC。还有一点不知道大家看出来2PC适用于数据库层面的分布式事务场景,有的时候我们的业务需求不仅仅和数据库有关,还可能是上传图片或者发送文本信息。而且Java中的JTA只能解决一个应用下多个数据库的分布式事务问题,如果是跨服务就不能用了。简单的说,Java中的JTA就是一个基于XA规范的事务接口。这里XA根据数据库的XA规范可以简单理解为2PC。(至于XA规范是什么,限于篇幅,下次再说。)接下来,我们来看一下3PC。3PC3PC的出现就是为了解决2PC的一些问题。与2PC相比,它还引入了参与者之间的超时机制,并增加了一个新的阶段,让参与者可以利用这个阶段来统一各自的状态。让我们详细看一下。3PC包括三个阶段,即准备阶段、预提交阶段和提交阶段。对应的英文是:CanCommit、PreCommit和DoCommit。好像2PC提交阶段变成了pre-commit阶段和提交阶段,但是3PC准备阶段coordinator只是问参与者自己的情况,比如你现在怎么样?负载重吗?这样的。预提交阶段与2PC的准备阶段相同,只是事务的提交不同。commit阶段和2PC一样,看图。无论哪个阶段参与者返回失败,都会宣告交易失败,这和2PC是一样的(当然在最后的commit阶段,和2PC一样,只要请求提交了,就只能重试了不断)。我们先来看3PC相变带来的影响。首先,准备阶段的变更不会直接执行交易,而是会先询问此时的参与者是否有资格接受交易,所以一来就不工作直接锁资源,使某些资源不可用。在这种情况下,所有参与者都被阻止。预提交阶段的引入充当统一状态。就像一个栅栏,表示在pre-submit阶段之前所有参与者都没有响应,在pre-processing阶段表示所有参与者都已经响应。如果您是参与者并且您知道自己处于预提交状态,那么您可以推断其他参与者也处于预提交状态。但是多引入一个stage也需要多一个交互,所以性能会差一些,而且大多数情况下,资源应该是可用的,也就是说每次知道可用都要问一次。让我们来看看参与者超时的影响。我们知道2PC是同步阻塞的。上面我们已经分析过,在提交请求没有发出去的时候,协调器是最受伤的。所有参与者都锁定了资源并被阻塞等待。然后引入超时机制,参与者就不会傻等了。如果等待提交命令超时,那么参与者就会提交交易,因为他们已经到了这个提交概率很高的阶段。如果他们在等待预提交命令的时候超时了,那么你该干什么就干什么,反正你什么都没干。但是超时机制也会造成数据不一致。比如等待提交命令超时,参与者默认执行提交事务操作,但可能执行回滚操作,所以数据不一致。当然3PCcoordinator的超时还是有的,不分析细节和2PC一样。根据维基百科,3PC的引入是为了解决2PC协调器和某个参与者在commit阶段都挂掉后,新选出的协调器不知道是提交还是回滚的问题。当新的协调器发现有参与者处于预提交或提交阶段时,说明所有参与者都已确认,所以此时执行commit命令。所以3PC引入了一个pre-submissionstage来统一参与者之间的状态,也就是留一个stage给大家同步。但是这只能让协调者知道该做什么,而不能保证一定要正确的去做。这其实和上面2PC的分析是一致的,因为无法判断被挂的参与者是否执行了交易。因此,3PC可以通过pre-commit阶段降低故障恢复的复杂度,但无法保证数据的一致性,除非挂掉的参与者恢复。总结一下,3PC相比2PC做了一些改进:引入了参与者超时机制,增加了pre-submit阶段,降低了故障恢复后协调者决策的复杂度,但整体交互过程更长而且性能下降了,还是会出现数据不一致的情况。所以2PC和3PC都不能保证数据100%一致,所以一般需要时序扫描补偿机制。再来说说3PC。没有找到具体的实现方式,所以我觉得3PC只是一个纯理论的东西,而且可以看出相对于2PC来说是做了一些努力但是收效甚微,所以了解一下也就罢了。TCC2PC和3PC都是数据库层面的,TCC是业务层面的分布式事务。前面说过,分布式事务不仅包括数据库操作,还包括发送短信等,这时候TCC就派上用场了!TCC代表尝试-确认-取消。Try指的是reservation,即资源的预留和锁定。请注意,这是预订。Confirm指的是确认操作,这一步其实才是真正的执行。取消指的是撤销操作,可以理解为取消预定阶段的动作。其实在意识形态上和2PC有些相似。它先试探性地执行。如果没问题,那就执行吧。如果它不起作用,它将被回滚。例如,如果一个事务需要执行A、B、C三个操作,那么保留的动作会先对这三个操作执行。如果所有预约成功,则执行确认操作,如果有预约失败,则执行取消操作。让我们来看看这个过程。TCC模型还有一个事务管理器角色,用于记录TCC全局事务状态,并提交或回滚事务。可以看出流程还是很简单的,难点在于业务的定义。对于每一个操作,都需要定义三个动作对应尝试-确认-取消。因此TCC对业务的侵入性大,业务紧耦合,需要根据具体场景和业务逻辑设计相应的操作。还有一点需要注意的是undo和confirm操作的执行可能需要重试,所以也需要保证操作的幂等性。与2PC、3PC相比,TCC的适用范围更大,但开发量也更大。毕竟都是在业务中实现的,有时候你会发现这三个方法真的很难写。但是,由于是在业务中实现的,TCC可以实现跨数据库、跨不同业务系统的事务。本地消息表本地消息表实际上是利用各个系统的本地事务来实现分布式事务。顾名思义,本地消息表就是有一张表用来存放本地的消息,这个表一般是放在数据库中,然后在执行业务的时候,业务的执行和将消息放入消息表的操作都是放在同一个事务中,这样可以保证消息放到本地表中,业务一定执行成功。然后调用下一个操作。如果下次操作调用成功,则可以直接将消息表的消息状态改为成功。调用失败也没关系,会有后台任务定时读取本地消息表,过滤掉没有调用成功的消息,然后调用相应的服务,服务更新后再改变消息的状态是成功的。这时候可能消息对应的操作不成功,所以也需要重试。重试必须保证对应的服务方法是幂等的,一般会有最大重试次数。如果超过最大重试次数,可以记录告警,供人工处理。.可以看出,本地消息表实际上实现了最终一致性,容忍临时数据不一致。消息事务RocketMQ非常支持消息事务。我们来看看如何通过消息实现事务。第一步是向Broker发送交易消息,即半消息。halfmessage不是消息的一半,而是消息对consumer是不可见的,发送成功后再由sender执行本地事务。然后根据本地事务的结果向Broker发送Commit或者RollBack命令。而RocketMQ的发送方会提供一个接口来查看交易状态。如果一段时间内没有收到操作请求,Broker会通过接口知道发送方的交易是否成功,然后执行Commit或者RollBack命令。如果是Commit,那么订阅者就可以收到这条消息,然后做相应的操作,完成后再消费这条消息。如果是RollBack,订阅者是收不到这条消息的,说明事务还没有执行。可见通过RocketMQ实现起来还是比较容易的。RocketMQ提供了事务消息的功能。我们只需要定义交易反向查询接口即可。可见消息事务也实现了最终一致性。Besteffortnotification其实我觉得本地消息表也可以算作besteffort,事务消息也可以算作besteffort。对于本地的消息表,会有后台任务定时检查未完成的消息,然后调用相应的服务。当一条消息多次调用失败时,可以记录下来,然后手动引入,也可以直接丢弃。这实际上是最好的努力。交易消息也是如此。当一个半消息被提交时,它确实是一个正常的消息。如果订阅者没有消费或者消费不了,就会不断重试,最后进入死信队列。其实这也是最好的努力。所以besteffortnotification其实只是展示了灵活交易的思想:我已经尽力达成了交易的最终协议。适用于时间不敏感的业务,如短信通知。综上所述,我们可以看出2PC和3PC是强一致性事务,但是仍然存在数据不一致、阻塞等风险,只能在数据库层面使用。TCC是一种补偿性事务思想,适用范围更广,实现在业务层面,因此对业务的侵入性更强,每个操作都需要实现对应的三个方法。本地消息、事务性消息和尽力而为通知实际上是最终一致性事务,因此适用于一些对时间不敏感的业务。