分布式事务
位架构师、分布式事务的“填坑人”们大家好之前咱们聊了锁那是单机世界的“独裁者”只要内存和磁盘听你指挥synchronized或ReentrantLock就能搞定一切。但到了微服务时代服务拆分了数据库也拆分了。订单服务在 MySQL A库存服务在 MySQL B。这时候你想让 A 和 B 同生共死就像想让两个分居两地的前男友和前女友同时立刻、马上、绝对地答应你的复合请求一样难。这就是分布式事务。今天咱们把这层遮羞布扯下来看看业界为了解决这个问题到底折腾出了哪些“神器”和“坑”。第一站2PC/3PC —— XA 协议的“霸道总裁”模式这是最古老、最正统也最“不招人待见”的方案。XA 协议是分布式事务的鼻祖它的核心是两阶段提交2PC。但大多数人对它的理解仅停留在“准备”和“提交”两个阶段却忽略了它在数据库内核层面的实现代价。原理两阶段提交2PCXA 事务在 MySQL InnoDB 引擎中不仅仅是逻辑上的两阶段它涉及到物理日志的写入时机。想象你是一个包工头协调者手下有两个小弟参与者/资源管理器一个负责砌墙一个负责刷漆。你要保证要么墙砌好且漆刷好要么都别干。阶段一Prepare协调者TM向所有参与者RM发送XA PREPARE。InnoDB 内核动作将事务的修改写入Redo Log重做日志并标记为XID_PREPARED状态。释放行锁注意InnoDB 在 Prepare 阶段会释放行锁但持有“全局读锁”或特殊的 XA 锁防止其他事务修改该行直到 XA 提交。返回XA_OK给协调者。阶段二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。后果这就导致了长时间的数据不可用因为该事务持有的资源虽然行锁释放了但逻辑上未完结会阻塞后续依赖该数据状态的操作。结论XA 是强一致性的但它是建立在牺牲高并发性能和增加数据库崩溃恢复复杂度的基础上的。在微服务架构中除非是极低频的后台操作否则严禁使用 XA。3PC加个“预演”阶段为了缓解阻塞3PC 加了个“预提交”阶段。简单说就是协调者先问“能行吗”大家说“能”协调者再说“准备提交”大家说“好”最后协调者说“提交”。如果协调者在“准备提交”阶段挂了参与者超时后可以根据策略自己决定提交因为大家都说好准备了。但是3PC 引入了更复杂的逻辑且在网络分区时依然可能导致数据不一致。所以别太当真现在很少用原生的 3PC。第二站TCC —— 阿里系的“硬核”补偿模式TCCTry-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 的一种优化它把“脏活累活”都交给了框架。阶段一拦截 SQLSeata 拦截业务 SQL如UPDATE account SET money money - 100 WHERE id 1。查询前镜像Before Image在执行更新前Seata 自动执行SELECT * FROM account WHERE id 1拿到旧数据money200。执行 SQL执行真正的更新操作money100。查询后镜像After Image执行后查询新数据money100。生成 Undo Log将前镜像、后镜像、SQL 信息组装成一条 Undo Log 记录插入到undo_log表。本地提交将业务数据和 Undo Log 在同一个本地事务中提交。释放本地锁注意此时本地数据库锁已经释放汇报向 Seata ServerTC汇报“我准备好了”。阶段二Commit/RollbackCommit异步删除undo_log。速度极快。RollbackSeata Server 发送回滚指令。参与者根据undo_log中的前镜像生成反向 SQLUPDATE account SET money 200 WHERE id 1。执行反向 SQL恢复数据。深度痛点脏写Dirty WriteSeata AT 最大的问题是隔离性差。场景事务 A 修改了数据money -100提交了本地事务释放了行锁但全局事务还没提交。此时事务 B 读取了这笔数据money100并进行了修改money -50提交了。如果事务 A 此时回滚它会恢复数据到 200。结果事务 B 的修改-50被事务 A 的回滚覆盖了这就是脏写。Seata 的解法全局锁Global Lock在阶段一提交本地事务时Seata 会尝试获取全局锁在global_table中加锁。如果拿不到全局锁说明有其他全局事务在操作同一行本地事务会重试直到拿到锁才提交。代价虽然避免了脏写但性能下降退化成类似 XA 的串行化。优缺点优点无侵入业务代码只需要加一个GlobalTransactional注解完全不用写 Try/Confirm 代码。缺点因为依赖数据库的本地事务和行锁并发度受限。如果两个事务同时操作同一行数据还是会锁住。总结怎么选强一致性需求极少选Seata AT或XA。但要做好性能下降的心理准备。高并发、核心业务推荐选TCC。虽然代码写得累但性能可控逻辑清晰。高并发、非核心/异步业务最常用选本地消息表/MQ 事务消息。这是互联网架构的基石用“时间换空间”实现最终一致性。方案一致性性能侵入性隔离性适用场景XA (2PC)强一致低阻塞低高传统单体拆微服务初期低频后台任务TCC最终一致高高需写三个方法高业务控制核心交易链路高并发对一致性要求高MQ 事务最终一致极高异步中需发消息低跨系统解耦非实时业务积分、通知Seata AT最终一致中依赖全局锁低注解中有脏写风险内部微服务开发效率优先并发适中最后送你一句话“分布式事务没有银弹。所有的方案都是在一致性、可用性和性能之间做交易。作为架构师你的任务不是寻找完美的方案而是找到最适合你业务场景的那个‘平衡点’。”