浅谈分布式事务

2026/7/15 18:25:33Henry4 阅读0 点赞0 评论

事务 Transaction

说简单点就是满足下面四种特性(ACID)的一段特殊的执行程序,就被称为事务。

  • 原子性 Atomicity​:事务中的操作要么全部成功,要么全部失败。
  • 一致性 Consistency​:事务执行前后,数据都满足业务约束。
  • 隔离性 Isolation​:并发事务之间尽量互不影响。
  • 持久性 Durability​:事务提交后,数据修改会被永久保存。

当事务的影响范围局限在一个关系型数据库范围内时,很多时候上述四项性质是能够水到渠成地得到实现的,但是倘若事务涉及修改的对象是跨数据库甚至跨服务跨存储组件时,这个问题就开始变得复杂了。

具体场景问题

假设我们在维护一个电商后台系统,每当在处理一笔来自用户创建订单的请求时,需要执行两步操作:

  • 从账户系统中,扣减用户的账户余额
  • 从库存系统中,扣减商品的剩余库存

从业务流程上来说,这个流程需要保证具备事务的原子性,即两个操作需要能够一气呵成地完成执行,要么同时成功,要么同时失败,不能够出现数据状态不一致的问题,比如发生从用户账户扣除了金额但商品库存却扣减失败的问题。

然而从技术流程上来讲,两个步骤是相对独立的两个操作,底层涉及到的存储介质也是相互独立的,因此无法基于本地事务的实现方式。

在我这个从来没有处理过分布式场景的视角下来看,其实很难在短时间能够想明白这种场景下的解决方案,对于这类问题,我们通常是调研行业内的成熟方案,既能学习,又确保稳定与正确性。

但对于这些场景,我们需要有以下的基本认知:

  • 在分布式事务中,我们谈到的数据状态一致性,指的是数据的最终一致性,而非数据的即时一致性,因为即时一致性通常是不切实际的;
  • 没有分布式事务能够保证数据状态具备百分之百的一致性,根本原因就在于网络环境和第三方系统的不稳定性。

在这样的预期下,我们再去寻找合理的解决方案。

事务消息方案 (TX Msg)

一类偏狭义的分布式事务解决方案是基于消息队列 Message Queue (MQ) 实现的事务消息 Transaction Message。之所以称之为狭义的分布式事务解决方案,是因为这个方案依然会存在一系列的问题,后文详述。

RocketMQ

https://github.com/apache/rocketmq
https://rocketmq.apache.org/docs/4.x/producer/06message5/

RocketMQ 是阿里基于 Java 实现并托管于 Apache 基金会的王者级开源消息队列组件,其中事务消息 TX Msg 也是 RocketMQ 现有的一项能力。

TX Msg

基于 MQ 实现分布式事务

我们知道在 MQ 组件中,通常能够为我们保证的一项能力是:投递到 MQ 中的消息能至少被下游消费者 Consumer 消费到一次,即所谓的 At least once 语义。

基于此,MQ 组件能够保证消息不会在消费环节丢失,但是无法解决消息的重复性问题。

因此,倘若我们需要追求精确消费一次的目标,则下游的 consumer 还需要基于消息的唯一键执行幂等去重操作,在 At least once 的基础上过滤掉重复消息,最终达到 Exactly once 的语义。

该问题也在我最近实习中开发的 IM 微服务得到体现,基于 Outbox 事务 + Kafka MQ 实现的消息链路就将存在上述问题。一条消息的发出涉及一条很长的链路:

TEXT
客户端 -> IM 微服务 -> 用户中心鉴权及关系验证 -> 数据库事务写入 Outbox -> 异步投放 Outbox Event 至 Kafka -> 异步消费 Kafka 消息 -> 推送消息至客户端

目前我们简单的通过幂等键来解决消息可能被重复消费的问题。

依赖于 MQ 中 At least once 的性质,完全可以简单认为,只要把一条消息成功投递到 MQ 组件中,它就一定被下游 Consumer 消费,至少不会发生消息丢失的问题。

