分布式事务有哪些解决方案

基于XA协议的

两阶段提交和三阶段提交,需要数据库层面支持。

XA协议通常不是通过简单的单条SQL命令来完成的,而是通过一组命令/API调用来管理一个分布式事务的完整生命周期。这些命令通常由事务管理器 调用,而应用程序则通过高级抽象(如Java JTA)来间接使用。

一些核心命令:

命令/函数名 说明 常见实现举例
阶段一:准备阶段 xa_start 开启一个分布式事务分支,将其与一个全局事务ID(XID)关联。 C API / 数据库驱动内部调用
xa_end 结束当前线程与分布式事务分支的关联。 C API / 数据库驱动内部调用
xa_prepare 核心命令。事务管理器向所有参与者发出此命令。参与者将事务内容持久化到日志中,并锁定相关资源,然后返回“就绪”或“失败”状态。 MySQL: XA PREPARE '<xid>'
阶段二:提交/回滚阶段 xa_commit 提交命令。如果所有参与者都返回“就绪”,事务管理器向所有参与者发出此命令,要求提交事务。参与者完成持久化操作并释放资源。 MySQL: XA COMMIT '<xid>'
xa_rollback 回滚命令。如果任何参与者返回“失败”或超时,事务管理器向所有参与者发出此命令,要求回滚事务。参与者根据准备阶段的日志进行回滚并释放资源。 MySQL: XA ROLLBACK '<xid>'
恢复 xa_recover 事务管理器查询资源管理器,找回那些处于“准备完成”状态(即已PREPARE但未COMMIT/ROLLBACK)的分布式事务,用于故障恢复。

阶段提交示例

1. 两阶段提交流程

假设我们有一个全局事务,需要同时更新db1db2中的账户。

第一步:在两个数据库连接上分别开启并执行事务

1
2
3
4
5
6
7
8
9
-- 在连接1(负责账户A)上
XA START '12345','1'; -- '12345'是gtrid,'1'是bqual
UPDATE account SET balance = balance - 100 WHERE user = 'A';
XA END '12345','1';

-- 在连接2(负责账户B)上
XA START '12345','2'; -- 使用相同的gtrid '12345',不同的bqual '2'
UPDATE account SET balance = balance + 100 WHERE user = 'B';
XA END '12345','2';

第二步:准备阶段 - 事务管理器发出 PREPARE

1
2
3
4
5
-- 在连接1上
XA PREPARE '12345','1';

-- 在连接2上
XA PREPARE '12345','2';

此时,两个MySQL实例都将事务结果写入日志,并锁定相关行,进入PREPARED状态。

第三步:提交阶段 - 事务管理器发出 COMMIT 如果两个PREPARE都成功:

1
2
3
4
5
-- 在连接1上
XA COMMIT '12345','1';

-- 在连接2上
XA COMMIT '12345','2';

至此,分布式事务完成。

如果在第二步中任何一个PREPARE失败,或者在第三步前发生了故障,事务管理器则应向所有参与者发出:

1
2
XA ROLLBACK '12345','1';
XA ROLLBACK '12345','2';

2. 相关监控命令

  • 查看MySQL中处于PREPARED状态的XA事务:

    1
    XA RECOVER;

    这条命令会输出所有已经准备但尚未提交或回滚的XA事务的XID信息。

基于事务补偿机制的

TCC,基于业务层面实现

简述TCC事务模型

TCC(补偿事务)TryConfirmCancel

针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作

  • Try操作:做业务检查及资源预留
  • Confirm操作:做业务确认操作
  • Cancel操作:实现一个与Try相反的操作即回滚操作

执行流程
TM(事务管理器)首先发起所有的分支事务的try操作,任何一个分支事务的try操作执行失败,TM将会发起所有分支事务的Cancel操作;若try操作全部成功,TM将会发起所有分支事务的Confirm操作。其中Confirm/Cancel操作若执行失败,TM会进行重试。

