位架构师、分布式事务的"填坑人"们,大家好!之前咱们聊了锁,那是单机世界的"独裁者",只要内存和磁盘听你指挥,synchronized 或 ReentrantLock 就能搞定一切。
但到了微服务时代,服务拆分了,数据库也拆分了。订单服务 在 MySQL A,库存服务 在 MySQL B。这时候,你想让 A 和 B 同生共死,就像想让两个分居两地的前男友和前女友同时立刻、马上、绝对地答应你的复合请求一样难。这就是分布式事务。
今天,咱们把这层遮羞布扯下来,看看业界为了解决这个问题,到底折腾出了哪些"神器"和"坑"。
第一站:2PC/3PC ------ XA 协议的"霸道总裁"模式
这是最古老、最正统,也最"不招人待见"的方案。XA 协议是分布式事务的鼻祖,它的核心是两阶段提交(2PC)。但大多数人对它的理解仅停留在"准备"和"提交"两个阶段,却忽略了它在数据库内核层面的实现代价。
原理:两阶段提交(2PC)
XA 事务在 MySQL InnoDB 引擎中,不仅仅是逻辑上的两阶段,它涉及到物理日志的写入时机。想象你是一个包工头(协调者),手下有两个小弟(参与者/资源管理器),一个负责砌墙,一个负责刷漆。你要保证要么墙砌好且漆刷好,要么都别干。
- 阶段一(Prepare) :
- 协调者(TM)向所有参与者(RM)发送
XA PREPARE。 - InnoDB 内核动作 :
- 将事务的修改写入 Redo Log (重做日志),并标记为
XID_PREPARED状态。 - 释放行锁(注意:InnoDB 在 Prepare 阶段会释放行锁,但持有"全局读锁"或特殊的 XA 锁,防止其他事务修改该行直到 XA 提交)。
- 返回
XA_OK给协调者。
- 将事务的修改写入 Redo Log (重做日志),并标记为
- 协调者(TM)向所有参与者(RM)发送
- 阶段二(Commit/Rollback) :
- 协调者收集所有
XA_OK后,发送XA COMMIT。 - InnoDB 内核动作 :
- 将 Commit 记录写入 Binlog(如果是主从架构)。
- 将 Redo Log 标记为
COMMITTED。 - 清理 Undo Log。
- 协调者收集所有
如果阶段一里有人喊"不能",你就喊:"回滚!" 大家都得把手里的活撤销。
缺点:为什么它是"万恶之源"?
- 同步阻塞 :在阶段一和阶段二之间,所有小弟都得傻站着 。A 砌完墙了,锁住了砖头,但他不敢走,得等你喊口号。这时候如果有其他人想用砖头?没门!等着吧!这就导致性能极差。
- 单点故障 :如果你这个包工头(协调者)喊完"提交"就挂了,小弟们就懵了。是提交还是回滚?不知道。只能一直锁着资源,导致死锁。
除了大家都知道的"同步阻塞"和"单点故障",XA 在 MySQL 中有一个更隐蔽的坑:两阶段提交导致的崩溃恢复问题。
- Crash 场景 :如果 MySQL 在 Redo Log 写入
PREPARE成功,但 Binlog 还没写入时宕机。 - 恢复逻辑 :
- 重启后,MySQL 检查 Redo Log,发现有
XID_PREPARED。 - 它不知道 Binlog 是否完整写入。
- 于是 MySQL 会去询问 XA 协调者(如果配置了)或者依赖上层应用层来决定是 Commit 还是 Rollback。
- 后果 :这就导致了长时间的数据不可用,因为该事务持有的资源(虽然行锁释放了,但逻辑上未完结)会阻塞后续依赖该数据状态的操作。
- 重启后,MySQL 检查 Redo Log,发现有
结论 :XA 是强一致性 的,但它是建立在牺牲高并发性能 和增加数据库崩溃恢复复杂度 的基础上的。在微服务架构中,除非是极低频的后台操作,否则严禁使用 XA。
3PC:加个"预演"阶段
为了缓解阻塞,3PC 加了个"预提交"阶段。
- 简单说就是:协调者先问"能行吗?",大家说"能";协调者再说"准备提交",大家说"好";最后协调者说"提交"。
- 如果协调者在"准备提交"阶段挂了,参与者超时后可以根据策略自己决定提交(因为大家都说好准备了)。
但是 ,3PC 引入了更复杂的逻辑,且在网络分区时依然可能导致数据不一致。所以,别太当真,现在很少用原生的 3PC。
第二站:TCC ------ 阿里系的"硬核"补偿模式
TCC(Try-Confirm-Cancel)是互联网大厂(尤其是阿里系)非常喜欢用的模式。它不是靠数据库锁,而是靠业务代码 来保证一致性。把事务控制从数据库层上移到了业务应用层。
原理:三步走
还是那个包工头,这次他学精了,不直接干活,先预留 。本质是补偿。它不再是数据库层面的原子操作,而是业务层面的逻辑拆分。
- Try(尝试|资源预留) :
- 小弟 A(库存):检查库存够不够?够的话,冻结 100 个(不是扣减,是冻结)。
- 小弟 B(账户):检查余额够不够?够的话,冻结 500 块。
- 注意:这里全是预留资源,不产生最终业务影响。
- 核心动作:检查并锁定资源。
- 底层实现:在数据库中插入一条"冻结记录"或更新状态为"冻结"。
- 关键点 :Try 阶段必须保证幂等 ,且必须记录分支事务 ID。
- Confirm(确认|业务提交) :
- 如果Try阶段大家都成功,协调者喊:"Confirm",核心动作:使用 Try 阶段预留的资源
- 小弟 A:把冻结的 100 个库存真正扣掉 。底层实现:将状态从"冻结"更新为"生效"
- 小弟 B:把冻结的 500 块真正扣掉。
- 约束 :Confirm 操作必须成功。如果失败,框架会无限重试。因此,Confirm 方法内部必须处理幂等(通过事务 ID 去重)。
- Cancel(取消|业务回滚) :
- 如果 Try 阶段有人失败(比如余额不足),协调者喊:"Cancel!"
- 小弟 A:把冻结的库存解冻 (释放)。核心动作:释放 Try 阶段预留的资源。
- 小弟 B:把冻结的余额解冻 。底层实现:删除冻结记录或回滚状态。
深度痛点:悬挂与空回滚
这是 TCC 最难处理的地方,也是容易忽略的。
- 悬挂(Dangling) :
- 场景 :Try 请求因为网络拥堵,比 Cancel 请求晚到。
- 后果:Cancel 先执行,发现没有预留资源,直接返回成功。随后 Try 请求到了,执行了预留。结果事务永远处于"Try 成功"状态,无法回滚,造成资源死锁。
- 解法 :在 Try 方法执行前,必须查询是否有对应的 Cancel 记录。如果有,说明 Cancel 已经执行过了,拒绝 Try。
- 空回滚(Null Compensation) :
- 场景:Try 请求因为网络原因根本没到,协调者超时直接调用 Cancel。
- 后果:Cancel 发现没有资源可回滚。
- 解法:Cancel 方法必须记录"已回滚"的日志,防止后续 Try 请求到达时误操作。
优缺点
- 优点:性能比 2PC 好,因为 Try 阶段不锁数据库(只是逻辑冻结),Confirm/Cancel 阶段非常快。
- 缺点 :代码侵入性极强!你得为每个业务写三个方法(Try, Confirm, Cancel)。如果业务逻辑复杂(比如涉及第三方接口),TCC 会让你写到怀疑人生。
结论 :TCC 性能极高(因为 Try 阶段不锁数据库行,只锁业务逻辑),但代码侵入性极强 。你需要为每个业务写三个方法,还要处理幂等、悬挂、空回滚。适合对一致性要求高、且并发量大的核心链路(如支付、扣库存)。
第三站:本地消息表/事务消息 ------ 最实用的"最终一致性"
这是互联网架构中最主流的方案。它的核心思想是:别想一口吃成个胖子,先保证自己这边不出事。把分布式事务拆解成多个本地事务,通过消息队列进行异步通知。
原理:本地消息表
- 底层保障 :利用本地数据库的 ACID 特性,保证了业务操作 与消息发送的原子性。
- 订单服务 :
- 在业务数据库中创建一张
message表 - 开启本地事务。插入业务数据 + 插入消息记录(状态:待发送)
- 提交事务。
- (此时,订单和消息都落地了,原子性保证!)
- 在业务数据库中创建一张
- 异步发送 :
- 有一个定时任务(或监听器),扫描
message表里"待发送"的记录。 - 把消息发到 MQ。
- 发送成功后,更新
message表状态为"已发送"。
- 有一个定时任务(或监听器),扫描
- 下游消费 :
- 库存服务监听 MQ,收到消息后扣减库存。
升级版:RocketMQ 事务消息
RocketMQ 把这个过程封装得更优雅。
- 生产者先发一个半消息(对消费者不可见)。
- MQ 返回成功。
- 生产者收到 MQ 的 ACK 后,执行本地事务(订单入库)
- 如果本地事务成功,告诉 MQ 提交,消息对消费者可见。
- 如果失败,告诉 MQ 回滚,消息删除。
- 兜底 :如果生产者挂了,MQ 没收到确认,会反过来查生产者:"兄弟,刚才那条消息咋样了?"(回查机制)定期扫描那些长时间处于"半消息"状态的消息
- 回调生产者的
checkLocalTransaction接口,询问:"兄弟,这条消息对应的本地事务到底成功没?"。 - 生产者查数据库,返回状态
- 回调生产者的
评价 :这是最实用 的方案,解耦彻底,性能极高,只要你能接受"最终一致性"。适合非实时性要求高、但数据量大的场景(如积分、通知、大数据同步)
第四站:Seata ------ AT 模式的"无侵入"魔法
Seata 是阿里开源的分布式事务框架,它的 AT 模式 是目前 Java 界最火的方案,因为它几乎不用改代码。
原理:自动代理 + 逆向日志
Seata AT 模式其实是对 2PC 的一种优化,它把"脏活累活"都交给了框架。
-
阶段一 :
- 拦截 SQL :Seata 拦截业务 SQL(如
UPDATE account SET money = money - 100 WHERE id = 1)。 - 查询前镜像(Before Image) :在执行更新前,Seata 自动执行
SELECT * FROM account WHERE id = 1,拿到旧数据(money=200)。 - 执行 SQL:执行真正的更新操作(money=100)。
- 查询后镜像(After Image):执行后查询新数据(money=100)。
- 生成 Undo Log :将前镜像、后镜像、SQL 信息组装成一条 Undo Log 记录,插入到
undo_log表。 - 本地提交:将业务数据和 Undo Log 在同一个本地事务中提交。
- 释放本地锁:注意,此时本地数据库锁已经释放!
- 汇报:向 Seata Server(TC)汇报"我准备好了"。
- 拦截 SQL :Seata 拦截业务 SQL(如
-
阶段二(Commit/Rollback):
- Commit :异步删除
undo_log。速度极快。 - Rollback :
- Seata Server 发送回滚指令。
- 参与者根据
undo_log中的前镜像 ,生成反向 SQL(UPDATE account SET money = 200 WHERE id = 1)。 - 执行反向 SQL,恢复数据。
- Commit :异步删除
深度痛点:脏写(Dirty Write)
Seata AT 最大的问题是隔离性差。
-
场景:
- 事务 A 修改了数据(money -100),提交了本地事务(释放了行锁),但全局事务还没提交。
- 此时,事务 B 读取了这笔数据(money=100),并进行了修改(money -50),提交了。
- 如果事务 A 此时回滚,它会恢复数据到 200。
- 结果 :事务 B 的修改(-50)被事务 A 的回滚覆盖了!这就是脏写。
-
Seata 的解法:全局锁(Global Lock):
- 在阶段一提交本地事务时,Seata 会尝试获取全局锁 (在
global_table中加锁)。 - 如果拿不到全局锁(说明有其他全局事务在操作同一行),本地事务会重试,直到拿到锁才提交。
- 代价 :虽然避免了脏写,但性能下降,退化成类似 XA 的串行化。
- 在阶段一提交本地事务时,Seata 会尝试获取全局锁 (在
优缺点
- 优点 :无侵入 !业务代码只需要加一个
@GlobalTransactional注解,完全不用写 Try/Confirm 代码。 - 缺点 :因为依赖数据库的本地事务和行锁,并发度受限。如果两个事务同时操作同一行数据,还是会锁住。
总结:怎么选?
- 强一致性需求(极少) :选 Seata AT 或 XA。但要做好性能下降的心理准备。
- 高并发、核心业务(推荐) :选 TCC。虽然代码写得累,但性能可控,逻辑清晰。
- 高并发、非核心/异步业务(最常用) :选 本地消息表/MQ 事务消息。这是互联网架构的基石,用"时间换空间",实现最终一致性。
| 方案 | 一致性 | 性能 | 侵入性 | 隔离性 | 适用场景 |
|---|---|---|---|---|---|
| XA (2PC) | 强一致 | 低(阻塞) | 低 | 高 | 传统单体拆微服务初期,低频后台任务 |
| TCC | 最终一致 | 高 | 高(需写三个方法) | 高(业务控制) | 核心交易链路,高并发,对一致性要求高 |
| MQ 事务 | 最终一致 | 极高(异步) | 中(需发消息) | 低 | 跨系统解耦,非实时业务(积分、通知) |
| Seata AT | 最终一致 | 中(依赖全局锁) | 低(注解) | 中(有脏写风险) | 内部微服务,开发效率优先,并发适中 |
最后送你一句话 :
"分布式事务没有银弹。所有的方案都是在一致性 、可用性 和性能之间做交易。作为架构师,你的任务不是寻找完美的方案,而是找到最适合你业务场景的那个'平衡点'。"