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

面试官问我:什么是分布式事务?

时间:2023-03-19 18:14:37 科技观察

事务大家应该都不陌生,尤其是程序员。如果你没听说过交易,没关系,因为你见过聪明有才华的我。事务其实就是处理各种混合操作,涉及多个业务的场景重点是事务应用场景,就是多个事务必须同时完成或者不能同时完成的场景,即实现严格意义上的真正意义上的“同生同死”一般来说,事务其实有四个特性:原子性、一致性、隔离性、持久性,也就是大家口中的ACID原子性(Atomicity)。可以理解为一个事务中的所有操作要么一致执行,要么不一致执行。一致性可以理解为满足完整性约束的数据,即不会有中间状态数据。比如你账户里有400,我账户里有100。你给我200元。这时候你的账户账户上的钱应该是200,我账户里的钱应该是300。不会有中间状态隔离(Isolation),我账户里的钱已经加进去了,但是里面的钱你的账户没有扣款。隔离是指多个事务并发执行。时间上不会相互干扰,即一个事务内部的数据与其他事务是隔离的(持久性),也就是说一个事务完成后,数据会永久保存,之后的其他操作或失败不会Strictly说起来,事务其实有四个特性:原子性、一致性、隔离性和持久性。也就是大家口中的ACID其实就是我们印象中的。我们应该对这笔交易非常熟悉。大家我们都知道,事务就是让数据库层面的一些更新操作要么全部成功,要么全部失败。不知道大家有没有学过Redis。如果你学过Redis,你可能会有疑惑,因为Redis的事务不能保证所有的操作都执行或不执行,但也叫事务。其实Redis在官网上已经说的很清楚了。官网告诉大家,交易中某条指令失败,后续指令仍会处理。Redis不会停止执行命令,也就是说不会回滚。Redis解释为什么不支持回滚他们给出的答案首先是,如果命令错误,是语法错误,属于个人编程错误,而且这种情况应该检测出来,而不是在生产环境中,所以Redis是比较快的。不支持回滚操作看似合理,但不对劲。现在大家都知道什么是事务了,那我们就来看看分布式事务吧。分布式事务刚才说的事务属于单机体程序,这个在单机上是没有问题的,通过普通的事务操作就可以解决;当我们的系统逐渐做大做强的时候,并发量和系统都会相应的增加。当两个系统合作完成一个事务时,这就比较困难了,因为不能直接通过一个系统的数据库来完成。假设有订单系统、扣分系统、积分系统,分属于三个系统。也就是在不同的数据库中,但是我需要保证三个系统中的服务要么都成功,要么都失败。其实设计到多库、多系统的事务操作就是分布式事务分布式事务其实很简单很简单。实际上,它们是由多个本地事务组成的。对于分布式事务,ACID很难满足。事实上,对于单机事务来说,ACID在大多数情况下是无法完全满足的。不然哪来的四个隔离级别?所以更不用说不同数据库和不同系统之间的分布式事务了。分布式事务大致可以分为六种,但实际上这六种可以按照三种思路进行分类。一起来看看吧。2PC和3PC是强一致性事务,但仍然存在数据不一致、阻塞等风险,只能在数据库层面应用;TCC是一种补偿事务的思想,适用范围应该是比较广的,但是这种补偿机制一般对业务的侵入性比较大,每个操作都需要实现相应的三个方法;另一种思路是力争最终一致性事务,包括本地消息、事务消息、best-effortnotification和best-effortnotification这三种方式都是为了实现最终一致性事务,所以适用于一些没有时间限制的业务-敏感的。大致了解了这三类之后,我们来逐一详细学习。2PCtwo-phasecommit:准备阶段,提交阶段2PC,又称两阶段提交,第二阶段指的是准备阶段和提交阶段。第二阶段提交属于强一致性设计。2PC引入了一个事务协调者角色来协调和管理所有参与者的提交和回滚机制,我们来看看具体的过程。在准备阶段,协调者会向每个参与者发送准备命令。这个准备其实就是环境的准备,可以理解为提交前的准备,等待所有资源同步响应后,一切准备就绪,提交阶段只有提交状态。提交阶段不一定是交易的提交。它可能是回滚事务。如果第一阶段准备成功,则第二阶段的提交为提交事务;同样,如果第一阶段没有完全准备好,第二阶段的提交就是回滚事务。假设第一阶段准备成功,协调者向所有参与者发送commit命令,然后等待所有参与者都成功,然后返回事务执行成功。假设第一阶段部分参与者返回失败,那么协调者会向所有参与者发送一个回滚事务的请求,也就是类似于上图,向所有参与者发送一个回滚事务说到这里,一些朋友们已经开始有疑问了。我知道第一阶段失败怎么处理是的,但是如果第二阶段失败了怎么办?其实这里有两种情况。第二阶段是提交阶段,第二阶段是回滚操作。这两种情况的处理方法其实是一样的,都是不断重试,直到重试成功;对于提交,可以根据业务场景进行一定次数的重试后尝试回滚;但是对于回滚操作,你永远无法执行成功的操作所以,如果第二阶段是回滚操作失败,当失败次数达到一定数量时,最好的方式是手动干预提交过程和分析几乎是一样的。一起来看看详情吧。2PC可以作为同步阻塞协议,同步阻塞等待第一阶段所有参与者的响应,然后再进行第二阶段的操作;熟悉Java基础的小伙伴们是不是很快就记住了Java并发包呢?一个工具类CountDownLatch,和一个功能类似的CyclicBarrier。如果忘记了,赶紧回忆一下,2PC这里有一个同步阻塞的超时机制。向所有参与者发起回滚命令,知道交易失败。以上都是从参与者的角度考虑的。如果协调员有问题?如果协调器是单点的,发生故障后,可能会出现一些系统问题,我们从流程的角度来分析:准备阶段的命令没有发出,协调器故障,事务还没有开始,问题不大;准备阶段的命令发出,协调器失败,事务开始,不管参与者是成功还是失败,最后的情况很糟糕,因为参与者不能等待下一条指令,也就是磁盘卡住,不仅交易无法执行,还会锁定一些公共资源,其他系统也会阻塞;当准备阶段发出命令,Allsuccessful,第二阶段执行提交阶段命令,这是不可接受的,因为部分参与者可能因为分区和网络拥塞而收不到提交命令。理想情况下,如果参与者收到了一次全部提交的命令,但是参与者可能会提交失败,所以仍然需要重试。这时候协调器挂了,就不行了。准备阶段的命令下达了,有的失败了。回滚命令在第二阶段发出。其实和上面的情况类似,也会出现各种各样的问题既然单点协调器不够用,那就多一个吧,通过选举机制选出新的协调员。如果他们在第一阶段,他们都很好。如果交易还没有提交,他们只会滚动;参与者可以进一步向所有参与者确认自己的情况,以推断下一步该怎么做。如果个别参与者挂断电话,那就尴尬了例如,如果协调者发送回滚命令,第一个参与者接收并执行它,然后协调者和第一个参与者都挂断。这时没有其他参与者收到请求,然后新的协调者参与者来了,它让其他参与者回答OK,但它不知道第一个参与者挂了。这时候如果按照allOK处理,直接发送提交命令,就坏了。这不是我们想要的。想要的结果其实在2PC协议中并没有提到,但是在实现的时候,我们需要灵活的让coordinator记录下他发送过的所有请求,类似于日志记录,这样新的coordinator才会来。我现在不知道什么时候发送。即使协调者知道他应该发送提交请求还是回滚请求,当参与者也挂了时也没有用,因为协调者无法知道参与者挂了。您之前提交过交易吗?其实这里最靠谱的方法就是对每个步骤进行相应的日志记录。重要步骤的日志记录最好强绑定。否则操作成功,日志记录失败,也是很糟糕的,总之,需要考虑各种极端情况,考虑到2PC是分布式事务,尽可能保证强一致性,尽量做好每一个细节,因为它是同步阻塞的,同步阻塞意味着在某些情况下,资源会被锁定,一旦单点失败,会导致资源锁定exit;}recordvote;}//如果所有参与者都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*/}3PCthree-stagesubmission:preparationstage,pre-submissionstage,andsubmissionstage3PCisactuallyanupgradedversionof2PC.Comparedwith2PC,participantsAtimeoutmechanismisalsointroduced,并增加了一个新的舞台,让参与者可以通过这个舞台来统一各自的状态。3PC分为三个阶段:准备阶段、预提交阶段、提交阶段看起来更像是将2PC中的提交阶段分为两个阶段:预提交和提交,但这里的准备阶段其实是在询问参与者自身的情况,也就是询问你目前的情况,是否负载超载,或者能否再次接受新任务?pre-submissionphase其实和2PC的preparationphase类似,就是除了提交交易之外该做的都是之前的准备工作,但是在3PC中叫做pre-submissionphase。3PC是第一个preparation阶段,不直接执行事务,而是先询问此时的参与者是否有资格执行事务,所以不会直接锁定资源,pre-commit阶段的引入是发挥统一作用,在预处理阶段,所有参与者都已经响应。其实这也引入了一个额外的阶段,所以性能会差一些,而且大多数情况下资源是没有问题的,就是可用的,等于每次你知道可用但是你还有问一次。当然,任何阶段的参与者未能返回都将宣告交易失败。这与2PC相同。当然最后的提交阶段和2PC是一样的。只要提交了请求,就可以通过不断的重试。上面我们说了2PC是同步阻塞的。协调员在提交请求还没发出来就挂了,这是最尴尬的。所有参与者都锁定了资源,都在阻塞等待,所以引入了超时机制,参与者不用直接等待。如果等待提交命令的超时时间结束,参与者就会提交交易,因为在这个阶段,很大概率是全部提交完毕。如果他们在等待预提交这里其实有一个问题,超时机制会导致数据不一致,即等待提交命令超时时,参与者会自动提交交易,但也可能被执行。回滚机制,让数据不一致,引入3PC是为了解决提交阶段2PC协调器和部分参与者挂掉,然后之后新选举的协调器不知道的情况whatshouldbethecurrent是提交还是回滚的问题。新的coordinator来了,发现有一个participant在pre-submit或者submit阶段,也就是说所有的participant都已经确认了,那么这个时候执行的就是submit命令。3PC是通过引入在pre-submission阶段,参与者之间的状态是真正统一的,也就是有一个大家同步的阶段,但这只是coordinator知道怎么做的,它确实不保证它一定是正确的。这其实和上面的2PC分析是一样的,因为无法判断挂掉的参与者是否执行了交易。因此,3PC可以通过pre-commit阶段降低失败的复杂性,但不能保证数据的真实性。一致,挂掉处理的参与者也恢复了一句话总结:3PC相比2PC,做了一定量的参与者超时机制的改进,增加预提交阶段,可以降低故障恢复后协调器的决策复杂度,但整体交互过程会变长,性能会下降,会出现数据不一致的情况TCC:Try-Confirm-CancelTCC属于业务层面的分布式事务。分布式事务不仅包括数据库层面的操作,还包括业务层面的操作。这时候TCC就派上用场了。TCC指的是Try、Confirm、Cancel三个步骤,Tryreferstoreservation,指资源的预留和锁定;Confirm指的是确认操作,这一步实际上是真正的执行,真正的消耗资源来进行相应的业务提交操作;Cancel指的是撤销操作,可以理解为在保留阶段销毁动作,这是回滚操作。从思想上来说,其实和2PC、3PC类似,都是先试探性的执行。永久锁定资源。如果每个参与者都没事,你就可以进行真正的操作,提交或回滚。例如:一个事务需要执行A、B、C三个操作,那么首先执行这三个操作Executethereservationaction。如果所有预留成功,则执行确认提交操作。如果至少一个保留失败,则执行撤消操作。TCC模型还有一个事务管理器角色,用于记录TCC相关全局事务操作的状态,并准备提交或回滚事务。其实这个还是比较容易理解的。难点在于业务的定义。TCC是对业务的大入侵,与业务紧耦合。需要根据对应的具体业务场景和业务逻辑来设置响应操作。其实还有一点需要注意,undo和confirm操作的执行需要重试,也就是说需要保证操作的幂等性。TCC相对而言,应用范围应该更广一些,但是这样有个缺点,就是这个和业务耦合了,需要大量的开发,因为都是在业务中实现的,相当于要求三实现方式是业务内嵌,这样TCC就可以实现跨业务系统和数据库的事务的本地消息表。本地消息表使用各个系统的本地事务来实现分布式事务。这其实很简单,其实会有一个表用来存放本地消息,这个表一般是放在数据库中的,然后在执行业务的时候,要把业务真正执行的操作和这个操作对应的消息放到消息表中.这个操作,存储在同一个事务中,即只要操作成功,就必须保证消息也成功放入本地消息表中。调用下一个操作时,如果下一个操作调用成功,可以直接将消息的状态改为成功,调用失败了也没关系,我们可以写一个定时任务读取本地的消息表,然后过滤掉没有执行成功的消息,然后调用相应的服务。服务更新成功后,改变消息的状态其实需要在这里重试。机制,重试必须保证对应服务的方法是幂等的,一般会有最大重试次数。当超过最大数量时,可以手动干预本地消息表,实现业务的最终一致性,需要容忍临时数据不一致的情况,消息事务其实就是消息事务,最典型的有在RocketMQ中实现,应用场景非常多。RocketMQ的机制是先向Broker发送事务消息,即半消息,半消息是指这条消息对消费者是不可见的,发送成功后,本地事务会继续执行。第二步,根据本地事务的执行结果,向Broker发送Commit和Rollback命令。如果还没有发送,RocketMQ的发送端会提供一个接口来查看事务的状态,用于检查对应事务的结果是成功还是回滚。其实这是一种超时机制。如果一段时间内没有收到操作请求,Broker会通过相应的结果来查找事务是否执行成功,是Commit还是Rollback。如果是Commit,broker会将这条消息发送给订阅者,然后执行相应的操作。完成后,消息就可以被消费了。如果是Rollback则订阅者不会收到这条消息,这意味着事务还没有执行尽力而为通知。其实我个人认为besteffortnotification是一种思想。和上面的本地消息表和事务消息一样,也是besteffort通知类型的本地消息表。会有后台任务定时查看未完成任务的消息,然后调用相应的服务进行多次重试。当需要手动引入多个失败时,这也是best-effort事务消息,也属于类似的,semi-message提交后发送给消费者。如果消费者没有消费或消费失败,会不断重试。如果重试次数达到一定次数,则将消息改回私有消息队列,这也是一种besteffort通知。这应该属于一种思路,尽量做到事务的最终一致性,适用于对时间不敏感的业务场景