分布式系统与事务:从基础到实践
分布式系统与事务:从基础到实践
当一个操作需要跨越多个服务、多个数据库才能完成时,如何保证"要么全部成功,要么全部回滚"?这就是分布式事务要解决的核心问题。
本文从分布式系统的基本概念出发,逐步深入到一致性理论和事务解决方案,力求构建一个完整的知识框架:为什么需要分布式 → 分布式带来了什么问题 → 理论上如何权衡 → 工程上如何解决。
阅读指南
- 建立基础概念:第 1–2 章(约 5 分钟)
- 理解理论框架:第 3–4 章(约 10 分钟)
- 掌握事务方案:第 5–8 章(约 25 分钟)
- 方案选型参考:第 9 章(约 5 分钟)
1. 从集中式到分布式
1.1 集中式系统
集中式系统的特点是:一个主机承担所有计算和存储,终端仅负责数据的输入和输出。早期的银行系统、大型企业的核心业务系统大多采用这种架构——从 IBM、HP 等厂商购买昂贵的大型主机,所有业务逻辑集中部署。
优点是部署简单,无需考虑节点间协调。但问题也很明显:
- 单点故障:主机宕机 = 整个系统瘫痪
- 扩展性差:纵向扩展(加 CPU/内存)有物理上限,且成本指数增长
- 维护困难:系统越来越大,所有逻辑耦合在一起
1.2 分布式系统
《分布式系统概念与设计》中的定义:
分布式系统是一个硬件或软件组件分布在不同的网络计算机上,彼此之间仅仅通过消息传递进行通信和协调的系统。
简单说就是:多台普通计算机通过网络协作,对外表现得像一台计算机。分布式意味着可以采用更多的普通计算机(相对于昂贵的大型主机)组成集群对外提供服务。计算机越多,CPU、内存、存储资源也就越多,能够处理的并发访问量也就越大。
分布式系统的四个基本特征:
| 特征 | 说明 |
|---|---|
| 分布性 | 多台计算机在空间上可以随意分布——同一机柜、不同机房甚至不同城市。系统中没有控制整个系统的主机,也没有受控的从机 |
| 透明性 | 系统资源被所有计算机共享,每台计算机的用户不仅可以使用本机的资源,还可以使用系统中其他计算机的资源(包括 CPU、文件、存储等)。用户感知不到背后有多少台机器在提供服务 |
| 协同性 | 多台计算机可以互相协作来完成一个共同的任务,一个程序可以分布在几台计算机上并行运行 |
| 通信性 | 系统中任意两台计算机都可以通过网络通信来交换信息 |
1.3 常见的分布式方案
分布式不是一种单一的技术,而是一种架构理念。在实际应用中,分布式思想体现在多个层面:
| 分布式方案 | 说明 | 典型技术 |
|---|---|---|
| 分布式应用和服务 | 将应用进行分层和分割,各模块独立部署。提高并发能力,减少资源竞争,使业务易于扩展 | 微服务架构、Spring Cloud、Dubbo |
| 分布式静态资源 | 将 JS、CSS、图片等静态资源分布式部署,减轻应用服务器负载 | CDN、对象存储(OSS/S3) |
| 分布式数据和存储 | 海量数据单机无法容纳,分布到多台机器存储 | 分库分表(ShardingSphere)、HBase、Cassandra |
| 分布式计算 | 将大型计算任务拆分为多个子任务,分配给多台机器并行处理 | MapReduce、Spark、Flink |
| 分布式锁 | 跨进程的互斥访问控制 | Redis(RedLock)、ZooKeeper、etcd |
| 分布式缓存 | 数据缓存分布在多个节点上,提高读取性能 | Redis Cluster、Memcached |
1.4 分布式 vs 集群
这两个概念经常混淆,区别其实很简单:
分布式(Distributed):不同的服务器部署不同的服务模块,协作对外提供服务
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 用户服务 │ │ 订单服务 │ │ 支付服务 │
└──────────┘ └──────────┘ └──────────┘
集群(Cluster):不同的服务器部署相同的服务,通过负载均衡对外提供服务
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 订单服务A │ │ 订单服务B │ │ 订单服务C │
└──────────┘ └──────────┘ └──────────┘
│ │ │
└──────────────┼──────────────┘
负载均衡器
实际系统往往是两者结合:每个分布式服务都以集群方式部署。
1.5 分布式带来的新问题
和集中式系统相比,分布式系统的性价比更高、处理能力更强、可靠性更高、也有更好的扩展性。但是,分布式在解决高并发问题的同时也带来了一些其他问题:
- 网络不可靠:分布式的必要条件是网络。延迟、丢包、分区随时可能发生,这对性能甚至服务能力都会造成影响
- 时钟不同步:不同机器的系统时钟存在偏差(时钟漂移),无法依赖本地时间戳判定分布式事件的全局先后顺序
- 节点故障:集群中的服务器数量越多,某台服务器宕机的概率也就越大
- 数据一致性:由于服务分布式部署,用户的请求只会落到其中一台机器上。一旦处理不好就很容易产生数据一致性问题。这是分布式系统中最核心也最困难的问题
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."——在分布式系统中,一台你甚至不知道其存在的计算机的故障,就可能让你自己的计算机变得不可用。 这句话精确地概括了分布式系统的根本复杂性。
2. 数据一致性问题
2.1 从 ACID 说起
在理解分布式一致性之前,先回顾单机数据库是如何保证一致性的。数据库通过事务(Transaction)机制来保证数据的 ACID 特性:
| 特性 | 含义 | 保障手段 |
|---|---|---|
| Atomicity(原子性) | 事务中的操作要么全部成功,要么全部回滚 | undo log |
| Consistency(一致性) | 事务执行前后,数据从一个一致状态转到另一个一致状态 | 由 A、I、D 共同保证 |
| Isolation(隔离性) | 并发事务之间互不干扰 | 锁 + MVCC |
| Durability(持久性) | 事务提交后数据不会丢失 | redo log + WAL |
在集中式系统中,所有数据在一台机器上,一个数据库事务就能保证多个操作的原子性。但在分布式系统中,数据分散在多台机器上,本地事务的边界无法跨越网络——这就是分布式一致性问题的根源。
2.2 分布式中的两种一致性
在分布式系统中,"一致性"有两层含义,对应两类不同的问题:
副本一致性(Replica Consistency):同一份数据的多个副本之间是否相同。例如数据库主从复制中,主库写入后从库是否能立即读到最新值。再如配置中心的配置信息如何保证所有节点保持同步。
事务一致性(Transactional Consistency):一个跨多个服务的业务操作,所有步骤要么全部成功,要么全部回滚。例如电商下单需要同时扣库存、扣红包、扣优惠券——任何一步失败,已执行的步骤都应该回滚。
2.3 为什么会出现一致性问题
分布式系统的数据复制需求主要来源于两个原因:
可用性:将数据复制到多台机器上,可以消除单点故障。当某台机器宕机时,其他机器上的副本仍然可以提供服务。
性能:通过负载均衡技术,让分布在不同地方的数据副本都对外提供读服务,有效提高系统的吞吐量和响应速度。
但数据复制面临的主要难题就是如何保证多个副本之间的数据一致性。在引入复制机制后,不同数据节点之间由于网络延迟、节点故障等原因很容易产生数据不一致。
根源在于数据复制和服务拆分两个场景:
场景一:数据副本同步延迟
客户端写入 → 主库(成功)→ 同步 → 从库(延迟)
客户端读取 → 从库 → 读到旧数据 ❌
场景二:跨服务调用部分失败
下单服务
├── 调用库存服务:扣减库存 ✅
├── 调用红包服务:扣减红包 ✅
└── 调用优惠券服务:扣减优惠券 ❌(超时)
此时库存和红包已扣减,但优惠券未知 → 数据不一致
用一个具体的代码场景说明:
// 电商下单伪代码 —— 跨三个服务的操作
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 失败了怎么办?库存和红包已经扣了,但订单没有创建——用户扣了钱却看不到订单。这就是分布式事务要解决的问题。
3. 理论基础:CAP 与 BASE
3.1 CAP 定理
2000 年,Eric Brewer 在 ACM PODC 会议上提出了 CAP 猜想,2002 年由 Seth Gilbert 和 Nancy Lynch 正式证明为定理:一个分布式系统最多只能同时满足以下三项中的两项——
| 属性 | 含义 | 举例 |
|---|---|---|
| Consistency(一致性) | 所有节点在同一时刻看到相同的数据。更准确地说,对于任何读操作,要么返回最近一次写操作的结果,要么返回错误 | 写入主库后,所有从库立即可读到新值 |
| Availability(可用性) | 每个请求都能在合理时间内收到非错误响应(注意:不保证是最新数据) | 任意时刻发送请求,系统都能正常响应 |
| Partition tolerance(分区容错性) | 网络分区(节点之间的通信中断或延迟)发生时,系统仍能继续运作 | 机房之间的网络断了,各机房仍能独立提供服务 |
为什么 P 不可放弃
在实际的分布式系统中,网络分区(P)是不可避免的——网络硬件会故障、光纤会被挖断、交换机会宕机。你不能假设网络永远不会出问题。正如 2012 年 Coda Hale 在其文章中论证的:"you cannot choose CA"——一旦系统部署在多台机器上,网络分区就是物理现实而非可选项。
因此,CAP 的核心不是"三选二",而是在发生网络分区时,你选择一致性还是可用性:
CAP 三角
C
/ \
/ \
/ \
CP / \ CA(理论上存在,
/ \ 实际不可行,
/ \ 因为 P 不可避免)
P ─────────── A
AP
CP 与 AP 的工程实践
| 策略 | 取舍 | 典型系统 | 工程表现 |
|---|---|---|---|
| CP | 保证一致性,牺牲部分可用性 | ZooKeeper、etcd、HBase | 网络分区时,少数派节点拒绝服务(返回错误),直到分区恢复后才重新提供服务。适用于对数据正确性要求极高的场景:分布式锁、配置管理、leader 选举 |
| AP | 保证可用性,允许短暂不一致 | Cassandra、DynamoDB、DNS、Eureka | 网络分区时,所有节点继续提供服务,但不同节点可能返回不同版本的数据。分区恢复后通过反熵协议(anti-entropy)或读修复(read repair)等机制达到一致。适用于对可用性要求极高的场景:用户信息缓存、社交动态 |
重要澄清:CAP 中的"放弃一致性"不是说数据可以永远不一致,而是放弃强一致性,允许数据在短时间内不一致,但最终会达到一致。分布式系统无论在 CAP 三者之间如何权衡,都无法彻底放弃一致性——如果真的放弃一致性,系统中的数据就不可信,那么这个系统也就没有任何价值可言。所以,我们常说的"放弃一致性"实际指的是放弃强一致性,而不是完全不保证一致性。这就引出了 BASE 理论。
3.2 BASE 理论
BASE 是对 CAP 中 AP 策略的延伸,它的核心思想是:即使无法做到强一致性,也可以通过适当的方式达到最终一致性。
| 缩写 | 全称 | 含义 |
|---|---|---|
| BA | Basically Available | 基本可用——出现故障时允许损失部分非核心功能(如降级、限流),但核心功能可用 |
| S | Soft State | 软状态——允许系统中的数据存在中间状态,即允许不同节点之间的数据副本在同步过程中暂时不一致 |
| E | Eventually Consistent | 最终一致——软状态不会一直持续,经过一段时间后,所有副本最终会达到一致状态 |
"基本可用"的两种典型表现
- 响应时间上的损失:正常情况下搜索引擎在 0.5 秒内返回结果,故障时可以延长到 1-2 秒
- 功能上的损失:电商大促时,为了保护核心的购买流程,暂时关闭评论、推荐等非核心功能
BASE vs ACID
BASE 理论是对 ACID 的妥协和补充。ACID 追求强一致性模型,BASE 追求的则是通过牺牲强一致性来获得可用性:
ACID(强一致性,悲观策略) BASE(最终一致性,乐观策略)
────────────────────── ──────────────────────────
Atomicity 原子性 Basically Available 基本可用
Consistency 一致性 Soft State 软状态
Isolation 隔离性 Eventually Consistent 最终一致
Durability 持久性
ACID 适用于:银行转账、库存扣减等对一致性要求极高的场景
BASE 适用于:社交动态、搜索索引等可以容忍短暂不一致的场景
在实际系统中,ACID 和 BASE 不是非此即彼的选择,很多系统会混合使用——核心链路用 ACID,非核心链路用 BASE。
4. 一致性模型
一致性模型定义了"数据写入后,读取方能看到什么"的约定。不同的模型在一致性强度和系统性能之间做出不同的取舍。如何能既保证数据一致性,又保证系统的性能,是每一个分布式系统都需要重点考虑和权衡的。一致性模型可以在做这些权衡的时候给我们很多借鉴和思考。
4.1 强一致性(Linearizability)
当更新操作完成之后,任何多个后续进程或线程的访问都会返回最新的更新过的值。这种是对用户最友好的——用户上一次写什么,下一次就保证能读到什么。
时间线 →
Writer: Write(x=1) ──── 完成
Reader A: Read(x) → 1 ✅
Reader B: Read(x) → 1 ✅
但这种实现对性能影响较大,因为这意味着只要上次的操作没有处理完,就不能让用户读取数据。所有读取都必须等待写入完成并同步到所有副本。单机数据库的事务就是强一致性的典型实现;在分布式环境中,Raft/Paxos 等共识算法可以实现强一致性,但代价是更高的延迟和更低的吞吐量。
4.2 弱一致性
系统并不保证后续进程或线程的访问都会返回最新的更新过的值。系统在数据写入成功之后,不承诺立即可以读到最新写入的值,也不会具体地承诺多久之后可以读到。但会尽可能保证在某个时间级别(比如秒级别)之后,可以让数据达到一致性状态。
从写入到最终所有读取都能看到新值的这段时间,被称为**"不一致窗口"(inconsistency window)**。弱一致性不对这个窗口的大小做任何承诺。
4.3 最终一致性
弱一致性的特定形式。系统保证:在没有后续更新的前提下,系统最终返回上一次更新操作的值。在没有故障发生的前提下,不一致窗口的时间主要受通信延迟、系统负载和复制副本的个数影响。
DNS 是最典型的最终一致性系统——你修改了域名解析记录,全球各地的 DNS 服务器不会立即更新,但经过 TTL 时间后,所有节点都会拿到新值。
4.4 最终一致性的变体
最终一致性有几种重要的变体,它们在"最终一致"的基础上提供了更具体的保证:
因果一致性(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)
系统保证来自同一个进程的写操作被串行化执行。例如:用户先修改了用户名,再修改了头像,系统不会出现头像先于用户名更新的情况。
变体的组合
上述最终一致性的不同变体可以进行组合使用。从实践的角度来看,读己所写 + 单调读的组合是最实用的——用户总能读取到自己更新的数据,并且一旦读取到最新的版本就不会再读取到旧版本。这个组合对于分布式架构上的程序开发来说,会减少很多额外的复杂性。大部分互联网应用的最终一致性方案都在追求这个组合。
一致性模型强度排序(由强到弱):
强一致性 > 因果一致性 > 读己所写 > 会话一致性 > 单调读/单调写 > 最终一致性 > 弱一致性
↑ ↑
│ │
性能最差,一致性最强 性能最好,一致性最弱
5. 分布式事务:2PC
5.1 什么是分布式事务
分布式事务是将单库事务的概念扩展到多库/多服务——跨越多个独立节点的操作,要么全部提交,要么全部回滚。
核心困难在于:每个节点只知道自己的事务执行结果,不知道其他节点的情况。因此需要引入一个**协调者(Coordinator)**来统一决策。
5.2 XA 规范
X/Open 组织定义的分布式事务处理模型(DTP),包含四个角色:
┌──────────────────────────────────────────────────────┐
│ 应用程序(AP) │
│ 发起全局事务 │
└──────────┬───────────────────────────┬───────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 事务管理器(TM) │ │ 通信资源管理器(CRM)│
│ 协调全局事务 │ │ 消息中间件 │
│ (交易中间件) │ │ │
└────────┬─────────┘ └──────────────────┘
│
┌────┴────┐
▼ ▼
┌───────┐ ┌───────┐
│RM(DB1)│ │RM(DB2)│
│资源管理器│ │资源管理器│
└───────┘ └───────┘
XA 是 TM 与 RM 之间的接口规范——定义了 xa_start、xa_end、xa_prepare、xa_commit、xa_rollback 等接口函数,由数据库厂商实现。2PC 和 3PC 就是基于 XA 规范的具体协议实现。
5.3 两阶段提交(2PC)
2PC 是最经典的分布式事务协议,核心思想:先投票,再执行。
第一阶段:准备(Prepare / Vote)
协调者(TM)
│
┌───────┼───────┐
│ Prepare │ Prepare
▼ ▼
参与者A 参与者B
执行本地事务 执行本地事务
写 redo/undo 写 redo/undo
但不提交 但不提交
│ │
│ Yes/No │ Yes/No
└───────┬───────┘
▼
协调者
每个参与者执行本地事务,写入 redo 和 undo 日志,但不提交,然后向协调者报告"我准备好了(Yes)"或"我执行失败了(No)"。
第二阶段:提交 / 回滚(Commit / Rollback)
情况一:所有参与者都返回 Yes → 提交
协调者
│
┌───────┼───────┐
│ Commit │ Commit
▼ ▼
参与者A 参与者B
正式提交事务 正式提交事务
释放锁资源 释放锁资源
│ │
│ ACK │ ACK
└───────┬───────┘
▼
事务完成 ✅
情况二:任一参与者返回 No 或超时 → 回滚
协调者
│
┌───────┼───────┐
│ Rollback │ Rollback
▼ ▼
参与者A 参与者B
利用 undo 回滚 利用 undo 回滚
释放锁资源 释放锁资源
│ │
│ ACK │ ACK
└───────┬───────┘
▼
事务回滚 ❌
Java 中的 XA 事务示例
// 使用 JTA(Java Transaction API)实现 2PC
import javax.transaction.UserTransaction;
import javax.sql.XADataSource;
public class XATransactionExample {
public void transfer(BigDecimal amount) throws Exception {
UserTransaction utx = (UserTransaction) ctx.lookup("java:comp/UserTransaction");
// XA 数据源(两个不同的数据库)
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 的问题
二阶段提交看起来确实能够提供原子性的操作,但不幸的是,它存在几个严重的缺陷:
问题一:同步阻塞
执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问这些公共资源将不得不处于阻塞状态。从准备阶段开始,参与者就持有了锁资源(写入了 redo/undo 日志,锁定了相关行),这些锁一直要到提交阶段完成才能释放。在高并发场景下,这种长时间持锁会严重影响系统吞吐量。
问题二:单点故障
由于协调者的重要性,一旦协调者发生故障,参与者会一直阻塞下去。尤其在第二阶段,如果协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。虽然可以通过选举协议重新选出一个协调者,但这无法解决因为协调者宕机导致的参与者已经处于阻塞状态的问题——新协调者并不知道上一个协调者在宕机前做出了什么决定。
问题三:数据不一致
在二阶段提交的第二阶段中,当协调者向参与者发送 Commit 请求之后,发生了局部网络异常,或者在发送 Commit 请求过程中协调者发生了故障,这会导致只有一部分参与者接收到了 Commit 请求。收到 Commit 请求的参与者会执行提交操作,而其他未收到的参与者则无法执行事务提交。于是整个分布式系统便出现了数据不一致的现象。
协调者发送 Commit 后宕机:
协调者 ──→ Commit ──→ 参与者A(收到,执行提交 ✅)
──→ Commit ──✗ 参与者B(未收到,仍在等待 ⏳)
──→ Commit ──✗ 参与者C(未收到,仍在等待 ⏳)
结果:A 已提交,B 和 C 仍在阻塞 → 数据不一致
问题四:二阶段无法解决的问题
协调者在发出 Commit 消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使通过选举协议产生了新的协调者,这条事务的状态也是不确定的——没有人知道事务是否已经被提交。新协调者无法从其他存活的参与者那里获取足够信息来做出正确的决定。
6. 分布式事务:3PC
6.1 3PC 对 2PC 的改进
由于二阶段提交存在着同步阻塞、单点故障、数据不一致等缺陷,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。与两阶段提交不同的是,三阶段提交有两个核心改动:
- 引入超时机制:同时在协调者和参与者中都引入超时机制。2PC 中只有协调者有超时机制,参与者在等待协调者指令时会无限阻塞。3PC 的参与者在超时后可以自行做出决定,避免了无限阻塞
- 增加预提交阶段:在第一阶段和第二阶段之间插入一个准备阶段(将 2PC 的准备阶段一分为二),保证了在最后提交阶段之前各参与节点的状态是一致的
6.2 三个阶段
阶段1: CanCommit 阶段2: PreCommit 阶段3: DoCommit
(轻量级询问) (预执行 + 写日志) (正式提交)
协调者 ──→ 参与者 协调者 ──→ 参与者 协调者 ──→ 参与者
"能提交吗?" "预提交" "正式提交"
参与者 ──→ 协调者 参与者 ──→ 协调者 参与者 ──→ 协调者
"Yes / No" "ACK"(执行事务,写日志) "ACK"(提交,释放锁)
阶段一:CanCommit(询问)
协调者向参与者发送 CanCommit 请求,询问是否可以执行事务提交操作,然后开始等待参与者的响应。参与者接到请求后,评估自身能否顺利执行事务(检查资源、权限等),如果认为可以则返回 Yes 响应并进入预备状态,否则返回 No。
注意:此阶段参与者不执行任何事务操作——这是与 2PC 准备阶段的关键区别。2PC 的第一阶段参与者就要执行事务并持有锁,而 3PC 的 CanCommit 只是一个轻量级的"询问",不占用任何资源。
阶段二:PreCommit(预执行)
协调者根据参与者的反应来决定是否可以进行事务的预执行。根据响应情况,有两种可能:
所有参与者返回 Yes → 预执行事务
- 协调者向参与者发送 PreCommit 请求,并进入 Prepared 阶段
- 参与者接收到 PreCommit 请求后,执行事务操作,将 undo 和 redo 信息记录到事务日志中(但不提交)
- 如果参与者成功执行了事务操作,则返回 ACK 响应,同时开始等待最终指令
任一参与者返回 No 或超时 → 中断事务
- 协调者向所有参与者发送 Abort 请求
- 参与者收到 Abort 请求之后(或超时之后仍未收到协调者请求),执行事务中断
阶段三:DoCommit(正式提交)
该阶段进行真正的事务提交,同样分为两种情况:
正常提交
- 协调者接收到所有参与者发送的 ACK 响应,从预提交状态进入提交状态,向所有参与者发送 DoCommit 请求
- 参与者接收到 DoCommit 请求后,执行正式的事务提交,并在完成后释放所有事务资源
- 参与者向协调者发送 ACK 响应
- 协调者接收到所有参与者的 ACK 后,完成事务
中断事务
- 协调者没有接收到参与者发送的 ACK 响应(可能参与者发送的不是 ACK,也可能响应超时),向所有参与者发送 Abort 请求
- 参与者接收到 Abort 请求后,利用阶段二记录的 undo 信息执行事务回滚,并在完成后释放所有事务资源
- 参与者完成回滚后,向协调者发送 ACK 消息
- 协调者接收到参与者反馈的 ACK 消息后,中断事务
超时默认提交的设计推理
关键设计:如果参与者在阶段三等待超时(没收到 DoCommit 也没收到 Abort),它会默认提交。
这个设计是基于概率推理的:当进入第三阶段时,说明参与者在第二阶段已经收到了 PreCommit 请求。而协调者产生 PreCommit 请求的前提条件是——它在第二阶段开始之前,收到了所有参与者的 CanCommit 响应都是 Yes。换句话说,一旦参与者收到了 PreCommit,就意味着它知道大家其实都同意修改了。所以,当进入第三阶段时,虽然参与者由于网络超时没有收到 Commit 或 Abort 响应,但它有理由相信:成功提交的概率远大于需要回滚的概率。
6.3 2PC vs 3PC 对比
| 维度 | 2PC | 3PC |
|---|---|---|
| 阶段数 | 2(准备 + 提交) | 3(询问 + 预提交 + 提交) |
| 超时机制 | 仅协调者有 | 协调者和参与者都有 |
| 阻塞风险 | 高(协调者宕机 → 参与者永久阻塞) | 低(参与者超时后默认提交) |
| 一致性 | 可能不一致(部分提交) | 仍可能不一致(见下文) |
| 网络开销 | 较低 | 多一轮通信 |
相对于 2PC,3PC 主要解决的是单点故障问题,并减少了阻塞——因为一旦参与者无法及时收到来自协调者的信息之后,它会默认执行 Commit,而不会一直持有事务资源并处于阻塞状态。
但是这种机制也会导致数据一致性问题:由于网络原因,协调者发送的 Abort 响应没有及时被参与者接收到,那么参与者在等待超时之后执行了 Commit 操作。这样就和其他接到 Abort 命令并执行了回滚的参与者之间存在数据不一致。
无论 2PC 还是 3PC 都无法彻底解决分布式一致性问题。Google Chubby 的作者 Mike Burrows 说过:"there is only one consensus protocol, and that's Paxos" — all other approaches are just broken versions of Paxos. 意即世上只有一种一致性算法,那就是 Paxos,所有其他一致性算法都是 Paxos 算法的不完整版。但在工程实践中,我们更多使用的是下面介绍的几种柔性事务方案。
7. 柔性事务方案
2PC/3PC 是刚性事务——追求强一致性,代价是性能和可用性。在互联网业务中,更常用的是柔性事务——基于 BASE 理论,接受短暂的不一致,保证最终一致性。
7.1 TCC(Try-Confirm-Cancel)
TCC 将每个业务操作拆分为三个步骤:
| 阶段 | 职责 | 说明 |
|---|---|---|
| Try | 资源预留 | 冻结库存、冻结余额,但不真正扣减 |
| Confirm | 确认执行 | 将冻结的资源正式扣减(幂等) |
| Cancel | 取消释放 | 将冻结的资源释放回去(幂等) |
┌─────────────────────────────────────────────────────────┐
│ TCC 执行流程 │
│ │
│ 业务发起方 │
│ │ │
│ ├── Try(库存服务): 冻结 10 件库存 │
│ ├── Try(余额服务): 冻结 100 元 │
│ ├── Try(优惠券服务): 冻结优惠券 │
│ │ │
│ ├─ 全部 Try 成功 ──→ Confirm 所有服务 ──→ 事务完成 ✅ │
│ │ │
│ └─ 任一 Try 失败 ──→ Cancel 已 Try 的服务 ──→ 事务回滚 ❌│
└─────────────────────────────────────────────────────────┘
代码示例
// 库存服务的 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 因为超时未到达,TCC 框架可能直接调 Cancel
- 防悬挂:Cancel 执行后,迟到的 Try 不能再执行
适用场景
适合对一致性要求较高、资源可以预留的场景:资金转账、库存预扣、票务预订等。
7.2 Saga 模式
Saga 将一个长事务拆分为一系列本地事务,每个本地事务都有对应的补偿操作。如果某个步骤失败,按反方向依次执行补偿操作。
正向执行:T1 → T2 → T3 → T4 → 完成 ✅
异常回滚:T1 → T2 → T3(失败) → C2 → C1 → 回滚完成 ❌
↑补偿T2 ↑补偿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 三个接口) | 中(需实现业务操作 + 补偿操作) |
| 适用场景 | 短事务、需要资源预留 | 长事务、跨多个服务的业务流程 |
| 实现复杂度 | 高(空回滚、悬挂、幂等) | 中等(补偿逻辑、幂等) |
7.3 本地消息表
通过本地数据库事务保证业务操作和消息写入的原子性,再通过异步消息驱动下游操作,最终达到一致。
┌────────────────────────────────────────────────────────────┐
│ 本地消息表流程 │
│ │
│ 上游服务(同一个数据库事务内) │
│ ├── 执行业务操作(如创建订单) │
│ └── 写入消息表(status = PENDING) │
│ │ │
│ 定时任务 ──→ 扫描 PENDING 消息 ──→ 发送到 MQ │
│ │ │
│ 发送成功 ──→ 更新 status = SENT │
│ │ │
│ 下游服务 ←── 消费 MQ 消息 ──→ 执行业务操作(幂等) │
│ │ │
│ 消费成功 ──→ 回调上游 ──→ 更新 status = 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());
}
}
}
优点是实现简单、不依赖特殊中间件。缺点是需要定时轮询,实时性取决于轮询间隔。
7.4 事务消息(RocketMQ)
RocketMQ 原生支持事务消息,相当于中间件级别的本地消息表——将"本地事务 + 消息发送"的原子性保证从应用层下沉到了消息中间件。
┌──────────────────────────────────────────────────────────┐
│ RocketMQ 事务消息流程 │
│ │
│ Producer RocketMQ Consumer│
│ │ │ │ │
│ ├── 1.发送半消息(Half) ────→│ │ │
│ │ ├── 半消息对消费者不可见 │ │
│ │←── 2.半消息发送成功 ──────┤ │ │
│ │ │ │ │
│ ├── 3.执行本地事务 │ │ │
│ │ (如写数据库) │ │ │
│ │ │ │ │
│ ├── 4a.本地事务成功 │ │ │
│ │ 发送 Commit ──────────→├── 消息对消费者可见 ───→│ │
│ │ │ │ │
│ ├── 4b.本地事务失败 │ │ │
│ │ 发送 Rollback ────────→├── 删除半消息 │ │
│ │ │ │ │
│ │── 4c.超时未响应 │ │ │
│ │ ├── 5.回查本地事务状态 │ │
│ │←─────────────────────────┤ │ │
│ ├── 返回 Commit/Rollback ─→│ │ │
└──────────────────────────────────────────────────────────┘
代码示例
// 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);
}
}
事务消息的优势在于:半消息 + 回查机制天然解决了"本地事务成功但消息发送失败"和"消息发送成功但本地事务失败"两种不一致场景。
8. 最大努力通知
最大努力通知是最简单的最终一致性方案:上游系统尽最大努力通知下游系统,如果通知失败则重试若干次,最终仍然失败则需要人工介入或下游主动查询。
上游系统 ──→ 通知下游(第1次)──→ 失败
──→ 通知下游(第2次)──→ 失败(间隔递增)
──→ 通知下游(第3次)──→ 成功 ✅
──→ ...
──→ 通知下游(第N次)──→ 仍然失败 → 放弃,记录日志,等待人工处理
或下游主动查询上游接口
典型应用场景:支付回调。支付宝、微信支付完成扣款后,会多次回调商户的通知地址。如果商户系统一直没有返回成功,支付平台会按递增间隔重试(如 1s、5s、30s、5min、30min),超过最大次数后停止。商户可以通过主动调用支付查询接口来获取最终结果。
9. 方案选型
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 2PC/XA | 强一致 | 低(同步阻塞) | 低(数据库/中间件原生支持) | 数据库层面的跨库事务,对一致性要求极高的场景 |
| 3PC | 强一致(仍有缺陷) | 低(多一轮通信) | 中 | 理论意义大于实践,工程中较少直接使用 |
| TCC | 最终一致 | 高 | 高(三个接口 + 幂等 + 空回滚 + 防悬挂) | 资金交易、库存预扣等需要资源预留的场景 |
| Saga | 最终一致 | 高 | 中 | 长事务、跨多服务的业务编排 |
| 本地消息表 | 最终一致 | 中(依赖轮询间隔) | 低 | 对实时性要求不高的异步场景 |
| 事务消息 | 最终一致 | 高 | 低(中间件原生支持) | 基于消息驱动的异步业务,如订单 → 物流 → 通知 |
| 最大努力通知 | 最终一致(弱保证) | 高 | 最低 | 跨平台/跨企业的通知场景,如支付回调 |
选型建议
一致性要求高?
┌── 是 ──→ 能接受性能损失?
│ ├── 是 ──→ 2PC/XA
│ └── 否 ──→ TCC
│
└── 否 ──→ 涉及多步骤编排?
├── 是 ──→ Saga
└── 否 ──→ 事务消息 / 本地消息表
实际项目中的经验法则:
- 能用单库事务解决就不要用分布式事务——分布式事务的复杂度远超想象
- 大部分互联网业务用最终一致性就够了——用户能接受几秒的延迟
- 资金相关用 TCC,业务流程编排用 Saga,异步通知用事务消息
- 无论哪种方案,幂等性设计都是基础——网络重试无处不在