特点与要求

  • TCC模型对业务的侵入性较强,改造的难度较大
  • 每个操作都需要有 tryconfirmcancel 三个接口实现
  • TCC中会添加事务日志,如果Confirm或者Cancel阶段出错,则会进行重试
  • ConfirmCancel操作需要支持幂等
  • 如果重试失败,则需要人工介入进行恢复和处理

结合一个经典的 “订单 -> 支付 -> 积分” 场景,来详细讲解 TCC事务 的完整流程。

这个场景包含三个微服务:

  • 订单服务:创建订单
  • 支付服务:扣减账户余额
  • 积分服务:增加用户积分

第一阶段:Try 阶段(业务检查与资源预留)

这个阶段的目标是完成所有业务的检查,并锁定必要的资源,为后续的确认提交做准备。

1. 订单服务 - tryCreateOrder

  • 操作:检查订单合法性(如商品是否存在、价格是否正确),然后将订单状态设置为 “预创建”,并锁定对应商品的库存。此时订单对用户不可见。
  • 资源预留:商品库存被锁定,防止超卖。

2. 支付服务 - tryPayment

  • 操作:检查用户账户余额是否充足。如果充足,则冻结支付所需的金额(例如,订单100元,则从可用余额中扣除100元,放入冻结金额字段)。
  • 资源预留:用户的100元资金被冻结,不能再用于其他支付。

3. 积分服务 - tryAddPoints

  • 操作:检查用户是否存在,然后准备一笔 “待增加” 的积分记录,状态为 **”预增加”**,并不实际改变用户的积分总额。
  • 资源预留:标记了一笔积分即将到账。

至此,Try阶段结束。 所有必要的业务检查都已通过,且资源都已预留。如果任何一个服务的Try操作失败(如库存不足、余额不足),则全局事务将进入Cancel阶段。


第二阶段:Confirm 阶段(业务确认)

此阶段在Try阶段全部成功之后执行。因为所有资源都已预留,所以Confirm操作必须成功,且需要是幂等的。

1. 订单服务 - confirmCreateOrder

  • 操作:将订单状态从 “预创建” 更新为 **”已创建”**,使订单对用户可见。
  • 特点:这是一个轻量的更新操作,因为所有检查在Try阶段已完成。

2. 支付服务 - confirmPayment

  • 操作:将之前冻结的金额从冻结字段中正式扣除。用户的账户余额实际减少。
  • 特点:真正的扣款发生在此刻。

3. 积分服务 - confirmAddPoints

  • 操作:将积分记录的状态从 “预增加” 更新为 “已增加”,并实际增加用户的积分总额。
  • 特点:用户真正收到积分。

至此,Confirm阶段成功,整个分布式事务完成。


补偿阶段:Cancel 阶段(回滚操作)

如果Try阶段有任何一步失败,或者事务管理器决定回滚,则会进入Cancel阶段。Cancel操作需要执行与Try操作相反的逻辑,释放Try阶段预留的资源,并且也必须是幂等的。

假设在tryPayment时发现用户余额不足:

1. 积分服务 - cancelAddPoints

  • 操作:删除或作废那条状态为 “预增加” 的积分记录。

2. 支付服务 - cancelPayment

  • 操作:无需操作,因为tryPayment已经失败,没有资金被冻结。或者,如果Try是部分成功(如冻结了资金但后续服务失败),则此操作需要解冻冻结的资金。

3. 订单服务 - cancelCreateOrder

  • 操作:将订单状态设置为 “已取消”,并释放在Try阶段锁定的商品库存。

至此,Cancel阶段完成,所有服务都回到了事务开始前的状态。


流程总览与关键点

阶段 订单服务 支付服务 积分服务 说明
Try 置订单为”预创建”,锁库存 检查并冻结资金 准备积分,状态为”预增加” 资源预留,任一失败则触发Cancel
Confirm 置订单为”已创建” 正式扣款 正式增加积分 业务确认,必须幂等
Cancel 置订单为”已取消”,释库存 解冻资金 作废积分记录 补偿回滚,必须幂等

