分布式事务

在单体应用时代,事务(Transaction)是数据库送给开发者最昂贵的礼物。你只需要由 Spring 加上 @Transactional 注解,数据库就会保证:要么全做,要么全不做。

但当你把系统从单体拆分成微服务,或者引入了多个数据库时,这个“魔法”失效了。

假设你在开发一个电商系统。用户点击“下单”按钮,后台需要发生两个动作:

  1. 订单服务:在订单库创建一条订单记录。
  2. 库存服务:在库存库扣减一个商品库存。

如果在单体架构下,这是一个本地事务。但在微服务架构下,这是两个跨网络的独立调用。如果订单创建成功了,网络抖动导致库存扣减请求超时,或者库存扣减成功了,但订单服务挂了,数据库的数据就会出现“不一致”:多卖了货,或者少记了账。

这就是分布式事务要解决的核心问题:如何跨越网络的不确定性,维持数据的一致性

理论基石

在深入具体方案之前,我们需要理解悬在所有分布式系统头顶的两把利剑:CAP 定理BASE 理论

CAP 定理:不得不做的选择题

该理论最初由 Eric Brewer 在 2000 年 PODC 会议上提出猜想,后由 Seth Gilbert 和 Nancy Lynch 在 2002 年通过形式化证明确立。

定理内容为:在一个分布式数据存储系统中,一致性、可用性、分区容错性 这三个基本要素,最多只能同时满足其中的两项

  • C (C onsistency):一致性(所有节点在同一时间看到的数据是相同的)。
  • A (A vailability):可用性(系统总能响应请求,即使部分节点挂了)。
  • P (P artition tolerance):分区容错性(当 网络因为故障导致节点之间通信中断(即发生网络分区),系统仍能持续运行并提供服务。)。

由于 网络不可靠是客观事实P(分区容错)是必须满足的前提

因此,真正的抉择在于:当网络分区发生时,系统选择 CP(强一致,但在网络抖动时系统不可用) 还是 AP(高可用,但允许数据暂时不一致)。

CP 架构:一致性优先

  • 设计逻辑:当发生分区导致部分节点无法同步时,为了保证数据准确(C),系统选择拒绝服务或阻塞请求,直到网络恢复并达成共识。
  • 代价:在分区期间,部分或全部服务不可用,系统的可用性下降。
  • 核心技术
    • 共识算法:Paxos, Raft, Zab。
    • 多数派机制(Quorum):写入必须获得超过半数节点的确认。
  • 典型系统
    • ZooKeeper / Etcd:用于分布式协调和元数据管理,Leader 选举期间集群对外不可用。
    • HBase:基于 RegionServer 的行锁机制保证强一致性。
    • Redis (Sentinel/Cluster):在默认配置及故障转移期间,实际上倾向于 CP,但在某些极端情况下可能丢失数据(异步复制导致)。
  • 适用场景:金融交易、库存扣减、分布式锁、元数据管理。

AP 架构:可用性优先

  • 设计逻辑:当发生分区时,优先保证业务不中断,允许节点返回本地的旧版本数据(Stale Data)。待网络恢复后,再通过后台机制进行数据合并与冲突解决。
  • 代价:不同分区的节点持有不同版本的数据,出现“脑裂”或数据不一致,需要复杂的冲突合并逻辑。
  • 核心技术
    • Gossip 协议:节点间随机通信传播状态。
    • 冲突解决:向量时钟(Vector Clock)、最后写入胜出(LWW)、CRDTs(无冲突复制数据类型)。
    • 反熵机制(Anti-Entropy):如 Read Repair(读修复)和 Hinted Handoff(提示移交)。
  • 典型系统
    • Cassandra / DynamoDB:基于最终一致性模型,写入即可返回,后台异步同步。
    • Eureka:服务发现组件,优先保证客户端能获取服务列表,即使列表包含已下线的服务。
  • 适用场景:社交动态点赞、商品评论、DNS 解析、Web 缓存、内容分发网络(CDN)。

PACELC 理论

由 Daniel Abadi 提出,填补了 CAP 理论中“无分区时如何权衡”的空白。

  • P (Partition):如果有网络分区,必须在 A(可用性)C(一致性) 之间选择。
  • E (Else):如果没有网络分区(系统正常运行时),必须在 L(Latency,延迟)C(一致性) 之间选择。
  • 深层含义:强一致性通常意味着需要等待更多节点的确认,这必然增加延迟。如果追求极致的低延迟,往往需要放宽一致性要求。

