大家好,我是北陌,上次发文的时候还是上次发文的时候,这篇文章分享分布式事务,看完要是你们不懂,那一定是不明白。
从本地事务到分布式事务
事务大家应该都知道,事务将一组操作纳入到一个不可分割的执行单元,这个执行单元里的操作都成功时才能提交成功。
简单地说,事务提供一种要么不做,要么全做机制。
ACID
我们先简单了解一下事务的四大特性:
- A 原子性(Atomicity)
一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会出现部分成功部分失败的情况。。
- C 一致性(Consistency)
事务的一致性指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。如果事务成功地完成,那么系统中所有变化将正确地应用,系统处于有效状态。如果在事务中出现错误,那么系统中的所有变化将自动地回滚,系统返回到原始状态。
- I 隔离性(Isolation)
指的是在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。事务查看数据更新时,数据所处的状态要么是另一事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看到中间状态的数据。
- D 持久性(Durability)
指的是只要事务成功结束,它对数据库所做的更新就必须永久保存下来。即使发生系统崩溃,重新启动数据库系统后,数据库还能恢复到事务成功结束时的状态。
单体事务
在单体架构时代,所有的业务只用一个数据库。
单体架构时代事务的是实现很简单,我们操作的是同一个数据库,利用数据库本身提供的事务机制支持就可以了。
例如我们比较熟悉的MySQL数据库:
- 事务的隔离性是通过数据库锁的机制实现的。
- 事务的一致性由undo log来保证:undo log是逻辑日志,记录了事务的insert、update、deltete操作,回滚的时候做相反的delete、update、insert操作来恢复数据。
- 事务的原子性和一持久性由redo log来保证:redolog被称作重做日志,是物理日志,事务提交的时候,必须先将事务的所有日志写入redo log持久化,到事务的提交操作才算完成。
详细了解建议阅读《MySQL技术内幕 InnoDB存储引擎》7.2节。
分布式事务
随着业务发展,单体架构顶不住了,慢慢进入分布式时代——SOA或者粒度更细的微服务。
当然伴随而来的就是分库分表。
- 我们可能会根据业务服务拆分的方式,对应地垂直拆分大库,例如原始大库拆分成订单库、商品库、支付库。
- 同时由于业务数据可能会高速增加,很快就成了亿级,我们不得不又水平分库,来减轻单个数据库的压力。
不管是怎么分库的,最后的结果就是我们一个操作可能要横跨多个数据库。
数据库本身的事务机制只能保证它自己这个库的事务,但是没法保证到其它的库。我们要保证跨多个库的操作还具备事务的特性,就不得不上分布式事务了。
在前面 分布式必备理论基础:CAP和BASE里,讲了分布式的理论基础——CAP和BASE,这里就不再多讲。
我们只需要知道,BASE理论是对CAP中AP的一个延申,在没法保证强一致性的前提下,尽可能达到最终的一致性。
我们的分布式事务通常也做不到本地事务那么强的一致性,一般都是对一致性(Consistency)适当做了一些放宽,只需要达到最终的一致性。
分布式事务解决方案
XA /2PC两阶段提交
XA
XA是一个分布式事务协议,由Tuxedo提出。
在这个协议里,有三个角色:
- AP(Application):应用系统(服务)
- TM(Transaction Manager):事务管理器(全局事务管理)
- RM(Resource Manager):资源管理器(数据库)
XA规范主要定义了 事务管理器(Transaction Manager)和资源管理器(Resource Manager)之间的接口。
XA协议采用两阶段提交方式来管理分布式事务。XA接口提供资源管理器与事务管理器之间进行通信的标准接口。
2PC 两阶段提交
两阶段提交的思路可以概括为: 参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情况决定各参与者是否要提交操作还是中止操作。
两阶段提交的两个阶段:第一阶段:准备阶段,第二阶段:提交阶段
准备阶段 Prepares
协调者向所有参与者询问是否可以执行提交操作,所有参与者执行事务,将结果返回给协调者。
提交阶段 commit
- 如果第一阶段汇中所有参与者都返回yes响应,协调者向所有参与者发出提交请求,所有参与者提交事务
- 如果第一阶段中有一个或者多个参与者返回no响应,协调者向所有参与者发出回滚请求,所有参与者进行回滚操作
两阶段提交优点:尽量保证了数据的强一致,但不是100%一致
两阶段提交同样有一些缺点:
- 单点故障由于协调者的重要性,一旦协调者发生故障,参与者会一直阻塞,尤其时在第二阶段,协调者发生故障,那么所有的参与者都处于锁定事务资源的状态中,而无法继续完成事务操作。
- 同步阻塞它是一个强一致性的同步阻塞协议,也就是所谓刚性事务,事务执⾏过程中需要将所需资源全部锁定,会比较影响性能。
- 数据不一致在第二阶段中,当协调者想参与者发送提交事务请求之后,由于网络抖动,如果第二阶段只有部分参与者收到提交请求,那么就会导致数据不一致。
3PC 三阶段提交
三阶段提交(3PC)是二阶段提交(2PC)的一种改进版本 ,为解决两阶段提交协议的单点故障和同步阻塞问题。上边提到两阶段提交,当协调者崩溃时,参与者不能做出最后的选择,就会一直保持阻塞锁定资源。
2PC 中只有协调者有超时机制,3PC 在协调者和参与者中都引入了超时机制,协调者出现故障后,参与者就不会一直阻塞。而且在第一阶段和第二阶段中又插入了一个预提交阶段,保证了在最后提交阶段之前各参与节点的状态是一致的。
三阶段提交的三个阶段:CanCommit,PreCommit,DoCommit三个阶段
准备阶段 CanCommit
协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。
预提交阶段 PreCommit
协调者根据参与者在准备阶段的响应判断是否执行事务还是中断事务
- 如果所有参与者都返回Yes,则执行事务
- 如果参与者有一个或多个参与者返回No或者超时,则中断事务
参与者执行完操作之后返回ACK响应,同时开始等待最终指令。
提交阶段 DoCommit
协调者根据参与者在准备阶段的响应判断是否执行事务还是中断事务
- 如果所有参与者都返回正确的ACK响应,则提交事务
- 如果参与者有一个或多个参与者收到错误的ACK响应或者超时,则中断事务
- 如果参与者无法及时接收到来自协调者的提交或者中断事务请求时,在等待超时之后,会继续进行事务提交
协调者收到所有参与者的ACK响应,完成事务。
可以看出,三阶段提交解决的只是两阶段提交中 单体故障和同步阻塞的问题,因为加入了超时机制,这里的超时的机制作用于 预提交阶段 和 提交阶段。如果等待 预提交请求 超时,参与者直接回到准备阶段之前。如果等到提交请求超时,那参与者就会提交事务了。
无论是2PC还是3PC都不能保证分布式系统中的数据100%一致
TCC补偿事务
TCC Try-Confirm-Cancel 的简称,是两阶段提交的一个变种,针对每个操作,都需要有一个其对应的确认和取消操作,当操作成功时调用确认操作,当操作失败时调用取消操作,类似于二阶段提交,只不过是这里的提交和回滚是针对业务上的,所以基于TCC实现的分布式事务也可以看做是对业务的一种补偿机制。
TCC的三阶段:
- Try 阶段:对业务系统做检测及资源预留
- Confirm 阶段:对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功
- Cancel 阶段:在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放
在Try阶段,是对业务系统进行检查及资源预览,比如订单和库存操作,需要检查库存剩余数量是否够用,并进行预留,预留操作的话就是新建一个可用库存数量字段,Try阶段操作是对这个可用库存数量进行操作。
例如下单减库存的操作:
执行流程:
- Try阶段:订单系统将当前订单状态设置为支付中,库存系统校验当前剩余库存数量是否大于1,然后将可用库存数量设置为库存剩余数量-1,
- 如果Try阶段执行成功,执行Confirm 阶段,将订单状态修改为支付成功,库存剩余数量修改为可用库存数量
- 如果Try阶段执行失败,执行Cancel 阶段,将订单状态修改为支付失败,可用库存数量修改为库存剩余数量
TCC 不存在资源阻塞的问题,因为每个方法都直接进行事务的提交,一旦出现异常通过则 Cancel 来进行回滚补偿,这也就是常说的补偿性事务。
但是,使用TCC,原本一个方法,现在却需要三个方法来支持,可以看到 TCC 对业务的侵入性很强,而且这种模式并不能很好地被复用,会导致开发量激增。还要考虑到网络波动等原因,为保证请求一定送达都会有重试机制,所以还需要考虑接口的幂等性。
本地消息表
本地消息表的核心思想是将分布式事务拆分成本地事务进行处理。
例如,可以在订单库新增一个消息表,将新增订单和新增消息放到一个事务里完成,然后通过轮询的方式去查询消息表,将消息推送到MQ,库存系统去消费MQ。
执行流程:
- 订单服务,添加一条订单和一条消息,在一个事务里提交
- 订单服务,使用定时任务轮询查询状态为未同步的消息表,发送到MQ,如果发送失败,就重试发送
- 库存服务,接收MQ消息,修改库存表,需要保证幂等操作
- 如果修改成功,调用rpc接口修改订单系统消息表的状态为已完成或者直接删除这条消息
- 如果修改失败,可以不做处理,等待重试
订单服务中的消息有可能由于业务问题会一直重复发送,所以为了避免这种情况可以记录一下发送次数,当达到次数限制之后报警,人工接入处理;库存服务需要保证幂等,避免同一条消息被多次消费造成数据不一致。
本地消息表这种方案实现了最终一致性,需要在业务系统里增加消息表,业务逻辑中多一次插入的DB操作,所以性能会有损耗,而且最终一致性的间隔主要有定时任务的间隔时间决定。
MQ消息事务
消息事务的原理是将两个事务通过消息中间件进行异步解耦。
订单服务执行自己的本地事务,并发送MQ消息,库存服务接收消息,执行自己的本地事务,乍一看,好像跟本地消息表的实现方案类似,只是省去 了对本地消息表的操作和轮询发送MQ的操作,但实际上两种方案的实现是不一样的。
消息事务一定要保证业务操作与消息发送的一致性,如果业务操作成功,这条消息也一定投递成功。
执行流程:
- 发送prepare消息到消息中间件
- 发送成功后,执行本地事务
- 如果事务执行成功,则commit,消息中间件将消息下发至消费端
- 如果事务执行失败,则回滚,消息中间件将这条prepare消息删除
- 消费端接收到消息进行消费,如果消费失败,则不断重试
消息事务依赖于消息中间件的事务消息,例如我们熟悉的RocketMQ就支持事务消息(半消息),也就是只有收到发送方确定才会正常投递的消息。
这种方案也是实现了最终一致性,对比本地消息表实现方案,不需要再建消息表,对性能的损耗和业务的入侵更小。
最大努力通知
最大努力通知相比实现会简单一些,适用于一些最终一致性要求较低的业务,比如支付通知,短信通知这种业务。
以支付通知为例,业务系统调用支付平台进行支付,支付平台进行支付,进行操作支付之后支付平台会去同步通知业务系统支付操作是否成功,如果不成功,会一直异步重试,但是会有一个最大通知次数,如果超过这个次数后还是通知失败,就不再通知,业务系统自行调用支付平台提供一个查询接口,供业务系统进行查询支付操作是否成功
执行流程:
- 业务系统调用支付平台支付接口, 并在本地进行记录,支付状态为支付中
- 支付平台进行支付操作之后,无论成功还是失败,同步给业务系统一个结果通知
- 如果通知一直失败则根据重试规则异步进行重试,达到最大通知次数后,不再通知
- 支付平台提供查询订单支付操作结果接口
- 业务系统根据一定业务规则去支付平台查询支付结果
Saga事务
Saga事务,核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。
和本地事务undo log有点像,出问题了,逆向操作来挽救。
Sega简介:
- Saga = Long Live Transaction (LLT,长活事务)
- LLT = T1 + T2 + T3 + … + Ti(Ti为本地短事务)
- 每个本地事务Ti 有对应的补偿 Ci
Sega的执行顺序:
- 正常情况:T1 T2 T3 … Tn
- 异常情况:T1 T2 T3 C3 C2 C1
Saga两种恢复策略
- 向后恢复,如果任意本地子事务失败,补偿已完成的事务。如异常情况的执行顺序T1 T2 Ti Ci C2 C1.
- 向前恢复,即重试失败的事务,假设最后每个子事务都会成功。执行顺序:T1, T2, …, Tj(失败), Tj(重试),…, Tn。
举个例子,假设用户下订单,花50块钱购买了10瓶可乐,则有这么一些短事务和回滚操作:
T1=下订单 => T2=用户扣50块钱 => T3=用户加10瓶可乐= > T4=库存减10瓶可乐
C1=取消订单 => C2= 给用户加50块钱 => C3 =用户减10朵玫瑰 = > C4=库存加10朵玫瑰
Seata
看了这么些事务的方案,介绍了相关的原理,但是这些原理怎么落地呢?各种各样的坑怎么处理呢?
—— 人生苦短,我用开源。
阿里巴巴开源了一套开源分布式事务解决方案——Seata。Seata可能并不称之为完美,但对代码入侵性非常小,基本环境搭建完成的话,使用的时候在只需要方法上添加一个注解@GlobalTransactional就可以开启全局事务。
Seata 也是从两段提交演变而来的一种分布式事务解决方案,提供了 AT、TCC、SAGA 和 XA 等事务模式,我们来看一下AT模式。
Seata 中主要有这么几种角色:
TC(Transaction Coordinator):事务协调者。管理全局的分支事务的状态,用于全局性事务的提交和回滚。
TM(Transaction Manager):事务管理者。用于开启、提交或回滚事务。
RM(Resource Manager):资源管理器。用于分支事务上的资源管理,向 TC 注册分支事务,上报分支事务的状态,接收 TC 的命令来提交或者回滚分支事务。
我们看一下Seata大概的一个工作流程:
执行流程:
- 服务A中的 TM 向 TC 申请开启一个全局事务,TC 就会创建一个全局事务并返回一个唯一的 XID
- 服务A中的 RM 向 TC 注册分支事务,然后将这个分支事务纳入 XID 对应的全局事务管辖中
- 服务A开始执行分支事务
- 服务A开始远程调用B服务,此时 XID 会根据调用链传播
- 服务B中的 RM 也向 TC 注册分支事务,然后将这个分支事务纳入 XID 对应的全局事务管辖中
- 服务B开始执行分支事务
- 全局事务调用处理结束后,TM 会根据有误异常情况,向 TC 发起全局事务的提交或回滚
- TC 协调其管辖之下的所有分支事务,决定是提交还是回滚
关于Seata的使用,和更详细的原理,这里挖个坑,以后有时间再细讲。
总结
上边简单介绍了 2PC、3PC、TCC、本地消息表、最大努力通知、MQ、Sega、Seata 这8种分布式事务解决方案,但不管我们选哪一种方案,我们可以看到真要落地要考虑的点都很多,一个不慎,可能踩坑。
即使是看起来很省心的Seata,我之前的项目花了不少w买了它的商业化版本GTS,但是支持方仍然列出了一些“禁忌”,像长事务、大量数据、热点数据、异步调用等等,都可能会出现问题。
所以在项目中应用分布式事务要谨慎再谨慎,除非真的有一致性要求比较强的场景,能不用就尽量不用。
如果觉得文章有帮助, 点赞、关注、收藏 素质三连,抱拳!