分布式系统与事务:从基础到实践
当一个操作需要跨越多个服务、多个数据库才能完成时,如何保证"要么全部成功,要么全部回滚"?这就是分布式事务要解决的核心问题。
从集中式到分布式
集中式系统
集中式系统的特点是一个主机承担所有计算和存储,终端仅负责数据的输入和输出。早期的银行系统、大型企业的核心业务系统大多采用这种架构——从 IBM、HP 等厂商购买昂贵的大型主机,所有业务逻辑集中部署。
部署简单,不用考虑节点间协调。但问题也很明显:主机宕机等于整个系统瘫痪(单点故障);纵向扩展有物理上限且成本指数增长;系统越来越大,所有逻辑耦合在一起,维护困难。
分布式系统
《分布式系统概念与设计》中的定义:
分布式系统是一个硬件或软件组件分布在不同的网络计算机上,彼此之间仅仅通过消息传递进行通信和协调的系统。
说白了就是多台普通计算机通过网络协作,对外表现得像一台计算机。用更多的普通机器(而不是昂贵的大型主机)组成集群,机器越多,CPU、内存、存储就越多,能处理的并发量也就越大。
分布式系统有四个基本特征:
| 特征 | 说明 |
|---|---|
| 分布性 | 多台计算机在空间上可以随意分布——同一机柜、不同机房甚至不同城市,没有绝对的主从关系 |
| 透明性 | 系统资源被所有计算机共享,用户感知不到背后有多少台机器在提供服务 |
| 协同性 | 多台计算机互相协作完成共同任务,一个程序可以分布在几台计算机上并行运行 |
| 通信性 | 系统中任意两台计算机都可以通过网络通信来交换信息 |
常见的分布式方案
分布式不是一种单一的技术,而是一种架构理念。在实际应用中,分布式思想体现在多个层面:
| 分布式方案 | 说明 | 典型技术 |
|---|---|---|
| 分布式应用和服务 | 将应用分层分割,各模块独立部署,提高并发能力 | 微服务架构、Spring Cloud、Dubbo |
| 分布式静态资源 | JS、CSS、图片等静态资源分布式部署,减轻应用服务器负载 | CDN、对象存储(OSS/S3) |
| 分布式数据和存储 | 海量数据单机无法容纳,分布到多台机器存储 | 分库分表(ShardingSphere)、HBase、Cassandra |
| 分布式计算 | 大型计算任务拆分为子任务,分配给多台机器并行处理 | MapReduce、Spark、Flink |
| 分布式锁 | 跨进程的互斥访问控制 | Redis(RedLock)、ZooKeeper、etcd |
| 分布式缓存 | 数据缓存分布在多个节点上,提高读取性能 | Redis Cluster、Memcached |
分布式 vs 集群
这两个概念经常混淆,区别其实很简单:分布式是不同的服务器部署不同的服务模块,协作对外提供服务;集群是不同的服务器部署相同的服务,通过负载均衡对外提供服务。实际系统往往是两者结合——每个分布式服务都以集群方式部署。
分布式带来的新问题
分布式在解决高并发问题的同时,也带来了一些棘手的问题:网络不可靠(延迟、丢包、分区随时可能发生)、时钟不同步(不同机器的系统时钟存在偏差,无法依赖本地时间戳判定事件的全局先后顺序)、节点故障(集群越大,某台服务器宕机的概率越高)、以及最核心也最困难的——数据一致性(用户请求只会落到其中一台机器上,处理不好就会产生一致性问题)。
Leslie Lamport(Paxos 算法发明者,2013 年图灵奖得主)说过:"A distributed system is one in which the failure of a computer you didn't even know existed can render your own computer unusable."——一台你甚至不知道其存在的计算机的故障,就可能让你自己的计算机变得不可用。这句话精确地概括了分布式系统的根本复杂性。
分布式用更多的普通机器替代了昂贵的大型主机,但代价是需要处理网络、时钟、故障和一致性等一系列新问题。
数据一致性问题
从 ACID 说起
先回顾单机数据库是如何保证一致性的。数据库通过事务(Transaction)机制来保证 ACID 特性:
| 特性 | 含义 | 保障手段 |
|---|---|---|
| Atomicity(原子性) | 事务中的操作要么全部成功,要么全部回滚 | undo log |
| Consistency(一致性) | 事务执行前后,数据从一个一致状态转到另一个一致状态 | 由 A、I、D 共同保证 |
| Isolation(隔离性) | 并发事务之间互不干扰 | 锁 + MVCC |
| Durability(持久性) | 事务提交后数据不会丢失 | redo log + WAL |
在集中式系统中,所有数据在一台机器上,一个数据库事务就能保证多个操作的原子性。但在分布式系统中,数据分散在多台机器上,本地事务的边界无法跨越网络——这就是分布式一致性问题的根源。
两种一致性
在分布式系统中,"一致性"有两层含义:
副本一致性(Replica Consistency):同一份数据的多个副本之间是否相同。比如数据库主从复制中,主库写入后从库是否能立即读到最新值。
事务一致性(Transactional Consistency):一个跨多个服务的业务操作,所有步骤要么全部成功,要么全部回滚。比如电商下单需要同时扣库存、扣红包、扣优惠券——任何一步失败,已执行的步骤都应该回滚。
为什么会出现一致性问题
数据复制的动机来自两方面:可用性(消除单点故障,某台机器挂了其他副本还能服务)和性能(让不同地方的副本都能提供读服务,提升吞吐量)。但引入复制后,不同节点之间由于网络延迟、节点故障等原因很容易产生数据不一致。
根源在于数据副本同步延迟和跨服务调用部分失败两个场景。前者是客户端写入主库成功,但从库同步有延迟,读请求落到从库就读到了旧数据;后者是跨服务调用中某一步超时或失败,已执行的步骤无法自动回滚,导致数据处于不一致状态。
用一个具体的代码场景说明:
// 电商下单伪代码 —— 跨三个服务的操作
public OrderResult createOrder(OrderRequest request) {
// 步骤1:扣减库存(调用库存服务)
inventoryService.deduct(request.getSkuId(), request.getQuantity());
// 步骤2:扣减红包(调用营销服务)
couponService.deduct(request.getUserId(), request.getCouponId());
// 步骤3:创建订单(本地数据库)
orderDao.insert(request.toOrder());
return OrderResult.success();
}
如果步骤 2 成功但步骤 3 失败了怎么办?库存和红包已经扣了,但订单没创建——用户扣了钱却看不到订单。这就是分布式事务要解决的问题。
一致性问题的根源在于:本地事务的边界无法跨越网络,而数据复制和服务拆分都依赖网络。
理论基础:CAP 与 BASE
CAP 定理
2000 年 Eric Brewer 提出 CAP 猜想,2002 年被正式证明为定理:一个分布式系统最多只能同时满足以下三项中的两项——
| 属性 | 含义 | 举例 |
|---|---|---|
| Consistency(一致性) | 所有节点在同一时刻看到相同的数据 | 写入主库后,所有从库立即可读到新值 |
| Availability(可用性) | 每个请求都能在合理时间内收到非错误响应(不保证是最新数据) | 任意时刻发送请求,系统都能正常响应 |
| Partition tolerance(分区容错性) | 网络分区发生时,系统仍能继续运作 | 机房之间网络断了,各机房仍能独立服务 |
为什么 P 不可放弃
网络分区(P)在现实中不可避免——网络硬件会故障、光纤会被挖断、交换机会宕机。一旦系统部署在多台机器上,网络分区就是物理现实而非可选项。
所以 CAP 的核心不是"三选二",而是在发生网络分区时,你选一致性还是可用性:
| 策略 | 取舍 | 典型系统 | 工程表现 |
|---|---|---|---|
| CP | 保证一致性,牺牲部分可用性 | ZooKeeper、etcd、HBase | 网络分区时,少数派节点拒绝服务,直到分区恢复。适用于分布式锁、配置管理、leader 选举 |
| AP | 保证可用性,允许短暂不一致 | Cassandra、DynamoDB、Eureka | 网络分区时所有节点继续服务,但可能返回不同版本数据。分区恢复后通过反熵协议或读修复达到一致。适用于用户信息缓存、社交动态 |
一个常见误解:CAP 中的"放弃一致性"不是说数据可以永远不一致,而是放弃强一致性,允许数据在短时间内不一致但最终会一致。如果真的放弃一致性,数据就不可信,系统也就没有价值了。所以"放弃一致性"实际指的是放弃强一致性——这就引出了 BASE 理论。
BASE 理论
BASE 是对 CAP 中 AP 策略的延伸,核心思想:即使无法做到强一致性,也可以通过适当的方式达到最终一致性。
| 缩写 | 全称 | 含义 |
|---|---|---|
| BA | Basically Available | 基本可用——故障时允许损失部分非核心功能(降级、限流),但核心功能可用 |
| S | Soft State | 软状态——允许不同节点的数据副本在同步过程中暂时不一致 |
| E | Eventually Consistent | 最终一致——软状态不会一直持续,经过一段时间后所有副本最终达到一致 |
"基本可用"有两种典型表现:一是响应时间上的损失(正常 0.5 秒返回结果,故障时延长到 1-2 秒);二是功能上的损失(电商大促时关闭评论、推荐等非核心功能,保护购买流程)。
BASE vs ACID
| 维度 | ACID | BASE |
|---|---|---|
| 策略 | 悲观,追求强一致性 | 乐观,追求最终一致性 |
| 组成 | Atomicity、Consistency、Isolation、Durability | Basically Available、Soft State、Eventually Consistent |
| 适用场景 | 银行转账、库存扣减等对一致性要求极高的场景 | 社交动态、搜索索引等可以容忍短暂不一致的场景 |
实际系统中两者不是非此即彼——核心链路用 ACID,非核心链路用 BASE,混合使用是常态。
CAP 告诉我们分布式系统必须在一致性和可用性之间做取舍,BASE 则给出了一条务实的中间路线:接受短暂不一致,保证最终一致。
一致性模型
一致性模型定义了"数据写入后,读取方能看到什么"的约定。不同的模型在一致性强度和系统性能之间做出不同的取舍。
强一致性(Linearizability)
更新操作完成之后,任何后续的读取都会返回最新值。对用户最友好——上次写什么,下次就保证能读到。但代价是性能,因为所有读取都必须等待写入完成并同步到所有副本。单机数据库事务是强一致性的典型实现;分布式环境中 Raft/Paxos 等共识算法可以做到,但延迟更高、吞吐更低。
弱一致性
系统不承诺写入后立即能读到最新值,也不承诺多久之后能读到。从写入到所有读取都能看到新值的这段时间,称为不一致窗口(inconsistency window),弱一致性对这个窗口不做任何承诺。
最终一致性
弱一致性的特定形式。系统保证:在没有后续更新的前提下,最终返回上一次更新的值。不一致窗口的大小主要受通信延迟、系统负载和副本个数影响。
DNS 是最典型的最终一致性系统——修改域名解析记录后,全球 DNS 服务器不会立即更新,但经过 TTL 时间后所有节点都会拿到新值。
最终一致性的变体
最终一致性有几种重要的变体,在"最终一致"的基础上提供了更具体的保证:
因果一致性(Causal Consistency):如果进程 A 更新后通知了进程 B,那么 B 的后续访问会返回更新后的值。与 A 没有因果关系的进程 C 则遵循最终一致性。比如 A 发了一条微博,B 对该微博评论,其他用户看到 B 的评论时一定能看到 A 的原始微博。
读己所写一致性(Read-your-writes Consistency):一个进程总能读到自己更新的数据。比如用户更新头像后刷新页面,一定能看到新头像,即使这个更新还没同步到所有从库。
会话一致性(Session Consistency):在同一个会话内保证读己所写。实现方式通常是将同一会话的读写请求路由到同一个节点(session stickiness)。
单调读一致性(Monotonic Read Consistency):读到的数据版本只会前进,不会后退。比如用户看到了 10 条评论,再次刷新不应该变成 8 条——这在请求被负载均衡到不同从库时容易出现。
单调写一致性(Monotonic Write Consistency):来自同一进程的写操作被串行化执行。比如用户先改用户名再改头像,系统不会出现头像先于用户名更新的情况。
从实践角度看,读己所写 + 单调读的组合最实用——用户总能读到自己更新的数据,且一旦读到最新版本就不会再读到旧版本。大部分互联网应用的最终一致性方案都在追求这个组合。
一致性强度从强到弱依次是:强一致性 > 因果一致性 > 读己所写 > 会话一致性 > 单调读/单调写 > 最终一致性 > 弱一致性。越靠左一致性越强但性能越差,越靠右性能越好但一致性越弱。
一致性模型本质上是在回答"数据可以不一致到什么程度",为后续的工程方案选型提供了理论依据。
分布式事务:2PC
上一章的一致性模型回答了"数据可以不一致到什么程度",接下来的事务方案则回答"工程上如何逼近我们选择的一致性级别"。2PC/3PC 追求强一致性,而 TCC、Saga、本地消息表、事务消息都是用不同的工程手段逼近最终一致性——区别在于一致性、性能和实现复杂度之间各自做了什么取舍。
什么是分布式事务
分布式事务是将单库事务的概念扩展到多库/多服务——跨越多个独立节点的操作,要么全部提交,要么全部回滚。
核心困难在于:每个节点只知道自己的事务执行结果,不知道其他节点的情况。因此需要引入一个**协调者(Coordinator)**来统一决策。
XA 规范
X/Open 组织定义的分布式事务处理模型(DTP),包含四个角色:应用程序(AP)发起全局事务,事务管理器(TM)协调全局事务,资源管理器(RM)即数据库,以及通信资源管理器(CRM)即消息中间件。
XA 是 TM 与 RM 之间的接口规范——定义了 xa_start、xa_end、xa_prepare、xa_commit、xa_rollback 等接口函数,由数据库厂商实现。2PC 和 3PC 就是基于 XA 规范的具体协议实现。
两阶段提交(2PC)
2PC 是最经典的分布式事务协议,核心思想:先投票,再执行。
第一阶段——准备(Prepare):协调者向所有参与者发送 Prepare 请求,每个参与者执行本地事务,写入 redo 和 undo 日志,但不提交,然后向协调者报告"准备好了(Yes)"或"执行失败了(No)"。
第二阶段——提交或回滚(Commit / Rollback):如果所有参与者都返回 Yes,协调者发送 Commit,所有参与者正式提交事务并释放锁资源;如果任一参与者返回 No 或超时,协调者发送 Rollback,所有参与者利用 undo 日志回滚并释放锁。
Java 中的 XA 事务示例
// 使用 JTA(Java Transaction API)实现 2PC
public class XATransactionExample {
public void transfer(BigDecimal amount) throws Exception {
UserTransaction utx = (UserTransaction) ctx.lookup("java:comp/UserTransaction");
Connection connA = xaDataSourceA.getConnection(); // 账户库
Connection connB = xaDataSourceB.getConnection(); // 积分库
try {
utx.begin(); // 开启全局事务
// 操作数据库 A:扣减账户余额
PreparedStatement psA = connA.prepareStatement(
"UPDATE account SET balance = balance - ? WHERE user_id = ?");
psA.setBigDecimal(1, amount);
psA.setLong(2, userId);
psA.executeUpdate();
// 操作数据库 B:增加积分
PreparedStatement psB = connB.prepareStatement(
"UPDATE points SET total = total + ? WHERE user_id = ?");
psB.setInt(1, amount.intValue());
psB.setLong(2, userId);
psB.executeUpdate();
utx.commit(); // 两阶段提交:TM 协调两个 RM 一起提交
} catch (Exception e) {
utx.rollback(); // 两个数据库一起回滚
throw e;
}
}
}
2PC 的问题
2PC 看起来能提供原子性操作,但有几个严重缺陷:
同步阻塞:从准备阶段开始,参与者就持有了锁资源,这些锁一直到提交阶段完成才释放。高并发场景下,这种长时间持锁会严重影响吞吐量。
单点故障:协调者一旦挂掉,参与者会一直阻塞。尤其在第二阶段,协调者故障会导致所有参与者锁定事务资源却无法继续。虽然可以重新选举协调者,但新协调者不知道上一个协调者宕机前做了什么决定。
数据不一致:第二阶段中,协调者发送 Commit 后发生局部网络异常或协调者故障,会导致只有一部分参与者收到 Commit 并提交,其他参与者仍在等待——数据就不一致了。
无解场景:协调者发出 Commit 后宕机,而唯一收到这条消息的参与者也同时宕机了。即使选出新协调者,这条事务的状态也是不确定的——没人知道事务是否已经被提交。
2PC 通过协调者统一决策来实现分布式原子性,但同步阻塞和单点故障使它在高并发场景下力不从心。
分布式事务:3PC
3PC 对 2PC 的改进
3PC 在 2PC 基础上做了两个核心改动:一是在协调者和参与者中都引入超时机制(2PC 中只有协调者有超时,参与者会无限阻塞),参与者超时后可以自行做出决定;二是增加预提交阶段(将 2PC 的准备阶段一分为二),保证在最后提交前各节点状态一致。
三个阶段
阶段一:CanCommit(询问)——协调者向参与者发送 CanCommit 请求,参与者评估自身能否执行事务(检查资源、权限等),返回 Yes 或 No。关键区别是:此阶段参与者不执行任何事务操作。2PC 的第一阶段参与者就要执行事务并持锁,3PC 的 CanCommit 只是一个轻量级的"询问",不占用资源。
阶段二:PreCommit(预执行)——如果所有参与者都返回 Yes,协调者发送 PreCommit,参与者执行事务操作并记录 undo/redo 日志(但不提交),成功则返回 ACK;如果有任一参与者返回 No 或超时,协调者发送 Abort 中断事务。
阶段三:DoCommit(正式提交)——协调者收到所有 ACK 后,发送 DoCommit,参与者正式提交事务并释放资源。如果协调者未收到 ACK,则发送 Abort,参与者利用 undo 信息回滚。
超时默认提交的设计
3PC 有一个关键设计:如果参与者在第三阶段等待超时(没收到 DoCommit 也没收到 Abort),它会默认提交。
背后的推理是:能走到第三阶段,说明参与者在第二阶段收到了 PreCommit。而协调者发出 PreCommit 的前提是第一阶段所有参与者都回复了 Yes。换句话说,一旦收到 PreCommit,就意味着所有人都同意了修改。所以虽然超时没收到最终指令,但提交的概率远大于需要回滚的概率。
2PC vs 3PC
| 维度 | 2PC | 3PC |
|---|---|---|
| 阶段数 | 2(准备 + 提交) | 3(询问 + 预提交 + 提交) |
| 超时机制 | 仅协调者有 | 协调者和参与者都有 |
| 阻塞风险 | 高(协调者宕机 → 参与者永久阻塞) | 低(参与者超时后默认提交) |
| 一致性 | 可能不一致(部分提交) | 仍可能不一致(超时默认提交可能与 Abort 冲突) |
| 网络开销 | 较低 | 多一轮通信 |
3PC 主要解决了单点故障导致的无限阻塞问题,但超时默认提交也可能导致数据不一致——如果协调者实际发出了 Abort 但因网络原因参与者没收到,参与者超时后还是会提交。
无论 2PC 还是 3PC 都无法彻底解决分布式一致性问题。Google Chubby 的作者 Mike Burrows 说过:"there is only one consensus protocol, and that's Paxos"——所有其他一致性算法都是 Paxos 的不完整版。但在工程实践中,我们更多使用的是下面介绍的几种柔性事务方案。
柔性事务方案
2PC/3PC 是刚性事务——追求强一致性,代价是性能和可用性。互联网业务中更常用的是柔性事务——基于 BASE 理论,接受短暂不一致,保证最终一致性。
TCC(Try-Confirm-Cancel)
TCC 将每个业务操作拆分为三步:
| 阶段 | 职责 | 说明 |
|---|---|---|
| Try | 资源预留 | 冻结库存、冻结余额,但不真正扣减 |
| Confirm | 确认执行 | 将冻结的资源正式扣减(幂等) |
| Cancel | 取消释放 | 将冻结的资源释放回去(幂等) |
执行流程是:业务发起方依次调用各服务的 Try 接口预留资源,如果全部 Try 成功则调用所有 Confirm 正式提交,如果任一 Try 失败则调用已 Try 服务的 Cancel 释放资源。
// 库存服务的 TCC 实现
public class InventoryTccService {
// Try:冻结库存(不真正扣减)
public boolean tryDeduct(String skuId, int quantity) {
int updated = jdbcTemplate.update(
"UPDATE inventory SET available = available - ?, frozen = frozen + ? " +
"WHERE sku_id = ? AND available >= ?",
quantity, quantity, skuId, quantity);
return updated > 0;
}
// Confirm:将冻结的库存正式扣减
public boolean confirm(String skuId, int quantity) {
jdbcTemplate.update(
"UPDATE inventory SET frozen = frozen - ? WHERE sku_id = ? AND frozen >= ?",
quantity, skuId, quantity);
return true;
}
// Cancel:释放冻结的库存
public boolean cancel(String skuId, int quantity) {
jdbcTemplate.update(
"UPDATE inventory SET available = available + ?, frozen = frozen - ? " +
"WHERE sku_id = ? AND frozen >= ?",
quantity, quantity, skuId, quantity);
return true;
}
}
TCC 有几个关键要求:Confirm 和 Cancel 必须幂等(网络重试可能导致重复调用);Cancel 必须能处理 Try 未执行的情况(空回滚——如果 Try 因超时未到达,框架可能直接调 Cancel);以及防悬挂(Cancel 执行后,迟到的 Try 不能再执行)。
适合对一致性要求较高、资源可以预留的场景:资金转账、库存预扣、票务预订等。
Saga 模式
Saga 将一个长事务拆分为一系列本地事务,每个本地事务都有对应的补偿操作。如果某个步骤失败,按反方向依次执行补偿。比如正向执行 T1 → T2 → T3,如果 T3 失败,就依次执行 C2(补偿 T2)→ C1(补偿 T1)。
Saga 有两种实现方式。编排式(Choreography):每个服务完成本地事务后发布事件,下游服务监听事件执行自己的操作,去中心化但流程难以追踪。协调式(Orchestration):引入一个 Saga 协调器集中控制每个步骤的执行和补偿,流程清晰便于监控。
// Saga 协调器伪代码
public class OrderSagaOrchestrator {
public void execute(OrderRequest request) {
SagaContext context = new SagaContext(request);
try {
context.execute("创建订单",
() -> orderService.create(request),
() -> orderService.cancel(request)); // 补偿操作
context.execute("扣减库存",
() -> inventoryService.deduct(request),
() -> inventoryService.restore(request)); // 补偿操作
context.execute("执行支付",
() -> paymentService.charge(request),
() -> paymentService.refund(request)); // 补偿操作
} catch (Exception e) {
// 任一步骤失败 → 反向执行已完成步骤的补偿操作
context.compensate();
}
}
}
TCC vs Saga
| 维度 | TCC | Saga |
|---|---|---|
| 隔离性 | 较强(Try 阶段锁定资源) | 较弱(无资源预留,中间状态可见) |
| 业务侵入 | 高(需实现 Try/Confirm/Cancel 三个接口) | 中(需实现业务操作 + 补偿操作) |
| 适用场景 | 短事务、需要资源预留 | 长事务、跨多个服务的业务流程 |
| 实现复杂度 | 高(空回滚、悬挂、幂等) | 中等(补偿逻辑、幂等) |
本地消息表
通过本地数据库事务保证业务操作和消息写入的原子性,再通过异步消息驱动下游操作,最终达到一致。
流程是:上游服务在同一个数据库事务内执行业务操作并写入消息表(status = PENDING);定时任务扫描 PENDING 消息发送到 MQ,成功则更新为 SENT;下游服务消费消息执行业务操作(幂等),成功后回调上游更新为 DONE。
// 上游服务:业务操作 + 消息写入在同一个本地事务中
@Transactional
public void createOrder(OrderRequest request) {
// 1. 执行业务操作
orderDao.insert(request.toOrder());
// 2. 写入消息表(同一个数据库,同一个事务)
localMessageDao.insert(new LocalMessage(
UUID.randomUUID().toString(),
"ORDER_CREATED",
JsonUtils.toJson(request),
"PENDING"
));
// 事务提交后,两条记录要么都写入,要么都不写入
}
// 定时任务:扫描并发送未处理的消息
@Scheduled(fixedDelay = 5000)
public void sendPendingMessages() {
List<LocalMessage> messages = localMessageDao.queryByStatus("PENDING");
for (LocalMessage msg : messages) {
try {
mqProducer.send(msg.getTopic(), msg.getBody());
localMessageDao.updateStatus(msg.getId(), "SENT");
} catch (Exception e) {
// 发送失败不更新状态,下次定时任务重试
log.warn("send message failed, will retry: {}", msg.getId());
}
}
}
优点是实现简单、不依赖特殊中间件;缺点是需要定时轮询,实时性取决于轮询间隔。
事务消息(RocketMQ)
RocketMQ 原生支持事务消息,相当于中间件级别的本地消息表——将"本地事务 + 消息发送"的原子性保证从应用层下沉到了消息中间件。
流程是:Producer 先发送半消息(Half Message),半消息对消费者不可见;然后执行本地事务,成功则发送 Commit 让消息可见,失败则发送 Rollback 删除半消息;如果 Producer 超时未响应,RocketMQ 会主动回查本地事务状态。
// RocketMQ 事务消息 Producer
public class OrderTransactionProducer {
private TransactionMQProducer producer;
public void sendOrderMessage(OrderRequest request) {
Message msg = new Message("ORDER_TOPIC", JsonUtils.toJson(request).getBytes());
producer.sendMessageInTransaction(msg, new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
orderService.createOrder(request);
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 回查:检查本地事务是否已执行成功
Order order = orderDao.queryByOrderId(request.getOrderId());
if (order != null) {
return LocalTransactionState.COMMIT_MESSAGE;
}
return LocalTransactionState.UNKNOW; // 继续等待回查
}
}, null);
}
}
事务消息的优势在于:半消息 + 回查机制天然解决了"本地事务成功但消息发送失败"和"消息发送成功但本地事务失败"两种不一致场景。
柔性事务的核心思路都是一样的:放弃强一致性,通过补偿、重试、回查等机制保证最终一致性。区别只在于资源预留方式和对业务的侵入程度。
最大努力通知
最大努力通知是最简单的最终一致性方案:上游系统尽最大努力通知下游,失败则按递增间隔重试若干次,最终仍失败则记录日志,等待人工介入或下游主动查询。
典型应用是支付回调。支付宝、微信支付完成扣款后,会多次回调商户通知地址,按递增间隔重试(1s、5s、30s、5min、30min),超过最大次数后停止。商户可以通过主动调用支付查询接口来获取最终结果。
最大努力通知适合跨平台、跨企业的场景,上下游之间只能做到"尽力而为"。
方案选型
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 低(同步阻塞) | 低(数据库原生支持) | 跨库事务,对一致性要求极高 |
| 3PC | 强一致(仍有缺陷) | 低(多一轮通信) | 中 | 理论意义大于实践,工程中较少直接使用 |
| TCC | 最终一致 | 高 | 高(三接口 + 幂等 + 空回滚 + 防悬挂) | 资金交易、库存预扣等需要资源预留 |
| Saga | 最终一致 | 高 | 中 | 长事务、跨多服务的业务编排 |
| 本地消息表 | 最终一致 | 中(依赖轮询间隔) | 低 | 对实时性要求不高的异步场景 |
| 事务消息 | 最终一致 | 高 | 低(中间件原生支持) | 基于消息驱动的异步业务 |
| 最大努力通知 | 最终一致(弱保证) | 高 | 最低 | 跨平台/跨企业通知,如支付回调 |
几条实践中的经验:能用单库事务解决就不要用分布式事务——分布式事务的复杂度远超想象;大部分互联网业务用最终一致性就够了——用户能接受几秒的延迟;资金相关用 TCC,业务编排用 Saga,异步通知用事务消息;如果团队没有分布式事务经验,优先选本地消息表而不是 TCC——TCC 的空回滚、防悬挂、幂等三件事加在一起,对实现团队的要求很高,现实中很多"用了 TCC 但出了问题"的案例,根源是实现不完整而不是方案本身有缺陷。
无论哪种方案,幂等性设计都是基础——网络重试无处不在。