BASE 理论:反抗强一致性

为了追求互联网应用的高可用,我们往往退而求其次,遵循 BASE 理论:

  • BA (Basically Available):基本可用(允许服务降级)。
  • S (Soft state):软状态(允许存在中间状态,如“支付中”)。
  • E (Eventually consistent):最终一致性(经过一段时间,数据终变一致)。

反面教材:2PC (两阶段提交)

最早的尝试是 2PC (Two-Phase Commit),这是一种试图在分布式环境实现“严格原子性”的协议。

  • 阶段 1 (Prepare):协调者问所有参与者:“能提交吗?”参与者锁定资源,写日志,但不提交。
  • 阶段 2 (Commit):全员回复 Yes 则提交;只要有一个 No 则全员回滚。

为什么互联网大厂不用它?

  • 同步阻塞:所有人都要等最慢的那个人。
  • 死锁风险:协调者挂了,参与者会无限期持有锁。
  • 性能毒药:在微服务高并发场景下,这种长时锁是吞吐量的杀手。

主流解决方案

Seata (Simple Extensible Autonomous Transaction Architecture) 是阿里巴巴开源的一站式分布式事务解决方案,它致力于让分布式事务的使用像本地事务一样简单。

Seata AT 模式

Seata AT 模式的设计目标是:把分布式事务变得像本地事务一样简单。

想象你在编辑文档,当你犯错时,按下 Ctrl+Z 就能撤销。Seata AT 就是给数据库操作装了一个自动的 Ctrl+Z

你不需要修改业务代码,只需要在方法上打一个注解。Seata 会利用代理机制,拦截你的 SQL,自动记录“修改前的数据”和“修改后的数据”。如果所有服务都成功,它就清理掉这些记录;如果有服务失败,它就利用这些记录把数据还原。

Seata 在你的业务 SQL 执行前后,悄悄做了手脚。

