
一、前言在单体项目中我们经常用Transactional来保证事务一致性。比如创建订单时同时写订单表、扣库存、写支付流水只要这些操作都在同一个数据库里一个本地事务基本就能解决问题。但是到了微服务架构事情就没这么简单了。一个下单流程可能会拆成多个服务订单服务创建订单库存服务扣减库存账户服务扣减余额优惠券服务核销优惠券积分服务增加积分这些服务可能有各自的数据库。订单服务的本地事务只能保证订单库的数据一致没办法直接保证库存库、账户库、优惠券库一起成功或一起失败。这就是分布式事务问题。本文就结合 Java 后端常见业务场景把分布式事务的几种主流方案讲清楚本地消息表可靠消息最终一致性TCCSagaSeata二、分布式事务到底解决什么问题先看一个下单场景。用户提交订单后系统需要做三件事1. 创建订单 2. 扣减库存 3. 扣减账户余额如果这三个操作都在一个数据库中可以直接使用本地事务TransactionalpublicvoidcreateOrder(){orderMapper.insert(order);stockMapper.deduct(productId);accountMapper.deduct(userId,amount);}只要中间任何一步失败事务回滚即可。但如果拆成微服务订单服务 - 订单库 库存服务 - 库存库 账户服务 - 账户库订单服务调用库存服务成功后如果调用账户服务失败会发生什么订单创建成功 库存扣减成功 账户扣减失败此时数据就不一致了。分布式事务要解决的就是这种跨服务、跨数据库、跨资源操作时的数据一致性问题。三、先明确不是所有场景都需要强一致很多人一听到分布式事务就马上想到“我要保证所有服务同时成功或同时失败”。但真实项目里大部分业务并不需要强一致而是可以接受最终一致。比如积分增加用户支付成功后积分晚几秒到账一般可以接受。比如短信通知订单创建成功后短信发送失败不应该影响订单创建。比如优惠券使用如果优惠券核销失败可以让订单创建失败 也可以先创建待确认订单再异步确认优惠券状态。所以做分布式事务设计前先问自己一个问题这个业务到底要求强一致还是最终一致就可以如果最终一致可以满足就不要轻易引入复杂的强一致方案。四、方案一本地消息表1. 什么是本地消息表本地消息表是实际项目中非常常见的一种最终一致性方案。核心思想是业务数据和消息记录放在同一个本地事务中提交。比如订单创建成功后需要通知库存服务扣库存。订单服务不直接依赖 MQ 是否发送成功而是在同一个事务中做两件事1. 写订单表 2. 写本地消息表代码示例TransactionalpublicvoidcreateOrder(CreateOrderCommandcommand){OrderordernewOrder();order.setUserId(command.getUserId());order.setProductId(command.getProductId());order.setStatus(OrderStatus.CREATED);orderMapper.insert(order);OutboxMessagemessagenewOutboxMessage();message.setBizId(order.getId());message.setTopic(order.created);message.setBody(JsonUtils.toJson(order));message.setStatus(MessageStatus.NEW);messageMapper.insert(message);}只要本地事务提交成功订单和消息记录就一定同时存在。然后后台任务扫描消息表Scheduled(fixedDelay3000)publicvoidsendMessages(){ListOutboxMessagemessagesmessageMapper.selectNewMessages();for(OutboxMessagemessage:messages){try{mqProducer.send(message.getTopic(),message.getBody());messageMapper.markSent(message.getId());}catch(Exceptione){messageMapper.increaseRetryCount(message.getId());}}}这样即使 MQ 暂时不可用也不会导致订单数据丢失。消息会留在本地表里后续继续重试。2. 本地消息表的优点优点很明显实现简单不强依赖分布式事务框架数据可查方便排查问题失败后可以重试适合大部分最终一致场景很多中小型系统其实用本地消息表就够了。3. 本地消息表的缺点它也不是没有缺点需要额外建消息表需要定时任务或消息投递任务消息可能重复发送消费端必须保证幂等消息表数据需要定期归档尤其要注意本地消息表只能保证消息不容易丢不能保证消费者只执行一次。所以消费端一定要做幂等。五、方案二可靠消息最终一致性可靠消息最终一致性一般会结合 MQ 使用。它的核心流程是1. 本地业务执行成功 2. 消息可靠发送到 MQ 3. 下游服务消费消息 4. 消费失败则重试 5. 多次失败进入死信队列或人工处理以订单支付成功为例支付服务确认支付成功 - 发送支付成功消息 - 订单服务修改订单状态 - 积分服务增加积分 - 优惠券服务更新使用记录消费者代码示例RabbitListener(queuespay.success.queue)publicvoidhandlePaySuccess(PaySuccessMessagemessage){StringmessageIdmessage.getMessageId();if(consumeLogMapper.exists(messageId)){return;}orderService.markPaid(message.getOrderId());consumeLogMapper.insert(messageId);}这里有个重点消费前先判断消息是否处理过。因为 MQ 常见语义是至少投递一次也就是说消息可能重复。所以消费端必须保证同一条消息消费多次结果仍然正确。这就是幂等。六、方案三TCC1. 什么是 TCCTCC 是 Try、Confirm、Cancel 的缩写。它把一个业务操作拆成三个阶段Try尝试执行业务预留资源 Confirm确认执行业务真正提交 Cancel取消执行业务释放资源举个扣余额的例子。Try 阶段不是直接扣钱而是冻结余额publicvoidtryFreeze(LonguserId,BigDecimalamount){accountMapper.freeze(userId,amount);}Confirm 阶段真正扣减冻结金额publicvoidconfirmDeduct(LonguserId,BigDecimalamount){accountMapper.deductFrozenAmount(userId,amount);}Cancel 阶段释放冻结金额publicvoidcancelFreeze(LonguserId,BigDecimalamount){accountMapper.unfreeze(userId,amount);}2. TCC 适合什么场景TCC 适合对一致性要求比较高并且业务资源可以预留的场景。比如账户余额冻结库存预占优惠券锁定名额占用这些业务都有一个共同点可以先冻结或预占再确认或释放。3. TCC 的缺点TCC 最大的问题是业务侵入强。每个业务接口都要写三套逻辑Try Confirm Cancel而且还要处理空回滚幂等悬挂重试比如 Cancel 接口可能在 Try 还没执行成功时就被调用这就是空回滚问题。所以 TCC 虽然一致性强但开发和维护成本也高。七、方案四SagaSaga 更适合长流程事务。它的思想是把一个大事务拆成多个本地事务。 每个本地事务都有一个对应的补偿动作。比如订单履约流程1. 创建订单 2. 扣减库存 3. 安排出库 4. 生成物流单 5. 通知用户如果第 4 步失败可以执行补偿取消物流单 取消出库任务 释放库存 关闭订单Saga 不追求数据库层面的回滚而是通过业务补偿让系统最终回到可接受状态。它适合订单履约审批流程跨系统结算长时间业务流程Saga 的难点在于补偿设计。因为很多业务不是简单反向操作。比如用户已经收到短信通知再“回滚短信”就不现实只能发一条新的通知解释状态变化。八、方案五SeataSeata 是常见的分布式事务框架。它有几种模式AT 模式TCC 模式Saga 模式XA 模式Java 项目里最常见的是 AT 模式。1. Seata AT 模式大致原理AT 模式对业务代码侵入较小。它会在本地事务执行前后记录 undo log用于全局回滚。大致流程1. 开启全局事务 2. 各服务执行本地事务 3. Seata 记录 undo log 4. 所有分支成功全局提交 5. 任一分支失败根据 undo log 回滚示例GlobalTransactionalpublicvoidcreateOrder(CreateOrderCommandcommand){orderService.create(command);stockService.deduct(command.getProductId());accountService.deduct(command.getUserId(),command.getAmount());}代码看起来很简单但背后有全局事务协调器、分支事务、undo log、全局锁等机制。2. Seata 的优点对业务代码侵入小使用方式接近本地事务适合快速接入分布式事务社区资料多3. Seata 的问题Seata 不是银弹。它也有一些成本引入事务协调器增加 undo log 表高并发下可能有全局锁竞争对 SQL 类型有要求故障排查复杂度更高如果你的业务本来最终一致就能接受强行上 Seata 反而可能增加系统复杂度。九、几种方案怎么选可以简单按下面思路选。1. 最终一致即可优先考虑本地消息表 MQ 消费幂等适合积分发放消息通知数据同步支付成功后异步更新下游状态2. 需要资源预留可以考虑TCC适合冻结余额预占库存锁定优惠券3. 长业务流程可以考虑Saga适合订单履约审批流程跨系统业务编排4. 想降低接入成本可以考虑Seata AT适合关系型数据库SQL 不太复杂并发压力不是特别夸张团队能接受引入事务协调器十、实际项目中的建议我个人更推荐的落地顺序是先业务规避 再最终一致 再 TCC / Saga 最后才考虑强一致框架不要一上来就追求“绝对一致”。真实系统更重要的是状态可追踪操作可重试接口幂等失败可补偿数据可对账异常可人工处理比如订单系统可以设计成状态机待支付 - 已支付 - 待发货 - 已发货 - 已完成每次状态流转都带上当前状态条件updateorderssetstatusPAIDwhereid#{orderId}andstatusWAIT_PAY;这样即使消息重复、接口重复调用也不会把状态改乱。十一、常见坑1. 没有幂等分布式系统里重试是常态。只要有重试就可能重复执行。所以接口必须考虑幂等。2. 没有补偿失败不可怕可怕的是失败后没有修复机制。比如扣库存失败后要么重试要么关闭订单要么进入人工处理。3. 没有对账最终一致不代表永远不管。核心业务必须有对账任务比如支付成功但订单未支付 订单已支付但库存未扣 库存已扣但订单不存在这些异常数据要能被定期发现。4. 过度依赖框架框架只能帮你处理一部分问题。业务状态、幂等、补偿、对账仍然要自己设计。十二、总结分布式事务本质上是跨服务、跨数据库、跨资源的一致性问题。常见方案可以这样理解本地消息表简单可靠适合最终一致可靠消息适合异步解耦但消费端必须幂等TCC一致性强但业务侵入大Saga适合长流程但补偿设计复杂Seata降低接入成本但不是万能方案实际项目中不要为了技术而技术。先判断业务到底需要强一致还是最终一致再选择合适方案。很多时候一个设计良好的状态机加上本地消息表、MQ、幂等、重试、补偿和对账就已经能支撑大部分业务场景。分布式事务没有银弹真正可靠的系统靠的是清晰的业务建模和完整的异常处理闭环。