
做后端开发久了只要系统拆成了微服务分布式事务这个问题就早晚会摆在面前。你在电商系统里下了一笔订单订单服务写入一条订单记录库存服务扣减库存支付服务完成扣款。这三个服务通常各自独立部署、各自拥有独立的数据库。任何一个环节失败之前写入的订单记录和扣减的库存都得撤回来。这套跨服务、跨数据库的“要么全成功、要么全失败”问题就是典型的分布式事务场景。这篇文章不是讲概念而是按照实际集成的路径走一遍。先搞清楚问题本质再把 2PC、TCC、Saga、消息事务、Seata 这些主流方案捋顺最后落到 Spring Cloud 项目的具体配置和真实踩坑记录上。无论你现在是准备引入分布式事务还是在评估方案阶段这篇文章都能提供一套可以直接参考的判断依据。1. 分布式事务到底在解决什么问题1.1 从单体到微服务事务边界发生了什么变化在单体应用时代这个场景简单得多。订单表、库存表、账户表都在同一个数据库里一次业务操作就是在一个 Connection 上执行多条 SQL然后统一提交或回滚。数据库的 ACID 特性替我们挡住了一切并发异常、中途断电、程序崩溃导致的数据不完整问题。Transactional一把梭问题不大。拆成微服务之后情况变了。订单服务拥有订单库库存服务拥有库存库支付服务拥有账户库。本地事务的边界被拆散了——订单服务只能保证自己库里的订单记录没问题库存服务只能保证自己库里的库存扣减没问题但是没有任何一个本地事务能把两个库的操作同时包住。你可能会想那我把它们放在一个方法里顺序调用不就行了比如先插入订单再远程调用库存服务扣减库存最后调用支付服务扣款。代码顺序确实如此但“顺序调用”和“整体提交”是两回事。假如订单插入成功了库存服务调用也成功了但支付服务超时返回异常你以为整个方法回滚了实际上订单和库存的写入已经提交了。为什么因为 Spring 的Transactional只对当前事务管理器管理的数据库连接生效远程调用是独立的系统根本不在同一个事务上下文里。这就是分布式事务要解决的核心多个服务各自持有的数据怎么在出现失败的情况下还能保持一致。1.2 CAP 定理和 BASE 理论为什么是绕不开的地基讨论分布式事务之前必须先接受一个事实网络通信是不可靠的。CAP 定理告诉我们在分布式系统里一致性、可用性、分区容错性三选二而分区容错性是必须选的因为在分布式环境下网络分区是必然事件。剩下的就只能在一致性和可用性之间做取舍。如果选择强一致性方案付出的代价就是可用性下降。典型如 2PC/XA在两阶段提交过程中所有参与者都处于阻塞状态只要一个参与者超时整个事务就必须停顿等待或回滚系统的吞吐量和可用性都会受到影响。如果选择高可用方案那就得接受数据在一段时间内是不一致的最终靠某种机制收敛成一致状态。这就是 BASE 理论的核心思想基本可用、软状态、最终一致。落到实际业务里真正对强一致性有苛刻要求的场景很少。比如订单和库存就算扣减后短暂地多给用户看到某个商品还有货只要秒级或毫秒级内同步过来对用户来说是无感知的。真正绝对不许错的可能只是账户余额这类资金数据。这也是我后来在方案选型时最重要的判断依据先想清楚业务允许多长时间的不一致窗口再去选技术方案。顺序反了就容易为了一个不需要的强一致买一个大而不当的复杂度账。2. 分布式事务主流方案全景拆解2.1 强一致路线2PC / XA 的设计逻辑和局限两阶段提交2PC是最经典的分布式事务方案也是数据库 XA 规范的基础。它的流程可以拆成准备阶段Prepare和提交阶段Commit两步。准备阶段由事务协调器向所有参与者发送准备请求每个参与者执行本地事务的写操作但先不提交把结果成功/失败报告给协调者。如果所有参与者都回复 OK协调者就广播提交指令大家统一提交任何一个人回复失败或者超时协调者就广播回滚指令所有参与者回滚自己的本地事务。这套协议在逻辑上是完备的但它存在几个硬伤第一资源锁定时间长。参与者从准备阶段开始就要持有数据库锁直到协调者最终下发提交或回滚指令这段时间内其他事务无法操作这些数据高并发场景下吞吐量直线下降。第二协调者本身是单点如果协调者在准备阶段之后、提交阶段之前宕机所有参与者都处于未知状态只能阻塞等待。第三数据可见性差参与者准备阶段写入的数据对外不可见加长了业务链路的延迟。实际项目中我比较少见到直接裸用 XA 的场景因为数据库厂商对 XA 的实现差异大网络抖动时协调者重试逻辑难写事务链路一长就容易变成全局锁风暴。但对一些低频、数据量小、强一致要求极高的内部系统来说XA 仍然是一种正确的选择——正确不等于高性能选型要看场景本身。2.2 业务侵入路线TCC 带来高性能也带来高开发成本TCCTry-Confirm-Cancel本质上是用业务逻辑代替数据库锁来实现分布式事务的协调。它的执行阶段有三个Try 阶段做资源检查和预留比如库存服务在 Try 阶段锁定 N 件商品、账户服务在 Try 阶段冻结 M 元资金Confirm 阶段真正执行提交把预留的资源正式扣减Cancel 阶段释放预留资源把 Try 阶段锁定的资源回滚回去。这个方案最大的优势是不用长时间持锁接口调用之间没有全局阻塞性能远高于 2PC。缺点也极其明显每个参与者都得实现三个方法且要为每个方法配套幂等控制。还要处理两个容易出问题的边界——空回滚和悬挂。所谓空回滚是指 Try 阶段根本没有执行成功或者没有执行但 Cancel 阶段却触发了。比如网络超时导致 Try 请求丢失全局事务判定失败后直接调用 Cancel此时预留记录不存在Cancel 必须能安全地“空操作过去”。所谓悬挂是指 Cancel 先执行完了迟到的 Try 请求才到达资源被“悬空”预留永远没有后续的 Confirm 或 Cancel 来释放它。解决办法通常是在事务控制表里增加状态位一旦确认该笔分支事务已经进入二阶段就直接丢弃一阶段的执行请求。TCC 的高性能是有代价的。如果团队规模小、业务模型不稳我不建议一上来就用 TCC因为在三个服务和二阶段之间来回排查很容易被状态不一致问题拖垮。2.3 最终一致路线Saga、本地消息表与事务消息如果业务链路很长、对实时一致性要求不高最终一致方案往往是性价比最高的选择。Saga 的核心思路是把长事务拆成一系列有序的本地事务每个本地事务都有对应的补偿事务。假设一个下单流程包含创建订单、扣库存、发券三个正向操作对应的补偿操作就是取消订单、回补库存、作废券。正向执行某个步骤失败后Saga 会把前面已经成功的步骤按逆序逐个补偿回滚。它的实现方式分编排式和协同式两种。编排式通过一个中心化控制器统一编排各个步骤协同式则通过事件队列在各服务之间传递驱动。Saga 几乎没有资源锁性能很好但业务上要接受中间状态短暂不一致。消息驱动的最终一致也很常用。其中本地消息表的思路是在业务数据库中建一张消息表业务操作和写入消息放在同一个本地事务里由消息表保证业务数据和消息同时成功。后台有一个轮询任务扫描未发送的消息把消息投递到 MQ消费方收到消息后处理业务并且通过业务表状态位或者事务记录表保证幂等。这套方案逻辑简单不引入额外中间件缺点是把 MQ 投递的可靠性和消息表的管理逻辑耦合到了业务代码里。事务消息则更进一步典型代表是 RocketMQ 的半消息机制。生产者先发送一条 half message 到 MQMQ 不会立即把它投递给消费者发送成功后生产者在本地执行业务事务然后根据事务结果向 MQ 提交 commit 或 rollback。如果本地事务执行成功且 commitMQ 才把消息变为可消费状态如果执行失败回滚MQ 直接丢弃消息如果 MQ 长时间没有收到提交指令它还会反向回查生产者本地事务的状态。这套机制把“本地业务和消息的一致性”转移到中间件层面业务侵入相对小得多。2.4 方案对比速查选型前先看这张表方案一致性级别性能表现开发成本资源锁定适用场景2PC / XA强一致低中高低频、强一致、小数据量TCC最终一致高高低高并发、实时性较强的资金类操作Saga最终一致高中低长链路、中间状态可容忍的流程本地消息表最终一致中中低已有关系型数据库、想避免引入复杂中间件事务消息最终一致中高中低已有 RocketMQ、跨服务异步解耦Seata AT 模式最终一致中低中基于 Spring Cloud 生态、想快速集成这张表是我的经验判断不是绝对标准。选型时重点看业务能容忍多长时间的不一致以及团队有没有人力维护复杂的补偿逻辑。方案越复杂出问题的面越大。3. Seata 集成实战从原理到 Spring Cloud 落地3.1 为什么 Spring Cloud 项目里 Seata 是高频选择上面那些方案很多都需要自行实现大量补偿逻辑和幂等控制。如果不打算自己造轮子基于 Spring Cloud 生态的技术栈里Seata 是比较常见的落地框架。它同时支持 AT、TCC、SAGA 和 XA 四种模式和 Spring Cloud、Dubbo 等框架都有现成的集成方案依赖引入和配置相对简单社区活跃度高。很多开源的后端脚手架比如若依微服务版本之类的项目也默认集成了 Seata遇到过太多的人只是把官方 demo 跑通了但完全不清楚 AT 模式的工作原理。我就把 AT 模式拆开讲清楚。3.2 AT 模式的执行流程和核心机制AT 模式是 Seata 的特色模式核心思想是用拦截 SQL 加生成快照的方式实现近似于本地事务的分布式事务体验。它三个核心角色TC事务协调者独立的服务端、TM事务管理器通常就是业务入口服务、RM资源管理器也就是各个业务服务的数据源代理。使用的时候在事务发起方的方法上加一个GlobalTransactional注解TM 会向 TC 注册一个全局事务拿到全局唯一的 XID。XID 会随着分布式调用链路往下传递下游服务拿到 XID 后在执行本地 SQL 时向 TC 注册分支事务。关键在 RM 的数据源代理层。Seata 会通过 DataSourceProxy 包装业务数据源拦截下每一条 SQL在真正执行之前解析出原始数据镜像before image执行 SQL 之后再获取一遍最终数据镜像after image把这两份镜像和 SQL 信息一起写入业务库的 undo_log 表。之后才是真正提交本地事务。全局事务提交时RM 会把对应的 undo_log 删除表示该分支事务的任务完成。回滚时RM 会拿当前数据库里的数据和 undo_log 中的 after image 做一次对比校验如果一致就说明期间没有别的脏写用 before image 反向生成补偿 SQL把数据恢复成执行前的状态。如果不一致说明有脏写会自动记录下来并人工介入处理。这种设计的好处是业务代码几乎零侵入不用像 TCC 那样写一堆 Try、Confirm、Cancel 方法。代价是每次 SQL 都要生成镜像整体性能会有一定损耗并发量特别高的场景需要注意。3.3 标准集成步骤依赖、配置与代码注解以新版 Seata1.4.x 之后为例集成步骤一般分为四步这里按先后顺序说。第一步部署 Seata ServerTC。因为它是一个独立服务需要从 GitHub 下载发行包修改 application.yml 或 registry.conf 指向注册中心和配置中心。我通常使用 Nacos 做注册配置中心把 application.yml 里的 registry 和 config 两部分都配置成 nacos 模式。第二步业务服务引入 Maven 依赖。Spring Boot 2.x 和 Spring Cloud Alibaba 控台上一般这样写dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-seata/artifactId version对应你使用的 Spring Cloud Alibaba 版本/version /dependency第三步配置数据源代理。所有需要参与全局事务的业务数据源都必须被 DataSourceProxy 包装。可以自定义一个配置类Configuration public class DataSourceConfig { Bean Primary public DataSource dataSource(DataSource druidDataSource) { return new DataSourceProxy(druidDataSource); } }这一步很容易被忽略。如果漏掉数据源代理Seata 拦截不到 SQL全局事务注册了分支事务也不会生成 undo_log回滚必然出问题。第四步在每个参与事务的数据库里创建 undo_log 表CREATE TABLE IF NOT EXISTS undo_log ( id BIGINT(20) NOT NULL AUTO_INCREMENT, branch_id BIGINT(20) NOT NULL, xid VARCHAR(100) NOT NULL, context VARCHAR(128) NOT NULL, rollback_info LONGBLOB NOT NULL, log_status INT(11) NOT NULL, log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8;然后在订单服务的创建订单方法上加全局事务注解Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private StockFeignClient stockFeignClient; GlobalTransactional(rollbackFor Exception.class) public void createOrder(OrderDTO orderDTO) { orderMapper.insert(buildOrder(orderDTO)); OrderResult result stockFeignClient.deductStock(orderDTO.getSkuId(), orderDTO.getCount()); if (!result.isSuccess()) { throw new RuntimeException(扣减库存失败); } } }这段代码里GlobalTransactional就是 TM 注册全局事务的入口。stockFeignClient发起的远程调用的请求头里会自动带上 XID由 Spring Cloud Alibaba 的拦截器完成不需要手动传递。库存服务的扣减逻辑内部如果还有本地事务注解Transactional也会被 Seata 的分支事务拦截。整个过程对开发者的心智负担是比较小的。3.4 事务分组与服务端部署容易踩的细节刚接触 Seata 的人最容易被“事务分组”这个概念绕晕。事务分组tx-service-group是客户端连接的逻辑分组不是服务名。客户端配置一个如my_test_tx_group的变量这个变量会在服务端环境变量或配置中心中映射到一个 Seata Server 集群名称。调用链路是业务服务通过配置的分组名找到 SeaServer 集群从而完成 XID 的绑定和分支事务的注册。实际项目里每个环境集群都需要独立的分组名比如dev_tx_group、test_tx_group、prod_tx_group。不要图省事让所有环境共用同一个分组因为环境隔离一旦做烂测试环境的事务状态会把生产环境的全局事务状态搞混乱。服务端如果要做高可用一般把 TC 集群注册到 Nacos 上多个 TC 实例共用一个配置中心的存储数据库。TC 服务实例挂掉后Nacos 服务发现机制会剔除不可用节点业务客户端的调用链路会自动切换到可用节点。这里需要特别注意TC 集群的配置中心、注册中心、数据库都必须一致否则不同的 TC 实例各自为政全局事务记录分散存放在不同数据库跨节点查不到回滚逻辑就会失效。4. 集成过程中遇到的典型坑与排查实录4.1 全局锁导致的死锁和性能抖动AT 模式在真正写数据之前会尝试获取全局锁。全局锁的粒度取决于 SQL 涉及的记录底层是插入全局锁记录。高并发场景下这个锁竞争很激烈一个数据行被多个事务并发修改时其他事务都要等待。我遇到过线上一个库存扣减接口刚引入 Seata 时接口 p99 延迟直接翻了四五倍。排查方法比较笨把 dubaundo_log 的辅助表和 global_lock 相关表的大事务时间打出来观察全局锁等待时间发现大量时间花在select for update和全局锁记录的争抢上。缓解措施有两个。一是从业务层面减小事务锁粒度比如拆分维度把一张大库存表拆成多行记录让并发请求尽量分散到不同行。二是把高频短事务的隔离级别从默认版调整。如果业务上能够接受较小的时间窗口不一致可以把全局事务的timeout调低让超时事务尽快回滚减少锁持有时间。4.2 幂等控制没做全导致消息重复消费无论是 TCC、Saga 还是本地消息表所有异步补偿和重试机制都能触发重复执行。最常见的问题是一个服务收到重试消息后库存扣减执行了两次数据变成负数。第一道防线是数据库的唯一约束。比如扣减流水表里对(order_id, sku_id)建唯一索引第二次插入直接走唯一冲突业务代码捕获后直接当成成功处理。第二道防线是状态机校验。订单状态按流程只能从“待支付”流转到“已支付”重试消息到达时如果发现当前状态已经是“已支付”就说明已经被处理过直接返回成功。补偿逻辑和正向逻辑的幂等是分开的。很多人只做了正向幂等没做补偿幂等。问题在 Cancel 场景同样会出现Cancel 接口要允许空操作允许重复调用必须做到连续调用两次 Cancel 的结果和调用一次完全一致。4.3 事务超时导致回滚失败和悬挂问题Seata 全局事务的默认超时时间通常是 60 秒可在配置中修改超时后 TM 会直接发起回滚但此时可能有一批分支事务还在执行中。如果某个分支事务还没执行完或者已经执行完但 undo_log 还没写入回滚指令下发时会发现找不到对应的分支记录这部分数据就无法自动恢复。这类问题的典型表象是一个订单服务调用库存服务库存服务响应很慢超过全局事务超时阈值TM 通知 TC 把 XID 对应的全局事务标记为回滚。此时库存服务的本地 SQL 才刚执行完分支事务注册成功但全局事务已处于回滚状态。数据库里出现的结果是库存已经扣了但订单最终没有生成。针对这个问题比较务实的做法是把涉及外部 RPC 调用的耗时统计算出来合理配置全局超时时间同时核心链路尽量在本地事务内完成远程调用在事务入口之前或之后发起缩短全局事务打开的窗口期。另外数据库层面加上失败重投逻辑定期扫描异常状态的全局事务记录能秒级发现处理减少对用户影响时间的蔓延。4.4 幂等表和状态机在订单与库存场景的落地细节以最常见的订单与库存分布式事务为例我在项目里一般会在库存服务中做两层保障。第一层是库存扣减流水表结构上最少要有订单号、商品 ID、扣减数量、创建时间订单号加商品 ID 建唯一索引天然防重复扣减。第二层是库存预占状态机。订单创建请求到达库存服务后先把库存状态置为“锁定”订单支付成功后再把状态改为“扣减成功”订单取消则改为“释放锁定”。状态机字段一定要带版本号或更新时间使用乐观锁来更新状态位防止并发请求在前后状态之间互相覆盖。这里有一个容易忽略的细节状态是存储在单独的状态表里的而不是直接改库存表的剩余数量。因为库存表的剩余数量是一个强业务指标如果重试消息把它扣了两次数据错误很难回查状态表则可以通过多套快照追踪问题。4.5 服务间的数据通信网络问题会放大分布式事务的难度分布式事务的成败严重依赖服务间数据通信网络的稳定性。远程调用超时后发起方并不清楚对方到底是“执行了但响应慢”还是“根本没收到请求”。这两种情况处理方式完全相反前者要做幂等补偿后者要做重试。线上常见的现象是订单服务调用库存服务超时订单服务做了重试库存服务实际收到了两次请求第一次扣减成功但响应丢失第二次扣减就触犯了唯一索引或库存为负。这种问题的根因不在于重试逻辑而在于没有做好接口幂等性。我的经验是所有跨服务写操作接口的设计都要把“调用方可能重试 N 次”作为前提。另外像 OpenFeign 默认的读超时时间是 1 秒改成 3 到 5 秒更合理否则正常业务波动就会频繁触发超时超时一多分布式事务中的失败可能性就直接被放大了。结合整体微服务架构图来看从用户入口到订单服务再到库存服务再到消息队列每一跳的通信延迟都在为分布式事务增加不确定性的概率。5. 事务消息与异步化思路的补充玩法前面讲的 Seata AT 模式本质上还是把多个服务的事务关联在同一个全局事务里同步执行。有些场景其实不需要同步分布式事务比如积分发放、优惠券发放、流水通知这类允许延迟处理的任务。把这些操作从主流程中剥离出来改造成消息驱动系统的吞吐量和稳定性都能上一个台阶。事务消息的做法在 Spring Cloud 生态里并不复杂。业务服务在本地事务里更新业务数据同时发送一条 MQ 事务消息。消息成功发送后由消费者异步处理后续操作。这里的关键点在于“本地业务和消息的一致性”不能被拆开。如果先更新业务数据再发送消息消息发送失败就会导致下游漏处理如果先发送消息再更新业务数据下游可能在本地事务还没提交的时候就消费到了旧数据。RocketMQ 的事务消息机制刚好覆盖了这个问题。生产者发送 half message本地事务执行成功后再 commitMQ 才会把消息投递给消费者。如果本地事务失败rollback 消息会被丢弃。整个过程有一个回查机制兜底MQ 会定期检查长时间状态未决的事务反向请求生产者确认本地事务最终状态。事务消息引入后订单服务和库存服务之间的直接调用就变成了解耦的异步操作。订单服务负责把订单事务完成通过事务消息把“扣库存”事件投递给库存服务。库存服务消费后执行扣减成功则完成流程失败则记录重试或进入人工处理队列。这种模式的副作用是最终一致的延迟变长了一些但换来了系统的弹性和吞吐提升。6. 选型经验总结分布式事务不是银弹做分布式事务集成最容易被带偏的思路是“先选框架再套场景”。有人听说 Seata AT 模式开发成本低就把所有跨服务调用都套上全局事务运维后期发现全局锁抖动、回滚不完整、分支事务堆积追查成本极高。在微服务拆分的语境里分布式事务只是众多一致性手段之一。很多时候通过合理地调整模型可以直接消灭分布式事务的需求。比如把订单和库存这两个强绑定操作放在同一个服务内由这个服务独占两个数据源的管理权限跨服务流程只保留本地事务或者把扣库存从创建订单链路中削掉改成事后异步校验让下单主流程只对订单数据负责库存支付后再校验。把模型改好之后再配一条事务消息或一个幂等接口就足够解决问题可能是最高的杠杆比。如果确认用了分布式事务我的实际体会是优先把关键路径上的数据模型和接口边界做严格约束把超时配置、幂等表、状态机这些保障先定为默认要求然后才去谈引入哪一种方案。很多问题不是分布式事务框架不行而是周边基础防护没做足。整个微服务模块引入分布式事务之后数据安全性和团队开发效率在一定程度上就是对矛盾。正确的方式有两种保持对业务边界和事务边界的通透理解在每一个环节上都留好幂等和补偿的后手以及根据场景特征理性选择方案避免为了强一致而放弃系统的高可用和响应速度。架构不是一次定型的分布式事务也不是六边形战士。把每个操作的失败分支想清楚把每个接口的重复调用写稳妥把每个异常的超时时间配刁钻这些东西串起来才是分布式事务落地的底气。