核心流程(伪代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 阶段一:执行并记录
1. 解析业务 SQL: UPDATE product SET stock = 99 WHERE id = 1;
2. 查询前镜像 (Before Image):记录 stock = 100;
3. 执行业务 SQL: stock 变为 99;
4. 查询后镜像 (After Image):记录 stock = 99;
5. 插入 Undo Log:将前后镜像打包存入 undo_log 表;
6. 提交本地事务 (业务数据 + undo_log 同时生效);

// 阶段二:决断
IF (全局提交) {
异步删除 undo_log; // 极其快速
} ELSE (全局回滚) {
根据 undo_log 生成反向 SQL;
UPDATE product SET stock = 100 WHERE id = 1;
}

AT 模式是典型的“低门槛”方案,但工程界没有免费的午餐。它有一个致命隐患:脏写(Dirty Write)

如果有一个非 Seata 管理的后台脚本,直接去修改了数据库,会发生什么?

  1. 事务 A 修改库存 ,提交本地事务(释放了数据库行锁)。
  2. 外部脚本 修改库存 。
  3. 事务 A 收到回滚指令,根据 Before Image (100) 强行回滚:SET stock = 100
  4. 结果:外部脚本修改的 80 被覆盖丢失了。

为了解决这个问题,Seata 引入了 全局锁。在二阶段提交/回滚完成前,该记录被 Seata 的协调者(TC)逻辑锁定。这带来了一个严重的性能权衡:热点数据的高并发写

如果你的业务是“秒杀”场景,成千上万的请求同时抢夺同一行库存记录的全局锁,Seata AT 将成为系统的性能瓶颈,且极易引发死锁。

结论:AT 适合并发量适中、非热点数据、追求开发效率的中后台业务。

XA 模式

在 AT 模式中,Seata 需要自己在应用层解析 SQL、生成 Undo Log,这就像是一个“外挂”。

XA 模式 是数据库(MySQL、Oracle、PostgreSQL)自带 的功能。它遵循 X/Open 组织制定的 DTP(Distributed Transaction Processing)标准。

类比:正规的法律合同

  • AT 模式:是你和朋友私下约定的“君子协定”,如果反悔了,你们自己负责恢复现场。
  • XA 模式:是走了公证处的“法律合同”。数据库本身(公证处)保证:只要你签了字(Prepare),我就确保这件事一定能成,或者一定能退,不需要你自己操心怎么退。

XA 是 2PC(两阶段提交) 的标准实现。

  • 一阶段(Prepare):业务代码执行 SQL,但在提交时,Seata(TM)会告诉数据库:“先别真提交,把资源锁死,数据写入日志,进入‘准备好’的状态”。此时,数据库会锁定这一行数据,任何人都动不了。
  • 二阶段(Commit / Rollback):Seata 收集所有服务的状态。
    • 全成功:通知数据库“正式提交”,数据库释放锁,数据生效。
    • 有失败:通知数据库“回滚”,数据库利用自身的日志恢复,释放锁。

Seata XA 与 Seata AT 的核心区别

  • AT:锁在 Seata 侧(Global Lock),数据库本地事务早就提交了。回滚靠 Undo Log。
  • XA:锁在数据库侧,数据库本地事务一直 挂起,直到二阶段结束。

优点:

  1. 强一致性:由数据库保证,没有脏读,没有中间状态。
  2. SQL 支持度完美:AT 模式解析复杂 SQL(如存储过程、复杂连接)可能会失败,但 XA 是数据库原生的,支持所有 SQL。
  3. 无侵入:不需要像 AT 那样建 undo_log 表。

缺点:

  1. 性能短板:因为数据库锁要持有到第二阶段结束(跨网络通讯),锁的时间长,并发度低。
  2. 木桶效应:如果协调者(TC)挂了,或者网络断了,数据库的锁会一直释放不掉,导致业务瘫痪。

为什么它又“重获新生”了?

过去互联网公司不用 XA 是因为性能差。但随着 云原生数据库(如阿里云 PolarDB)和 高速网络 的普及,XA 的性能损耗被大幅降低。现在,Seata 的 XA 模式已经优化得非常好了,对于 非高并发、但数据极其重要 的系统(如内部 ERP、财务系统),XA 是最稳妥的选择。

Seata TCC 模式

AT 模式因为要加全局锁,性能有上限。为了追求极致性能,我们需要把锁的粒度从“数据库层面”移交到“业务逻辑层面”。

类比:买房交定金

  • Try (定金):你不用付全款,先付 5 万定金。房产中介把房子“锁”住(预留),别人买不了。
  • Confirm (交房):你凑齐了首付,正式过户。
  • Cancel (退定):你反悔了,中介把房子释放出来卖给别人,定金退回。

TCC 强制要求你将一个业务接口拆分为三个方法。

代码逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface StockService {
// 1. Try: 资源检查 + 预留
// 并不是直接扣减,而是把库存移入 "frozen_stock" 字段
@Transactional
void tryDeduct(long id, int count) {
checkStock(id, count);
stock -= count;
frozen_stock += count; // 关键:冻结资源
}

// 2. Confirm: 真正扣减
// 此时不需要检查库存,直接使用 Try 阶段锁定的资源
void confirmDeduct(long id, int count) {
frozen_stock -= count; // 消耗冻结,真正完成
}

// 3. Cancel: 释放资源
void cancelDeduct(long id, int count) {
stock += count; // 恢复可用库存
frozen_stock -= count; // 释放冻结
}
}

TCC 赋予了开发者极大的灵活性,但它将 复杂性 完全转嫁给了业务方。在分布式网络环境中,你必须在代码中处理以下三种极端异常,否则会导致资金损失。

  1. 空回滚

    • 场景:Try 请求因为网络丢包,根本没发到库存服务。但协调器(TC)认为失败了,触发了 Cancel。

    • 后果:Cancel 执行了“退钱/释放库存”的操作。如果没做防护,会导致库存凭空增加。

    • 对策:Cancel 必须检查“是否收到过 Try”。

  2. 幂等

    • 场景:网络抖动,TC 以为 Confirm 超时了,重发了一次 Confirm。

    • 后果:库存被扣了两次。

    • 对策:所有 Confirm/Cancel 必须实现幂等,处理过的事务 ID 直接返回成功。

  3. 悬挂

    • 场景:网络极度拥堵。Cancel 比 Try 先到了。Cancel 发现没 Try 过,直接返回成功(空回滚)。随后,迟到的 Try 到了,并执行成功。
    • 后果:资源被 Try 冻结了,但永远不会被 Confirm 或 Cancel,变成了“僵尸资源”。
    • 对策:Try 执行时,必须检查该事务 ID 是否已经执行过 Cancel。

结论:TCC 性能极高(不长期持有数据库锁),但开发和测试成本极高。仅适用于核心资金链路或对并发要求极高的场景。

Seata Saga 模式

有些业务流程非常长,既不能像 AT 模式那样长时间锁表(导致数据库瘫痪),也不能像 TCC 那样要求每个服务都改造代码。

你要预订一次欧洲旅行,流程是:订机票 订酒店 租车。这个流程涉及三个不同的供应商,甚至可能需要人工审核,整个过程可能持续几分钟甚至几小时。

  • 如果用 AT/TCC:从订机票开始,数据库就被锁住了。直到租车成功,机票的锁才释放。这期间,别人无法买这张票。

核心定义:Saga 模式把一个大事务拆分成多个 互不相关 的本地短事务。就像接力赛:

  • 第一棒(机票)跑完,立刻提交,释放锁。
  • 第二棒(酒店)跑完,立刻提交
  • 如果第三棒(租车)摔倒了,Saga 协调器会负责往回跑:退酒店(补偿)、退机票(补偿)。

Saga 的核心在于定义“如何前进”和“如何后退”。开发者需要通过 JSON 或代码定义一个状态机。

  • 正常情况(All Good):(提交) (提交) (提交) 结束
  • 异常情况(C 失败):(提交) (提交) (失败) 触发回滚 (B 的补偿) (A 的补偿)

Saga 是分布式事务中最“反直觉”的模式,因为它打破了 ACID 中的 I (Isolation,隔离性)

由于缺乏隔离性引发的“脏读。在 Saga 模式中,因为每个子事务都是 立即提交 的,所以中间状态对外界是 可见 的。

  1. 用户 A 订了最后一张机票( 提交)。
  2. 用户 B 来查票,发现没票了(看见了 A 的提交)。
  3. 用户 A 后续的酒店预订失败了,触发补偿取消了机票( 执行)。
  4. 结果:用户 B 刚才看到的是一个“无效”的状态,且因为这个状态放弃了购买。这就是 脏读

为了解决这个问题,你通常需要在业务层面引入 “语义锁” (Semantic Lock)

  • 比如:在订机票时,不直接把票减掉,而是将状态改为 BOOKING_PENDING(预订中)。
  • 只有当整个 Saga 流程全部完成,才把状态改为 CONFIRMED

结论:Saga 适合 业务流程极长跨机构/跨遗留系统(无法改造代码使用 TCC,只能调用接口)的场景。

RocketMQ 事务消息

并不是所有业务都需要“同时成功”。

场景:用户支付成功后,需要增加用户积分。

如果支付成功了,积分服务挂了,用户能不能忍受积分晚 10 秒到账?通常是可以的。

这就不需要强一致性事务,而是 最终一致性。核心思想是:只要我本地事务成功了,我就保证消息一定能发出去;只要消息发出去了,消费者最终一定能收到。

普通的 MQ 发送消息是不可靠的:可能消息发出去了,本地数据库回滚了;或者本地数据库提交了,消息发失败了。RocketMQ 引入了 Half Message(半消息) 来解决原子性问题。

核心步骤:

  1. 发送半消息:生产者发一条消息给 MQ,MQ 收到了但不给消费者看(Pending 状态)。

  2. 执行本地事务:生产者执行具体的业务(如:更新订单状态)。

  3. Commit/Rollback

    • 本地事务成功 通知 MQ “这条消息可以投递了”。

    • 本地事务失败 通知 MQ “把这条消息删了吧”。

RocketMQ 方案的精髓在于 处理“未决”状态

如果在执行本地事务后,服务器立刻断电,没来得及发 Commit 给 MQ,怎么办?

MQ 里的消息一直是“半消息”状态。RocketMQ 会启动 回查机制(CheckBack)

MQ 会主动问生产者:“刚才那个事务 ID,你到底执行成功没?”

生产者需要实现一个检查接口(例如查数据库订单表),确认事务状态并回复。

本地消息表 (Local Message Table):如果你不用 RocketMQ,也可以用“本地消息表”模式。原理一样:在同一个数据库事务里插入业务数据和一条“待发送”的消息记录。然后有一个定时任务不断轮询这张表去发消息。

  • 代价:不仅要写业务表,还要写消息表,存在数据库 I/O 竞争。

结论:这是微服务解耦最常用的方案。它牺牲了实时性(E),换取了高可用(A)和分区容错性(P)。


分布式事务
https://gavinmo1.github.io/2025/11/22/分布式事务/
作者
Gavin
发布于
2025年11月22日
许可协议