倘若我们需要执行一个分布式事务,事务流程中包含需要在服务 A 中执行的 [动作 I] 以及需要在服务 B 中执行的 [动作 II],此时可以基于如下思路串联流程:

  1. 以服务 A 作为 MQ 生产方 producer,服务 B 作为 MQ 消费方 Consumer;
  2. 服务 A 首先在执行 [动作 I],执行成功后往 MQ 中投递消息,驱动服务 B 执行 [动作 II];
  3. 服务 B 消费到消息后,完成 [动作 II] 的执行。

对上述流程进行总结,其具备如下优势:

  • 服务 A 和服务 B 通过 MQ 组件实现异步解耦,从而提高系统处理整个事务流程的吞吐量;
  • 当服务 A 执行 [动作 I] 失败后,可以选择不投递消息从而熔断流程,因此保证不会出现 [动作 II] 执行成功,而 [动作 I] 执行失败的不一致问题;
  • 基于 MQ At least once 的语义,服务 A 只要成功消息的投递,就可以相信服务 B 一定能消费到该消息,至少服务 B 能感知到 [动作 II] 需要执行;
  • 依赖于 MQ 消费侧的 ACK 机制,可以实现服务 B 有限轮次的重试能力,即当服务 B 执行 [动作 II] 失败后,可以给予 MQ Bad ACK,从而通过消息重发的机制实现 [动作 II] 的重试,提高 [动作 II] 的执行成功率。

与之相对的,上述流程也具备如下几项局限性:

  • 问题 1:服务 B 消费到消息执行 [动作 II] 可能发生失败,即便依赖于 MQ 重试也无法保证动作一定能执行成功,此时缺乏令服务 A 回滚 [动作 I] 的机制。因此很可能出现 [动作 I] 执行成功,而 [动作 II] 执行失败的不一致问题;
  • 问题 2:在这个流程中,服务 A 需要执行的操作有两步:执行 [动作 I] 和投递消息,这两个步骤本质上也无法保证原子性,即可能出现服务 A 执行 [动作 I] 成功,而投递消息失败的问题。

针对问题 1 是无能为力的,因为这个问题本身就脱离于事务消息的领域范畴之外,需要通过另一类分布式事务的实现方案加以解决。

而针对于问题 2 的解决思路,则正是本节所重点探讨的话题。

本地事务+消息投递

不难想到有两种实现路径:

  1. 先执行本地事务,后执行消息投递;
  2. 先执行消息投递,后执行本地事务。

其各自的优缺点也十分明显,如果先执行本地事务,成功后再投递消息,虽然保证了如果下游收到消息,上游一定执行成功,但却没法保证上游执行成功,下游一定能收到消息。一旦消息持续无法送出,那么回滚已经提交的本地事务,代价是非常高的。

如果先投消息,再执行本地事务,问题也很明显,消息一经投递,可能会很快被下游所消费,因为在 MQ 链路中,通常我们都会使用多个 Consumer 并行对某个 Topic 进行消费,以保证消息的吞吐量,那在这样的场景下,一旦后续事务执行失败,那么已投递的消息或许早已消费,覆水难收了。

被小徐先生说中了,针对于以上问题,我们能够想到一个很简单,看似很完美的做法(Codex 也会犯这个错误):

  • 首先 begin transaction,开启本地事务;
  • 在事务中,执行本地状态数据的更新;
  • 完成数据更新后,不立即 commit transaction;
  • 执行消息投递操作;
  • 倘若消息投递成功,则 commit transaction;
  • 倘若消息投递失败,则 rollback transaction。

这个流程乍一看没啥毛病,重复利用了本地事务回滚的能力,解决了本地修改操作成功、消息投递失败后本地数据修正成本高的问题。

但其实仔细一想就会发现问题很严重,投递消息的过程一般涉及网络或与第三方组件的 I/O 操作,而这类操作一般是比较耗时的,这会让本地事务时间变长,引发长时间锁占用等风险。