核心要求与挑战

  1. 幂等性:Confirm和Cancel接口可能会因为网络问题被重复调用,必须保证多次调用的结果与一次调用相同。
  2. 空回滚:如果Try阶段没执行(如网络超时),但Cancel请求却来了,需要能够处理并返回成功。
  3. 防悬挂:如果Cancel请求先于Try请求到达(网络包乱序),要能识别并忽略后续来的Try请求。
  4. 业务侵入性高:需要将业务逻辑清晰地拆分为Try/Confirm/Cancel三个部分,设计和开发复杂度高。

总结:TCC通过业务层面的补偿机制,避免了像2PC那样长时间的资源锁定,提供了更高的性能和解耦能力,但代价是需要为每个服务设计并实现完整的补偿逻辑,对业务设计和开发的要求非常高。


本地消息表

这是一种最终一致性的解决方案,其核心思想是利用本地数据库的事务性,将消息生产和业务操作绑定在同一个本地事务中,然后通过异步重试机制来保证消息一定能被下游服务消费。

核心流程

  1. 消息生产与业务操作同步
    • 业务执行方在同一个本地数据库事务中:
      • 执行业务SQL操作(如:订单库中插入一条订单记录,状态为待确认)。
      • 向同一数据库的本地消息表插入一条消息记录(内容为“扣减库存”或“增加积分”等),此消息的状态为 进行中未发送
  2. 异步投递消息
    • 一个独立的消息发送者(通常是定时任务)轮询扫描本地消息表,抓取状态为 进行中 的消息。
    • 将消息内容发送给消息队列。
    • 如果发送成功,就将本地消息表中原记录的状态更新为 已发送 或直接删除该记录。
  3. 消息消费与确认
    • 下游服务(如库存服务)消费消息队列中的消息,执行自身的业务逻辑。
    • 执行成功后,下游服务可以选择向另一个回调队列发送一条“成功确认”消息。
  4. 补偿机制(扫表重试)
    • 这是该方案的关键。另一个定时任务会周期性地检查本地消息表中长时间处于 进行中 状态的消息。
    • 对于这些消息,它会重新执行第2步(投递消息)。由于消息可能已经被成功消费但确认失败,所以下游服务的接口必须支持幂等,防止重复处理。

优缺点

  • 优点
    • 方案简单,与具体MQ组件解耦,甚至可以用MySQL表代替MQ。
    • 实现了业务的最终一致性。
  • 缺点
    • 消息数据与业务数据强耦合,占用业务数据库资源。
    • 需要维护额外的定时任务来扫表和重发,架构稍显繁琐。
    • 实时性相对较差,依赖于定时任务的扫描间隔。

基于事务消息

这种方案通常需要支持事务消息模型的中间件(如RocketMQ)来原生支持。其核心思想是通过两阶段提交,将消息的投递与本地事务绑定,确保消息能成功发出,并且最终一定能被消费

核心流程(以RocketMQ为例)

  1. 发送半消息
    • 业务执行方首先向MQ Server发送一条 “半消息”“预备消息”。这条消息对下游消费者是不可见的,即此时不会被消费。
  2. 执行本地事务
    • MQ Server持久化半消息后,返回确认。
    • 业务执行方开始执行本地事务(如:在订单库中插入订单记录)。
  3. 提交或回滚消息
    • 本地事务执行完成后,根据结果向MQ Server发送 二次确认
      • Commit: 本地事务成功。MQ Server此时将半消息转为正式消息,下游消费者现在可以消费这条消息了。
      • Rollback: 本地事务失败。MQ Server将丢弃这条半消息。
      • 未知: 如果因为网络等原因,MQ Server没有收到二次确认。
  4. 回查机制
    • 对于状态为 未知 的消息,MQ Server会定期向消息生产者发起 回查
    • 生产者需要检查对应的本地事务的执行状态。
    • 根据检查结果(成功/失败),再次向MQ Server发送Commit或Rollback指令。
  5. 消息消费
    • 一旦消息被Commit,下游服务即可正常消费。同样,下游服务接口需要幂等处理,因为网络抖动可能导致消息重投。

