当业务数据被拆分到多个数据库实例,或者一个业务流程需要同时操作数据库和消息队列、缓存等多个系统时,单个数据库的事务已经无法覆盖所有参与方。此时就需要分布式事务来保证跨节点的数据一致性。本节介绍两种最基础的分布式事务方案:两阶段提交代表强一致性,柔性事务代表最终一致性。了解它们的原理和适用场景,是后续深入选型的基础。
17.4.1 分布式事务要解决什么问题
用一个最典型的场景说明:用户下单后,系统需要做两件事——在订单库创建订单,在库存库扣减库存。这两个操作分别落在两个独立的 MySQL 实例上,各自有各自的事务。如果订单创建成功,但库存扣减失败(网络异常、服务宕机等),就会出现“订单生成了但库存没扣”的数据不一致。分布式事务的目标就是保证这两个操作要么一起成功,要么一起失败,整体上维持业务语义的正确性。
然而在分布式环境中,要实现像单机那样“强一致”的 ACID 事务,代价极高。因此业界通常根据业务对一致性的要求,在强一致性方案和最终一致性方案之间做出选择。
17.4.2 两阶段提交:强一致性的经典方案
两阶段提交(Two-Phase Commit,简称 2PC)是一个经典的分布式事务协议,很多数据库和中间件(如 MySQL XA 事务)都支持它。它的核心思想是引入一个协调者来统一管理多个参与者的提交或回滚。
执行过程分为两个阶段:
阶段一:准备阶段(投票阶段)
- 协调者向所有参与者(订单库、库存库)发送事务准备请求,让它们执行事务操作但不提交。
- 每个参与者执行自己的事务,将 Redo Log 和 Undo Log 写盘,准备好提交或回滚,然后向协调者返回“就绪”(Yes)或“失败”(No)。
阶段二:提交/回滚阶段
- 如果所有参与者都返回“就绪”,协调者向所有参与者发送“提交”命令,各自正式提交事务。
- 如果任一参与者返回“失败”或超时未响应,协调者向所有参与者发送“回滚”命令,各自回滚事务。
XA 事务在 MySQL 中的体现
MySQL 从 5.0 开始支持 XA 分布式事务,允许以编程方式实现 2PC。你可以使用 XA START、XA END、XA PREPARE、XA COMMIT、XA ROLLBACK 等命令手动编写协调者逻辑,或借助某些数据库中间件(如 ShardingSphere)的 XA 事务管理器来封装。
2PC 的优点
- 实现原理简单清晰,能保证分布式环境下的原子性。
- 参与者完成准备后,即使个别节点宕机,协调者可以在恢复后重新下发提交命令,保证了最终的强一致性。
2PC 的致命弱点
在实际的互联网业务中,2PC 并没有被广泛用于高并发链路上,主要原因有:
- 同步阻塞:准备阶段完成后,参与者会锁定本地事务资源(如锁定行),必须等待协调者发出最终的提交或回滚指令。如果协调者崩溃,参与者的事务会一直悬挂,造成长时间锁等待甚至死锁。
- 单点故障:协调者本身是单点,一旦不可用且没有良好的恢复机制,整个分布式链路就会卡死。
- 网络开销大:至少需要两次 RPC 通信(准备/提交),响应时间较长,不适合追求低延迟的业务。
- 数据不一致窗口:极端情况下,如果协调者发送提交命令后部分参与者提交成功、部分因为网络中断没有收到提交而一直保持事务悬挂,就会出现不一致,需要人工介入。
因为这些缺点,2PC 更适合对一致性要求极高、并发量不大、可接受较长响应时间的内部系统操作,例如在金融系统中进行日终清算的批量处理,或者在内部系统之间进行确定性的资源划拨,而不是面向终端用户的高并发交易链路。
17.4.3 柔性事务:高性能场景的实用主义
在互联网的大规模交易场景中,人们更倾向于放弃强一致性,追求最终一致性。柔性事务就是这一类方案的总称,它并不要求所有参与方在同一个全局事务中立刻达成一致,而是允许中间状态存在一段短暂的“不一致窗口”,最终通过异步补偿或重试达到一致。
下面介绍几种最常见的柔性事务实现思路。
(1)TCC(Try-Confirm-Cancel)
TCC 将每个参与者的操作拆分为三个步骤:
- Try:预留资源,锁定业务资源(如冻结库存、设置预占金额),这一步结束时业务还未真正发生,只是锁定。
- Confirm:确认执行,使用 Try 阶段预留的资源完成真正操作(如扣库存、划转资金)。
- Cancel:取消执行,释放 Try 阶段预留的资源,恢复到初始状态。
所有参与者的 Try 都成功后,协调者调用 Confirm;任何一个 Try 失败,协调者调用所有参与者的 Cancel 进行释放。如果 Confirm 或 Cancel 执行失败,协调者需要不断重试,直到成功,因此这两个接口必须设计成幂等的。
TCC 将资源锁定和确认操作分离开,避免了 2PC 的资源长期悬挂。但它侵入性极强:每个业务需要按照 Try-Confirm-Cancel 接口进行改造,开发工作量较大。适合对一致性要求相对较高、但又能接受实现成本的场景,如账户转账、优惠券核销等。
(2)本地消息表 + 可靠消息最终一致
这是一个非常实用的方案,由 eBay 早期提出的分布式事务方案演化而来。核心思路是:将业务操作和一个“待发送消息”放在同一个本地事务中持久化,然后通过异步任务将消息可靠地投递给下游。
以“下单后发积分”为例,实际步骤为:
- 在订单库的同一个本地事务内,插入订单数据,同时向“本地消息表”插入一条“待发送积分”消息。
- 一个后台任务轮询本地消息表,将状态为“待发送”的消息投递到消息队列(如 RocketMQ、Kafka)。
- 积分服务消费消息,给用户增加积分。如果积分服务本地事务执行成功,向消息队列返回确认;如果失败,根据消息的重试机制或死信队列进行重试补偿。同时,积分服务的消费者要保证幂等性(如根据消息 ID 去重),防止重复消费。
这个方案的核心优势是不依赖分布式事务协调者,完全用本地事务和消息队列的递送保证最终一致。目前很多消息队列(如 RocketMQ 的事务消息)已经将“本地消息表+发送”这一步做了封装,使用起来更加方便。
(3)Saga 模式
Saga 将一个长事务拆分成一系列有序的本地事务,每个本地事务都有一个对应的补偿操作。如果某个步骤执行失败,则按照相反顺序依次调用之前成功步骤的补偿操作进行回滚。
例如一个旅行预订流程:
- 航班预订 → 酒店预订 → 租车预订,如果酒店预订失败,则补偿取消航班。
- 所有步骤的补偿操作必须定义好,并且是幂等的。
Saga 可以由编排式(一个中心节点调度每个步骤)或协同式(每个服务监听事件自行触发下一步)实现。它适合涉及多个微服务、执行时间较长的业务流程。其好处是不需要长时间锁资源,但事务隔离性差(中间状态可能被其他事务看到),需要在业务层面容忍一些临时的不一致。
(4)最大努力通知
对于最终一致性要求不高的场景,可以用反复尝试通知的方式,如订单支付成功后多次通知商户结果,直到商户确认或通知次数耗尽。这种方式更像一种异步通信模式,不要求参与方一定返回成功,业务上应对“没收到”这种异常有额外处理。
17.4.4 如何选择合适的方案
没有银弹,实际选择取决于业务对一致性的要求、可接受的响应时间、开发成本等因素。
| 方案 | 一致性强度 | 性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| XA / 2PC | 强一致 | 低(同步阻塞) | 低(数据库内置) | 批量处理、内部系统操作 |
| TCC | 较强(预留资源) | 中高 | 高(需改造业务) | 资金交易、核心资源扣减 |
| 本地消息表 + 可靠消息 | 最终一致 | 高 | 中 | 跨服务数据同步、异步解耦 |
| Saga | 最终一致 | 高 | 中高(定义补偿) | 长流程微服务事务 |
| 最大努力通知 | 弱一致 | 高 | 低 | 通知类、非关键数据同步 |
对于绝大多数互联网业务,本地消息表 + 可靠消息或Saga 是性价比最高的选择。当涉及到资金类操作,TCC 可以提供更强的中间状态保护。而 XA 事务一般不建议用在核心交易链路上。
最后强调一点:无论选择哪种方案,幂等性都是分布式事务的基石。重试、补偿、消息重复消费都可能导致重复操作,任何参与方的接口都必须能够安全处理重复请求,否则一切一致性努力都可能付诸东流。