一、前言
在单体项目中,我们经常用@Transactional来保证事务一致性。
比如创建订单时,同时写订单表、扣库存、写支付流水,只要这些操作都在同一个数据库里,一个本地事务基本就能解决问题。
但是到了微服务架构,事情就没这么简单了。
一个下单流程可能会拆成多个服务:
- 订单服务:创建订单
- 库存服务:扣减库存
- 账户服务:扣减余额
- 优惠券服务:核销优惠券
- 积分服务:增加积分
这些服务可能有各自的数据库。订单服务的本地事务,只能保证订单库的数据一致,没办法直接保证库存库、账户库、优惠券库一起成功或一起失败。
这就是分布式事务问题。
本文就结合 Java 后端常见业务场景,把分布式事务的几种主流方案讲清楚:
- 本地消息表
- 可靠消息最终一致性
- TCC
- Saga
- Seata
二、分布式事务到底解决什么问题?
先看一个下单场景。
用户提交订单后,系统需要做三件事:
1. 创建订单 2. 扣减库存 3. 扣减账户余额如果这三个操作都在一个数据库中,可以直接使用本地事务:
@TransactionalpublicvoidcreateOrder(){orderMapper.insert(order);stockMapper.deduct(productId);accountMapper.deduct(userId,amount);}只要中间任何一步失败,事务回滚即可。
但如果拆成微服务:
订单服务 -> 订单库 库存服务 -> 库存库 账户服务 -> 账户库订单服务调用库存服务成功后,如果调用账户服务失败,会发生什么?
订单创建成功 库存扣减成功 账户扣减失败此时数据就不一致了。
分布式事务要解决的,就是这种跨服务、跨数据库、跨资源操作时的数据一致性问题。
三、先明确:不是所有场景都需要强一致
很多人一听到分布式事务,就马上想到“我要保证所有服务同时成功或同时失败”。
但真实项目里,大部分业务并不需要强一致,而是可以接受最终一致。
比如积分增加:
用户支付成功后,积分晚几秒到账,一般可以接受。比如短信通知:
订单创建成功后,短信发送失败,不应该影响订单创建。比如优惠券使用:
如果优惠券核销失败,可以让订单创建失败; 也可以先创建待确认订单,再异步确认优惠券状态。所以做分布式事务设计前,先问自己一个问题:
这个业务到底要求强一致,还是最终一致就可以?如果最终一致可以满足,就不要轻易引入复杂的强一致方案。
四、方案一:本地消息表
1. 什么是本地消息表?
本地消息表是实际项目中非常常见的一种最终一致性方案。
核心思想是:
业务数据和消息记录放在同一个本地事务中提交。比如订单创建成功后,需要通知库存服务扣库存。
订单服务不直接依赖 MQ 是否发送成功,而是在同一个事务中做两件事:
1. 写订单表 2. 写本地消息表代码示例:
@TransactionalpublicvoidcreateOrder(CreateOrderCommandcommand){Orderorder=newOrder();order.setUserId(command.getUserId());order.setProductId(command.getProductId());order.setStatus(OrderStatus.CREATED);orderMapper.insert(order);OutboxMessagemessage=newOutboxMessage();message.setBizId(order.getId());message.setTopic("order.created");message.setBody(JsonUtils.toJson(order));message.setStatus(MessageStatus.NEW);messageMapper.insert(message);}只要本地事务提交成功,订单和消息记录就一定同时存在。
然后后台任务扫描消息表:
@Scheduled(fixedDelay=3000)publicvoidsendMessages(){List<OutboxMessage>messages=messageMapper.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(queues="pay.success.queue")publicvoidhandlePaySuccess(PaySuccessMessagemessage){StringmessageId=message.getMessageId();if(consumeLogMapper.exists(messageId)){return;}orderService.markPaid(message.getOrderId());consumeLogMapper.insert(messageId);}这里有个重点:消费前先判断消息是否处理过。
因为 MQ 常见语义是:
至少投递一次也就是说,消息可能重复。
所以消费端必须保证:
同一条消息消费多次,结果仍然正确。这就是幂等。
六、方案三:TCC
1. 什么是 TCC?
TCC 是 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 虽然一致性强,但开发和维护成本也高。
七、方案四:Saga
Saga 更适合长流程事务。
它的思想是:
把一个大事务拆成多个本地事务。 每个本地事务都有一个对应的补偿动作。比如订单履约流程:
1. 创建订单 2. 扣减库存 3. 安排出库 4. 生成物流单 5. 通知用户如果第 4 步失败,可以执行补偿:
取消物流单 取消出库任务 释放库存 关闭订单Saga 不追求数据库层面的回滚,而是通过业务补偿让系统最终回到可接受状态。
它适合:
- 订单履约
- 审批流程
- 跨系统结算
- 长时间业务流程
Saga 的难点在于补偿设计。
因为很多业务不是简单反向操作。
比如用户已经收到短信通知,再“回滚短信”就不现实,只能发一条新的通知解释状态变化。
八、方案五:Seata
Seata 是常见的分布式事务框架。
它有几种模式:
- 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 最后才考虑强一致框架不要一上来就追求“绝对一致”。
真实系统更重要的是:
- 状态可追踪
- 操作可重试
- 接口幂等
- 失败可补偿
- 数据可对账
- 异常可人工处理
比如订单系统可以设计成状态机:
待支付 -> 已支付 -> 待发货 -> 已发货 -> 已完成每次状态流转都带上当前状态条件:
updateorderssetstatus='PAID'whereid=#{orderId}andstatus='WAIT_PAY';这样即使消息重复、接口重复调用,也不会把状态改乱。
十一、常见坑
1. 没有幂等
分布式系统里,重试是常态。
只要有重试,就可能重复执行。
所以接口必须考虑幂等。
2. 没有补偿
失败不可怕,可怕的是失败后没有修复机制。
比如扣库存失败后,要么重试,要么关闭订单,要么进入人工处理。
3. 没有对账
最终一致不代表永远不管。
核心业务必须有对账任务,比如:
支付成功但订单未支付 订单已支付但库存未扣 库存已扣但订单不存在这些异常数据要能被定期发现。
4. 过度依赖框架
框架只能帮你处理一部分问题。
业务状态、幂等、补偿、对账,仍然要自己设计。
十二、总结
分布式事务本质上是跨服务、跨数据库、跨资源的一致性问题。
常见方案可以这样理解:
- 本地消息表:简单可靠,适合最终一致
- 可靠消息:适合异步解耦,但消费端必须幂等
- TCC:一致性强,但业务侵入大
- Saga:适合长流程,但补偿设计复杂
- Seata:降低接入成本,但不是万能方案
实际项目中,不要为了技术而技术。
先判断业务到底需要强一致还是最终一致,再选择合适方案。
很多时候,一个设计良好的状态机,加上本地消息表、MQ、幂等、重试、补偿和对账,就已经能支撑大部分业务场景。
分布式事务没有银弹,真正可靠的系统,靠的是清晰的业务建模和完整的异常处理闭环。