3PC
3PC 的引入是为了解决 2PC 同步阻塞和减少数据不一致的情况。
3PC 也就是多了一个阶段,一个询问的阶段,分别是准备、预提交和提交这三个阶段。
准备阶段单纯就是协调者去访问参与者,类似于你还好吗?能接请求不。
预提交其实就是 2PC 的准备阶段,除了事务的提交啥都干了。
提交阶段和 2PC 的提交一致。
3PC 多了一个阶段其实就是在执行事务之前来确认参与者是否正常,防止个别参与者不正常的情况下,其他参与者都执行了事务,锁定资源。
出发点是好的,但是绝大部分情况下肯定是正常的,所以每次都多了一个交互阶段就很不划算。
然后 3PC 在参与者处也引入了超时机制,这样在协调者挂了的情况下,如果已经到了提交阶段了,参与者等半天没收到协调者的情况的话就会自动提交事务。
不过万一协调者发的是回滚命令呢?你看这就出错了,数据不一致了。
还有维基百科上说 2PC 参与者准备阶段之后,如果协调者挂了,参与者是无法得知整体的情况的,因为大局是协调者掌控的,所以参与者相互之间的状况它们不清楚。
而 3PC 经过了第一阶段的确认,即使协调者挂了参与者也知道自己所处预提交阶段是因为已经得到准备阶段所有参与者的认可了。
简单的说就像加了个围栏,使得各参与者的状态得以统一。
小结 2PC 和 3PC
从上面已经知晓了 2PC 是一个强一致性的同步阻塞协议,性能已经是比较差的了。
而 3PC 的出发点是为了解决 2PC 的缺点,但是多了一个阶段就多了一次通讯的开销,而且是绝大部分情况下无用的通讯。
虽说引入参与者超时来解决协调者挂了的阻塞问题,但是数据还是会不一致。
可以看到 3PC 的引入并没什么实际突破,而且性能更差了,所以实际只有 2PC 的落地实现。
再提一下,2PC 还是 3PC 都是协议,可以认为是一种指导思想,和真正的落地还是有差别的。
TCC
不知道大家注意到没,不管是 2PC 还是 3PC 都是依赖于数据库的事务提交和回滚。
而有时候一些业务它不仅仅涉及到数据库,可能是发送一条短信,也可能是上传一张图片。
所以说事务的提交和回滚就得提升到业务层面而不是数据库层面了,而 TCC 就是一种业务层面或者是应用层的两阶段提交。
TCC 分为指代 Try、Confirm、Cancel ,也就是业务层面需要写对应的三个方法,主要用于跨数据库、跨服务的业务操作的数据一致性问题。
TCC 分为两个阶段,第一阶段是资源检查预留阶段即 Try,第二阶段是提交或回滚,如果是提交的话就是执行真正的业务操作,如果是回滚则是执行预留资源的取消,恢复初始状态。
比如有一个扣款服务,我需要写 Try 方法,用来冻结扣款资金,还需要一个 Confirm 方法来执行真正的扣款,最后还需要提供 Cancel 来进行冻结操作的回滚,对应的一个事务的所有服务都需要提供这三个方法。
可以看到本来就一个方法,现在需要膨胀成三个方法,所以说 TCC 对业务有很大的侵入,像如果没有冻结的那个字段,还需要改表结构。
我们来看下流程。
虽说对业务有侵入,但是 TCC 没有资源的阻塞,每一个方法都是直接提交事务的,如果出错是通过业务层面的 Cancel 来进行补偿,所以也称补偿性事务方法。
这里有人说那要是所有人 Try 都成功了,都执行 Comfirm 了,但是个别 Confirm 失败了怎么办?
这时候只能是不停地重试调失败了的 Confirm 直到成功为止,如果真的不行只能记录下来,到时候人工介入了。
TCC 的注意点
这几个点很关键,在实现的时候一定得注意了。
幂等问题,因为网络调用无法保证请求一定能到达,所以都会有重调机制,因此对于 Try、Confirm、Cancel 三个方法都需要幂等实现,避免重复执行产生错误。
空回滚问题,指的是 Try 方法由于网络问题没收到超时了,此时事务管理器就会发出 Cancel 命令,那么需要支持 Cancel 在未执行 Try 的情况下能正常的 Cancel。
悬挂问题,这个问题也是指 Try 方法由于网络阻塞超时触发了事务管理器发出了 Cancel 命令,但是执行了 Cancel 命令之后 Try 请求到了,你说气不气。
这都 Cancel 了你来个 Try,对于事务管理器来说这时候事务已经是结束了的,这冻结操作就被“悬挂”了,所以空回滚之后还得记录一下,防止 Try 的再调用。
TCC 变体
上面我们说的是通用型的 TCC,它需要改造以前的实现,但是有一种情况是无法改造的,就是你调用的是别的公司的接口。
没有 Try 的 TCC
比如坐飞机需要换乘,换乘的又是不同的航空公司,比如从 A 飞到 B,再从 B 飞到 C,只有 A - B 和 B - C 都买到票了才有意义。
这时候的选择就没得 Try 了,直接调用航空公司的买票操作,当两个航空公司都买成功了那就直接成功了,如果某个公司买失败了,那就需要调用取消订票接口。
也就是在第一阶段直接就执行完整个业务操作了,所以要重点关注回滚操作,如果回滚失败得有提醒,要人工介入等。
这其实就是 TCC 的思想。
异步 TCC
这 TCC 还能异步?其实也是一种折中,比如某些服务很难改造,并且它又不会影响主业务决策,也就是它不那么重要,不需要及时的执行。
这时候可以引入可靠消息服务,通过消息服务来替代个别服务来进行 Try、Confirm、Cancel 。
Try 的时候只是写入消息,消息还不能被消费,Confirm 就是真正发消息的操作,Cancel 就是取消消息的发送。
这可靠消息服务其实就类似于等下要提到的事务消息,这个方案等于糅合了事务消息和 TCC。
TCC 小结
可以看到 TCC 是通过业务代码来实现事务的提交和回滚,对业务的侵入较大,它是业务层面的两阶段提交,。
它的性能比 2PC 要高,因为不会有资源的阻塞,并且适用范围也大于 2PC,在实现上要注意上面提到的几个注意点。
它是业界比较常用的分布式事务实现方式,而且从变体也可以得知,还是得看业务变通的,不是说你要用 TCC 一定就得死板的让所有的服务都改造成那三个方法。
本地消息表
本地消息就是利用了本地事务,会在数据库中存放一直本地事务消息表,在进行本地事务操作中加入了本地消息的插入,即将业务的执行和将消息放入消息表中的操作放在同一个事务中提交
这样本地事务执行成功的话,消息肯定也插入成功,然后再调用其他服务,如果调用成功就修改这条本地消息的状态。
如果失败也不要紧,会有一个后台线程扫描,发现这些状态的消息,会一直调用相应的服务,一般会设置重试的次数,如果一直不行则特殊记录,待人工介入处理。
可以看到还是很简单的,也是一种最大努力通知思想。
事务消息
这个其实我写过一篇文章,专门讲事务消息,从源码层面剖析了 RocketMQ 、Kafka 的事务消息实现,以及两者之间的区别。
在这里我不再详细阐述,因为之前的文章写的很详细了,大概四五千字吧。我就附上链接了:事务消息
Seata 的实现
首先什么是 Seata ,摘抄官网的一段话。
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
可以看到提供了很多模式,我们先来看看 AT 模式。
AT模式
AT 模式就是两阶段提交,前面我们提到了两阶段提交有同步阻塞的问题,效率太低了,那 Seata 是怎么解决的呢?
AT 的一阶段直接就把事务提交了,直接释放了本地锁,这么草率直接提交的嘛?当然不是,这里和本地消息表有点类似,就是利用本地事务,执行真正的事务操作中还会插入回滚日志,然后在一个事务中提交。
这回滚日志怎么来的?
通过框架代理 JDBC 的一些类,在执行 SQL 的时候解析 SQL 得到执行前的数据镜像,然后执行 SQL ,再得到执行后的数据镜像,然后把这些数据组装成回滚日志。
再伴随的这个本地事务的提交把回滚日志也插入到数据库的 UNDO_LOG 表中(所以数据库需要有一张UNDO_LOG 表)。
这波操作下来在一阶段就可以没有后顾之忧的提交事务了。
然后一阶段如果成功,那么二阶段可以异步的删除那些回滚日志,如果一阶段失败那么可以通过回滚日志来反向补偿恢复。
这时候有细心的同学想到了,万一中间有人改了这条数据怎么办?你这镜像就不对了啊?
所以说还有个全局锁的概念,在事务提交前需要拿到全局锁(可以理解为对这条数据的锁),然后才能顺利提交本地事务。
如果一直拿不到那就需要回滚本地事务了。
官网的示例很好,我就不自己编了,以下部分内容摘抄自 Seata 官网的示例:
此时有两个事务,分别是 tx1、和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。
tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待全局锁 。
可以看到 tx2 的修改被阻塞了,之后重试拿到全局锁之后就能提交然后释放本地锁。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的全局锁等锁超时,放弃全局锁并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程全局锁在 tx1 结束前一直是被 tx1 持有的,所以不会发生脏写的问题。
然后 AT 模式默认全局是读未提交的隔离级别,如果应用在特定场景下,必需要求全局的读已提交 ,可以通过 SELECT FOR UPDATE 语句的代理。
当然前提是你本地事务隔离级别是读已提交及以上。
AT 模式小结
可以看到通过代理来无侵入的得到数据的前后镜像,组装成回滚日志伴随本地事务一起提交,解决了两阶段的同步阻塞问题。
并且利用全局锁来实现写隔离。
为了总体性能的考虑,默认是读未提交隔离级别,只代理了 SELECT FOR UPDATE 来进行读已提交的隔离。
这其实就是两阶段提交的变体实现。
TCC 模式
没什么花头,就是咱们上面分析的需要搞三个方法, 然后把自定义的分支事务纳入到全局事务的管理中
我贴一张官网的图应该挺清晰了。
Saga 模式
这个 Saga 是 Seata 提供的长事务解决方案,适用于业务流程多且长的情况下,这种情况如果要实现一般的 TCC 啥的可能得嵌套多个事务了。
并且有些系统无法提供 TCC 这三种接口,比如老项目或者别人公司的,所以就搞了个 Saga 模式,这个 Saga 是在 1987 年 Hector & Kenneth 发表的论⽂中提出的。
那 Saga 如何做呢?来看下这个图。
假设有 N 个操作,直接从 T1 开始就是直接执行提交事务,然后再执行 T2,可以看到就是无锁的直接提交,到 T3 发现执行失败了,然后就进入 Compenstaing 阶段,开始一个一个倒回补偿了。
思想就是一开始蒙着头干,别怂,出了问题咱们再一个一个改回去呗。
可以看到这种情况是不保证事务的隔离性的,并且 Saga 也有 TCC 的一样的注意点,需要空补偿,防悬挂和幂等。
而且极端情况下会因为数据被改变了导致无法回滚的情况。比如第一步给我打了 2 万块钱,我给取出来花了,这时候你回滚,我账上余额已经 0 了,你说怎么办嘛?难道给我还搞负的不成?
这种情况只能在业务流程上入手,我写代码其实一直是这样写的,就拿买皮肤的场景来说,我都是先扣钱再给皮肤。
假设先给皮肤扣钱失败了不就白给了嘛?这钱你来补啊?你觉得用户会来反馈说皮肤给了钱没扣嘛?
可能有小机灵鬼说我到时候把皮肤给改回去,嘿嘿这种事情确实发生过,啧啧,被骂的真惨。
所以正确的流程应该是先扣钱再给皮肤,钱到自己袋里先,皮肤没给成功用户自然而然会找过来,这时候再给他呗,虽说可能你写出了个 BUG ,但是还好不是个白给的 BUG。
所以说这点在编码的时候还是得注意下的。
最后
可以看到分布式事务还是会有各种问题,一般分布式事务的实现还是只能达到最终一致性。
极端情况下还是得人工介入,所以做好日志记录很关键。
还有编码的业务流程,要往利于公司的方向写,就例如先拿到用户的钱,再给用户东西这个方向,切记。
在上分布式事务之前想想,有没有必要,能不能改造一下避免分布式事务?
再极端一点,你的业务有没有必要上事务?
最后个人能力有限,如有纰漏请赶紧联系鞭挞我,如果觉得文章不错还望点个在看支持一下哟。
巨人的肩膀
分布式协议与算法实战,韩健
分布式数据库30讲,王磊
seata.io