面试官问我:分布式事务是什么?
事务其实大家应该不陌生,尤其是对于程序员来说,如果你连事务都没听说过,没关系,因为你遇到了聪明和才智于一体的我,事务其实就是为了处理多种混合操作,涉及到多方面业务的情景
重点是事务应用的场景就是为了解决多种事务必须要么同时完成,要么同时不能完成的场景,也就是做到真正意义上的"同生共死"
严格意义上来说事务其实具有原子性、一致性、隔离性和持久性四种特性,也就是大家老生常谈的ACID
原子性(Atomicity),可以理解为一个事务内的所有操作要么都执行,要么都不执行
一致性(Consistency),可以理解为数据是满足完整性约束的,也就是不会存在中间状态的数据,比如你账上有400,我账上有100,你给我打200块,此时你账上的钱应该是200,我账上的钱应该是300,不会存在我账上钱加了,你账上钱没扣的中间状态
隔离性(Isolation),指的是多个事务并发执行的时候不会互相干扰,即一个事务内部的数据对于其他事务来说是隔离的
持久性(Durability),指的是一个事务完成了之后数据就被永远保存下来,之后的其他操作或故障都不会对事务的结果产生影响
严格意义上来说事务其实具有原子性、一致性、隔离性和持久性四种特性,也就是大家老生常谈的ACID
其实在我们印象中,应该对这个事务再熟悉不过了,大家都知道事务就是为了使得一些数据库层面的更新操作要么全部成功,要么全部失败。
不知道大家学过Redis没有,如果学过Redis的其实可能会有疑问,因为Redis的事务不能保证所有操作要么都执行,要么都不执行,但是也叫做事务。Redis其实在官网就已经说明白了,官网中告诉大家事务中的某个命令失败了,之后的命令还是会被处理,Redis不会停止执行命令,也就是意味着不会回滚
Redis解释为什么不支持回滚
他们给出的回的就是首先如果命令出错那就是语法的错误,是属于个人的编程错误,而且这种情况应该被检测出来,而不是在生产环境出现,于是乎Redis为了速度更快不支持回滚操作
感觉很有道理的样子,但是又有点不对劲
好了,这下大家都知道事务是啥了,那么我们一起来看看分布式事务吧
刚才说的事务都是属于单体程序中,单机中这样是没问题的,通过普通的事务操作就可以来解决;当我们的系统逐渐变大,日益变强的同时,并发量和系统都随之而增加,当涉及到多个系统之间的配合来完成一个事务的时候,这就比较难办了,因为无法直接通过一个系统的数据库来完成
假设现在有订单系统、扣款系统、积分系统,这是属于三个系统,也就是分别在不同的数据库中,但是我需要保证三个系统中的服务要么全部成功、要么全部失败,其实像这种设计到多个库、多个系统之间的事务操作,也就是分布式事务了
分布式事务其实说简单也简单,其实就是有多个本地事务组合而成,对于分布式事务而言几乎满足不了ACID,其实对于单机事务大多是情况下也是无法全部满足ACID的,否则哪里来的四种隔离级别?所以更别说分布在不同数据库、不同系统之间的分布式事务了
分布式事务大致可以分为六种,但是其实这六种又可以按照三种思想来分类,接下来一起看看吧
2PC和3PC是一种强一致性事务,不过还是有数据的不一致、阻塞等风险,而且只能应用在数据库层面;而TCC是一种补偿性事务的思想,适用的范围应该是比较广,不过这种补偿性机制一般对业务的侵入性比较大,每一个操作都需要实现对应的三种方法;还有一种思想就是努力实现最终一致性事务,有本地消息、事务消息、和最大努力通知这三种方法,都是实现最终一致性事务,因此适用于于一些对于时间不敏感的业务
大致了解了这三类,接下来来细细学习每一种吧
2PC二阶段提交:准备阶段、提交阶段
2PC,又叫做二阶段提交,二阶段指的是准备阶段和提交两个阶段
二阶段提交属于一种强一致性的设计,2PC引入一个事务协调者的角色来协调管理各参与者的提交和回滚机制,我们来看下具体流程
准备阶段协调者会向各个参与者发送准备的命令,这个准备其实就是准备环境,可以理解成提交之前的准备工作
同步的等待所有的资源的响应之后,就到了万事俱备,只欠提交的状态了
提交阶段,提交阶段并不一定是提交事务,也有可能是回滚事务,如果第一阶段都准备成功,则第二阶段的提交就是提交事务;同理如果第一阶段未全部准备成功,则第二阶段提交的就是回滚事务了。假设第一阶段都准备成功,则协调者向所有参与者发送提交命令,然后接下来等待所有参与者都成功之后,返回事务执行成功
假设第一阶段有部分参与者返回失败的话,那么协调者则会向所有参与者都发送回滚事务的请求,即类似上图,向全部参与者发送回滚事务
说到这里其实有些小伙伴已经开始有疑问了,我知道了第一阶段有失败的如何处理了,但是如果第二阶段出现失败了咋整呢
其实这里分了两种情况,分别是第二阶段执行的是提交阶段、第二阶段执行的是回滚操作,这两种情况的处理方式其实是一样的,都是属于不断地重试,直到重试成功;对于提交来说,可以根据业务场景,执行一定次数的重试之后,尝试回滚;但是对于回滚操作,总不能执行成功操作吧
所以,如果第二阶段是回滚操作有失败,当失败次数达到一定次数的时候,最好的方法就是人工介入了
提交流程大致也分析的差不多了,接下来一起看看细节部分,2PC可以看成同步阻塞协议,同步阻塞的等待所有参与者的第一阶段都有响应之后,才会进行第二阶段的操作;对于Java基础很熟悉的小伙伴是不是很快想起来Java并发包中的一个工具类CountDownLatch,以及功能类似的CyclicBarrier,忘记的赶紧回忆下
其实2PC中对于这里的同步阻塞是有超时机制的,协调者等待参与者的响应超时的情况下,会默认失败,然后协调者直接向所有参与者发起回滚的命令,知道这次事务失败
上面这些都是基于参与者的角度来考虑的,那如果协调者出问题了呢
协调者如果是单点的,出现故障之后,可能会出现一些系统的问题,我们从流程的角度分析下:
准备阶段命令未发出,协调者故障,事务还没开始,问题不大;
准备阶段命令发出了,协调者故障,事务开始了,无论参与者都是成功还是失败,最终情况都很糟糕,因为参与者无法等到下一步的指令了,也就是卡碟了,不仅事务无法执行,还会锁定一些公用资源而阻塞其它系统;准备阶段命令发出,全部成功,第二阶段执行提交阶段命令发出,这种情况也是不行的,因为也可能因为分区和网络阻塞,某些参与者未收到提交命令,理想情况下如果参与者一次性全部收到提交命令,但是参与者有可能提交失败,这样还是需要重试,此时协调者挂了,也是不行
准备阶段命令发出,部分失败,第二阶段回滚命令发出,其实和上面情况类似,也是会出现各式各样的问题
既然单点协调者不行,那就来个多个的吧,通过选举机制再选一个新协调者
如果都处于第一阶段,其实都还好,事务还没提交,直接都会滚就好了;如果处于第二阶段,假设参与者都没挂,此时新协调者可以向所有参与者来进一步确认他们自身的情况来推断下一步该如何操作,如果个别参与者挂了,就比较尴尬了。比如协调者发送了回滚的命令,此时第一个参与者收到了并执行了,然后协调者和第一个参与者都挂掉了,此时其它参与者都没收到请求,然后新协调者来了,它询问了其它的参与者都回答OK,但是它不知道其中第一个参与者挂了,此时要是按照全部OK来处理,直接发送提交命令,就糟糕了,这不是我们想要的结果
其实虽然2PC协议上没说,但是在实现的时候我们需要灵活的让协调者将自己发过的请求在哪些地方都记一下,也就类似于日志记录,这样新的协调者来的时候就不、知道此时该不该发了
即使协调者知道自己应该发提交还是回滚请求,但是在参与者也一起挂了的情况下也是没用的,因为协调者无法知道参与者在挂之前有没有提交事务,其实这里最靠谱的方法,就是对每一步都进行相应的日志记录,重要的步骤最好还是强绑定日志记录的,否则操作成功了,日志记录失败那也很糟糕,总之就是要考虑各种极端的情况,尽最大努力去做到每个细节都考虑到
2PC是一种尽量保证强一致性的分布式事务,因为它是同步阻塞的,而同步阻塞就意味着在某些情况下会出现锁定资源的情况,而且单点一旦出现故障,就会造成资源锁定的情况
以下代码取自 <<Distributed System: Principles and Paradigms>>
协调者:
write START_2PC to local log; //开始事务
multicast VOTE_REQUEST to all participants; //广播通知参与者投票
while not all votes have been collected {
wait for any incoming vote;
if timeout { //协调者超时
write GLOBAL_ABORT to local log; //写日志
multicast GLOBAL_ABORT to all participants; //通知事务中断
exit;
}
record vote;
}//如果所有参与者都ok
if all participants sent VOTE_COMMIT and coordinator votes COMMIT {
write GLOBAL_COMMIT to local log;
multicast GLOBAL_COMMIT to all participants;
} else {
write GLOBAL_ABORT to local log;
multicast GLOBAL_ABORT to all participants;
}
参与者:
write INIT to local log; //写日志
wait for VOTE_REQUEST from coordinator;
if timeout { //等待超时
write VOTE_ABORT to local log;
exit;
}
if participant votes COMMIT {
write VOTE_COMMIT to local log; //记录自己的决策
send VOTE_COMMIT to coordinator;wait for DECISION from coordinator;
if timeout {
multicast DECISION_REQUEST to other participants; //超时通知
wait until DECISION is received; /* remain blocked*/
write DECISION to local log;
}
if DECISION == GLOBAL_COMMIT
write GLOBAL_COMMIT to local log;
else if DECISION == GLOBAL_ABORT
write GLOBAL_ABORT to local log;
} else {
write VOTE_ABORT to local log;
send VOTE_ABORT to coordinator;
}每个参与者维护一个线程处理其它参与者的DECISION_REQUEST请求:
while true {
wait until any incoming DECISION_REQUEST is received;
read most recently recorded STATE from the local log;
if STATE == GLOBAL_COMMIT
send GLOBAL_COMMIT to requesting participant;
else if STATE == INIT or STATE == GLOBAL_ABORT;
send GLOBAL_ABORT to requesting participant;
else
skip; /* participant remains blocked */
}
3PC三阶段提交:准备阶段、预提交阶段、提交阶段
3PC其实就是2PC的升级版,相比于2PC,参与者也引入了超时机制,并且还新增了一个阶段使得参与者可以利用这一阶段来统一各自的状态
3PC分为三个阶段:准备阶段、预提交阶段、提交阶段。看起来更像是把2PC中的提交阶段分为了预提交和提交的两个阶段, 但是这里的准备阶段其实就是询问参与者的自身状况,就是问你现在的状况如何,负载是不是超载,还可以再接受新的任务吗
而预提交阶段其实就是类似于2PC的准备阶段,就是除了事务的提交该做的都做了,就是之前的准备工作,但是在3PC中叫做预提交阶段
3PC是首先准备阶段并不会直接执行事务,而是先去询问此时的参与者是否有条件可以执行这个事务,因此不会直接锁住资源,而预提交阶段的引入则是为了起到了一个统状态的作用,在预处理阶段表面所有参与者都已经回应了
其实这也多引入了一个阶段,因此性能会差一些,而且绝大部分的情况下资源也都是没问题的,也就是可用的,这样等于每次明知可用但是还是得询问一次
当然,这其中哪一个阶段的参与者返回失败都会宣布事务失败,这个2PC也是一样的,当然到最后的提交阶段和2PC一样都是只要是提交请求也就只能通过不断的重试咯
我们上面说过2PC是同步阻塞的,协调者挂在了提交请求还未发出去的时候是最尴尬的,所有参与者都已经锁定了资源并且阻塞的等待着,于是引入了超时机制,参与者则不用直接干干的等着了,如果是等待提交命令超时,那么参与者就会提交事务了,因为到了这一阶段大概率都是提交的,如果是等待预提交超时,接下来也没啥影响
这里其实有一个问题,然后超时机制会带来数据不一致的问题,就是在等待提交命令的时候超时,那么参与者自动提交事务了,但是呢,也可能执行的是回滚机制,这样一来数据便出现了不一致了
3PC的引入是为了解决提交阶段2PC协调者和其中的部分参与者都挂了的情况下,然后之后的新选举的协调者不知道当前应该是该提交还是回滚的问题,新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明所以参与者都已经经过确认了,所以此时执行的就是提交命令了
3PC就是通过引入预提交阶段来是的参与者之间的状态得到真正的统一,也就是留了一个阶段让大家都同步,但是这也是只能让协调者知道如何做,并不能保证这样做一定是对的,这其实和上面的2PC的分析一直,因为挂了的参与者到底有没有执行事务是无法断定的,所以说呢,3PC通过预提交阶段可以减少故障时候的复杂性,但是并不能保证数据真正的一致,处理挂了的那个参与者也恢复了
一句话总结:3PC相比于2PC做了一定的参与者超时机制的改进,并且增加了预提交阶段,可以使故障恢复之后的协调者的决策复杂度降低,但整体的交互过程会变得更长,性能会有所下降,而且还会出现数据不一致的情况
TCC:Try-Confirm-Cancel
TCC属于业务层面的分布式事务,分布式事务不仅仅包含数据库层面的操作,还包括业务层面的操作,这时候TCC就要排上用场了
TCC指的就是Try、Confirm、Cancel三个步骤,Try指的是预留,指的是资源的预留和锁定;Confirm指的就是确认操作,这一步其实就是属于真正的执行了,真正的消耗资源来进行相应的业务提交操作;Cancel指的是撤销操作,可以理解为把预留阶段的动作销毁了,就是一个回滚操作
从思想上来看,其实是和2PC、3PC是类似的,都是先试探性的执行,先试探性的锁定资源,如果每一个参与者都没问题了,就可以执行真正的操作了,提交或者回滚
举个例子:一个事务要执行A、B、C三个操作,那么先对三个操作执行预留动作,如果所有都预留成功了那么就执行确认提交操作,如果其中至少有一个预留失败,那就都执行撤销的动作
TCC模型其中还有一个事务管理者的角色,用来记录TCC有关的全局事务操作的状态,并且准备提交或者回滚事务,其实这个是比较容易理解的,难点在于业务上的定义
怎么说呢,TCC这种是对业务的侵入较大和业务紧耦合,需要根据相应的特定的业务场景和业务逻辑来设定的响应操作,其实还有一点需要注意的是,撤销和确认的操作的执行的就是需要重试,就是需要保证操作的幂等性
TCC相对来说,适用的范围应该是更广的,但是这个是有一个缺点的,就是这个和业务是耦合的,需要大量的开发,因为都是在业务上的实现,等同于每个场景都需要三个方法来实现,就是嵌入业务,所以TCC是可以跨业务系统、跨数据库来实现事务
本地消息表
本地消息表,就是利用了各个系统的本地事务来实现分布式事务,这个呢,其实很简单的道理,其实就是会有一张存放本地消息的表,一般都是放在数据库中,然后在执行业务的时候,必须把业务的真正的执行操作和相应的这个操作的消息放入到消息表中这个操作,存放到同一个事务中,就是只要操作成功了,就必须保证该消息也成功的放入到本地的消息表中了
接下来调用下一个操作的时候,如果下一个操作调用成功了,就可以直接把消息的状态改成已成功,调用失败也没有关系,我们可以写一个定时任务来读取本地的消息表,然后筛选出未执行成功的消息再调用对应的服务,服务更新成功了,再改变消息的状态
其实这里也是需要重试机制,重试就得保证对应服务的方法是幂等的,而且一般重试也会有最大的次数,超过最大次数的时候可以人工介入
本地消息表实现的是业务的最终一致性,需要能够容忍数据暂时不一致的情况
消息事务
其实消息事务,最典型的就是属于RocketMQ中的实现了,而且应用的场景也是比较多的
RocketMQ的机制就是先给Broker发送事务消息,也就是半消息,半消息指的是这个消息对消费者来说不可见,然后发送成功后,发送之后会继续执行本地事务
第二步就是根据本地事务的执行结果向Broker发送Commit和Rollback命令,如果一直不发送,RocketMQ的发送方会提供一个反查事务状态的接口,用来反查相应的事务的结果到底是成功还是回滚
其实这也就是个超时机制,在一段时间内没有收到任何的操作请求,那么Broker就会通过相应的结果查出该事务是否成功执行呢,是Commit还是Rollback
如果是Commit,则broker就会发送这个消息到订阅方,然后再做对应的操作,做完了之后就可以消费这个消息,如果是Rollback则订阅方即收不到这个消息,等同于事务没有执行过
最大努力通知
其实最大努力通知我个人认为是一种思想,像上面的本地消息表、事务消息也是属于最大努力通知类型的
本地消息表会有后台任务定时查看未完成的任务的消息,然后去调用对应的服务,进行多次重试,当多次失败的时候就需要引入人工,这也是属于最大努力
事务消息也是属于类似,半消息被Commit之后就会发送到消费端了,如果消费端一直不消费或者消费不了则会一直重试,如果重试次数达到一定数量,该消息变回进入到私信队列,也是属于尽最大努力通知吧
这应该是属于一种思想,尽最大努力的达到事务的最终一致,适用于对时间不敏感的业务场景
好了,以上就是全部内容了,我是小鱼仙,你们的学习成长小伙伴
我希望有一天能够靠写字养活自己,现在还在磨练,这个时间可能会有很多年,感谢你们做我最初的读者和传播者。请大家相信,只要给我一份爱,我终究会还你们一页情的。
再次感谢大家能够读到这里,我后面会持续的更新技术文章以及一些记录生活的灵魂文章,如果觉得不错的,觉得【小仙】有点东西的话,求点赞、关注、分享三连
哦,对了!后续的更新文章我都会及时放到这里,欢迎大家点击观看,都是干货文章啊,建议收藏,以后随时翻阅查看
https://github.com/DayuMM2021/Java