优缺点

  • 优点
    • 数据隔离性好,消息逻辑不侵入业务数据库。
    • 由MQ中间件提供高可用的消息存储和可靠投递,可靠性更高。
    • 无需自己实现扫表任务,架构更优雅。
  • 缺点
    • 强依赖于支持事务消息的MQ,技术选型受限。
    • 需要实现回查接口,增加了开发工作量。

在XA协议中,对比二阶段,三阶段有什么改进

二阶段:

image.png

三阶段:

image.png

第一阶段(prepare):每个参与者执行本地事务但不提交,进入 ready 状态,并通知协调者已经准备就绪。

第二阶段(commit)当协调者确认每个参与者都 ready 后,通知参与者进行 commit 操作;如果有参与者 fail ,则发送 rollback 命令,各参与者做回滚。

问题:

  • 单点故障:一旦事务管理器出现故障,整个系统不可用(参与者都会阻塞住)
  • 数据不一致:在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
  • 响应时间较长:参与者和协调者资源都被锁住,提交或者回滚之后才能释放
  • 不确定性:当协调者管理器发送 commit 之后,并且此时只有一个参与者收到了 commit,那么当该参与者与事务管理器同时机之后,重新选举的事务管理器无法确定该条消息是否提交成功。

三阶段协议:主要是针对两阶段的优化,解决了2PC单点故障的问题,但是性能问题和不一致问题仍然没有根本解决

2PC两阶段提交 的缩写,英文全称为 Two-Phase Commit

引入了超时机制解决参与者阻塞的问题,超时后本地提交,2pc只有协调者有超时机制

  • 第一阶段:CanCommit阶段,协调者询问事务参与者,是否有能力完成此次事务。

  • 如果都返回yes,则进入第二阶段

  • 有一个返回no或等待响应超时,则中断事务,并向所有参与者发送abort请求

  • 第二阶段:PreCommit阶段,此时协调者会向所有的参与者发送PreCommit请求,参与者收到后开始执行事务操作。参与者执行完事务操作后(此时属于未提交事务的状态),就会向协调者反馈“Ack”表示我已经准备好提交了,并等待协调者的下一步指令。

  • 第三阶段:DoCommit阶段,在阶段二中如果所有的参与者节点都返回了Ack,那么协调者就会从“预提交状态”转变为“提交状态”。然后向所有的参与者节点发送“doCommit”请求,参与者节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈“Ack”消息,协调者收到所有参与者的Ack消息后完成事务。相反,如果有一个参与者节点未完成PreCommit的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送abort请求,从而中断事务。

如何理解RPC

远程过程调用

RPC要求在调用方中放置被调用的方法的接口。调用方只要调用了这些接口,就相当于调用了被调用的实际方法,十分易用。于是,调用方可以像调用内部接口一样调用远程的方法,而不用封装参数名和参数值等操作。

包含组件

  1. 动态代理:封装调用细节
  2. 序列化与反序列化:数据传输与接收
  3. 通信:可以选择七层的HTTP,四层的TCP/UDP
  4. 异常处理

RPC调用流程

调用方流程:
首先,调用方调用的是接口,必须为接口构造一个假的实现。显然,要使用动态代理。这样,调用方的调用就被动态代理接收到了。

动态代理收到调用后,应该想办法调用远程的实际实现。这包括下面几步:

  • 识别具体要调用的远程方法的IP、端口
  • 将调用方法的入参进行序列化
  • 通过通信将请求发送到远程的方法中

被调用方流程:
这样,远程的服务就接收到了调用方的请求。它应该:

  • 反序列化各个调用参数
  • 定位到实际要调用的方法,然后输入参数,执行方法
  • 按照调用的路径返回调用的结果