此外,不能保证事务一定可以成功 Commit,虽然事务可以回滚,但我们投递的消息依然无法回滚。

事务消息的原理

我们以 RocketMQ 中 TX Msg 的实现方案为例展开介绍。首先,TX Msg 能保证我们做到在本地事务执行成功的情况下,后置的投递消息操作能以接近百分之百的概率被发出,其实现的核心流程为:

  1. 生产方 Producer 首先向 RocketMQ 生产一条半事务消息,此消息处于中间态,会暂存于 RocketMQ 不会被立即发出;
  2. Producer 执行本地事务;
  3. 如果本地事务执行成功,Producer 直接提交本地事务,并且向 RocketMQ 发出一条确认消息;
  4. 如果本地事务执行失败,Producer 向 RocketMQ 发出一条回滚指令;
  5. 倘若 RocketMQ 接收到确认消息,则会执行消息的发送操作,供下游消费者 Consumer 消费;
  6. 倘若 RocketMQ 接收到回滚指令,则会删除对应的半事务消息,不会执行实际的消息发送操作;
  7. 在 RocketMQ 侧,针对半事务消息会有一个轮询任务,倘若半事务消息一直未收到来自 Producer 侧的二次确认,则 RocketMQ 会持续主动询问 Producer 侧本地事务的执行状态,从而引导半事务消息走向终态。

在 TX Msg 的实现流程中,能够保证前文谈及的各种 Bad case 都能被很好地消化:

  • 倘若本地事务执行失败,则 Producer 会向 RocketMQ 发出删除半事务消息的回滚指令,因此保证消息不会被发出;
  • 倘若本地事务执行成功, 则 Producer 会向 RocketMQ 发出事务成功的确认指令,因此消息能够被正常发出;
  • 倘若 Producer 端在发出第二轮的确认或回滚指令前发生意外状况,导致第二轮结果指令缺失,则 RocketMQ 会基于自身的轮询机制主动询问本地事务的执行状况,最终帮助半事务消息推进进度。

事务消息的局限性

来总结梳理一下 TX Msg 中存在的几项局限性:

  • 流程高度抽象: TX Msg 把流程抽象成本地事务+投递消息两个步骤。然而在实际业务场景中,分布式事务内包含的步骤数量可能很多,因此就需要把更多的内容更重的内容糅合在所谓的“本地事务”环节中,上游 Producer 侧可能会存在比较大的压力;
  • 不具备逆向回滚能力: 倘若接收消息的下游 Consumer 侧执行操作失败,此时至多只能依赖于 MQ 的重放机制通过重试动作的方式提高执行成功率,但是无法从根本上解决下游 Consumer 操作失败后回滚上游 Producer 的问题。这一点正是 TX Msg 中存在的最大的局限性。

关于上面第二点,我们知道,并非所有动作都能通过简单的重试机制加以解决。

打个比方,倘若下游是一个库存管理系统,而对应商品的库存在事实上已经被扣减为 0,此时无论重试多少次请求都是徒然之举,这就是一个客观意义上的失败动作。

而遵循正常的事务流程,后置操作失败时,我们应该连带前置操作一起执行回滚,然而这部分能力在 TX Msg 的主流程中并没有予以体现。

要实现这种事务的逆向回滚能力,就必然需要构筑打通一条由下游逆流而上回调上游的通道,这一点并不属于 TX Msg 探讨的范畴。

针对于 TX Msg 中存在的问题,我们需要引入另一种分布式事务解决方案:Try-Confirm-Cancel (TCC)。

具体的,将在后续篇幅中讨论,毕竟没人愿意一次看很长的内容,我也一样,循序渐进挺好的。


内容参考自小徐先生,几年前刚入门 Golang 时就在看他,现在补充知识空白依然看他 😆,他的内容一眼就能和那些培训班级别的垃圾资源区分开。

评论区