分布式事务解决方案:我踩过的那些坑和走过的路
分布式系统越来越流行,随着业务增长和架构拆分的深入,我们项目中不可避免地遇到了一个经典问题——分布式事务。这个问题让我在项目上线前焦虑不已,也逼着我从理论到实战重新梳理了一遍自己对“一致性”的理解。
这篇文章不是教科书式的介绍 TCC、Saga、XA、Seata 这些概念的文章,而是我在真实项目中遇到的问题、思考过程、技术选型的纠结与取舍,以及最后落地的方案和经验总结。如果你正在面临或者即将面临分布式事务的问题,相信这篇来自一线实战的文章能给你一些启发。
一、背景介绍:为什么我们需要处理分布式事务?

2022年的时候,我参与公司的一个核心项目——供应链平台重构,目标是把原来高度耦合的大单体服务模块化,按照业务领域拆分为用户中心、订单中心、库存中心、支付中心等微服务模块。
刚开始一切顺利,直到进入联调阶段,一个问题逐渐暴露出来:
当用户下单后,需要同时扣减库存、生成订单、发起预支付请求,这些操作必须保证全部成功或全部失败,否则就会出现库存扣了但订单没生成,或者订单生成了但用户没付款,导致资金损失。
这显然就是一个典型的分布式事务场景。各个服务各自维护自己的数据库(MySQL),而一次完整的下单操作涉及多个服务的数据修改。一旦其中一个服务出错,整个流程都必须回退到初始状态。
于是,我和团队开始认真研究各种分布式事务方案,并尝试在实际项目中落地。
二、第一次尝试:直接用本地事务 + 消息队列补偿?

最开始,我们的想法很朴素:“既然不能强一致,那就靠补偿机制来兜底吧!”于是我们设计了一套基于消息队列的最终一致性方案:
- 下单服务先开启本地事务,插入订单数据
- 向库存服务发送 MQ 消息,要求减少库存
- 同时向支付服务发送 MQ 消息,要求冻结金额
- 如果其中任意一步失败,就记录日志,然后人工介入
看起来没问题吧?可现实啪啪打脸。
1. 消费失败的重试陷阱
有一次生产环境库存服务重启之后,MQ 消费端因为连接超时频繁报错,消费失败的消息不断重试,但由于重试次数过多,消息直接进了死信队列,订单状态卡在“已提交未扣库存”,用户打电话过来投诉……
这种“补偿+重试”方案看似灵活,但其实对系统的健壮性和异常恢复能力要求极高。你需要考虑:
- 消息是否幂等?
- 消费失败如何重试?
- 是否需要记录状态以支持手动干预?
- 如何监控和报警?
这些问题我们当时都没做好,导致几次故障都是靠人肉半夜修复。
三、第二次尝试:引入 Seata 做全局事务管理
既然消息队列兜不住,那我们就得上点硬货了 —— Seata。
Seata 是阿里开源的一站式分布式事务框架,支持 AT、TCC、SAGA 等多种模式。我们选择了它的默认模式 —— AT 模式(Auto Transaction Mode),因为它几乎不需要改动原有业务代码,只需要加注解即可完成分布式事务控制。
1. 项目结构简要说明
我们当时的系统是 Spring Boot + Dubbo + Nacos 架构,每个服务都有独立的 MySQL 数据库,整体结构如下:
[Order Service]
↓
[Inventory Service] ←→ [MySQL - inventory_db]
↓
[Payment Service] ←→ [MySQL - payment_db]
所有服务注册到 Nacos 中,通过 Dubbo 实现远程调用。
2. 使用 Seata 的大致步骤
- 安装部署 Seata Server(TC),配置注册中心为 Nacos
- 在每个服务中集成 Seata 客户端,配置好数据源代理
- 在下单入口方法上添加
@GlobalTransactional注解 - 正常调用库存、支付服务接口(通过 Dubbo)
- 所有事务由 Seata 来协调提交或回滚
3. 初步效果还不错!
刚开始几轮测试下来,确实达到了预期效果:只要某个服务返回异常,Seata 会自动进行全局回滚,所有服务的状态都会恢复原样。
但好景不长……
四、Seata 的“隐藏坑”:性能瓶颈和不确定性
1. 高并发下的性能下降明显
某次压测中,我们在单节点服务环境下模拟 2000TPS 的下单请求。结果发现 Seata 的全局锁竞争非常严重,特别是在高并发下容易出现“写冲突导致 XID 冲突”。
日志中能看到大量类似错误:
io.seata.core.exception.BranchTransactionException: Branch register failed ...
后来查文档才知道:Seata 的 AT 模式依赖于“全局锁”,也就是在更新数据库时会先加全局锁,防止脏写。但在高并发写入场景下,这个机制反而成了性能瓶颈。
2. 异常情况下回滚不彻底
更夸张的是,有一次线上发生了一个诡异现象:一个订单明明已经调用了“支付确认”接口,但 Seata 却没有回滚它!
原因是某个服务挂掉了,事务参与者(RM)没能及时上报分支事务状态,导致 TC(事务协调者)误认为该分支已经提交成功。
虽然可以通过补偿任务来修复,但这意味着我们需要再加一层定时检查机制,复杂度陡增。
五、最终方案:TCC 模式 + 本地事务 + 最终一致性兜底
经过多轮评估和团队讨论,我们决定采用 Seata 的 TCC 模式(Try - Confirm - Cancel),配合本地事务实现最终一致性。
1. TCC 模式简介
TCC 是一种编程模型,要求开发者为每个业务操作定义三个方法:
- Try:资源预留/预检查(如锁定库存)
- Confirm:真正执行操作(如正式扣库存)
- Cancel:取消操作(如释放库存)
这三个方法都需要幂等,且 Confirm 和 Cancel 要么都执行成功,要么都跳过。
2. 改造后的下单逻辑如下:
- 订单服务启动事务,在 Try 阶段调用:
- 库存服务 Try 方法:冻结指定商品数量
- 支付服务 Try 方法:冻结用户账户余额
- 如果都成功,进入 Confirm 阶段:
- 扣除库存
- 扣除预冻结金额
- 如果任何一步失败,触发 Cancel 阶段:
- 释放库存冻结
- 释放用户账户冻结金额
这种方式虽然增加了开发量,但灵活性更好、可控性更高。
六、关键代码片段示例
以下是我们在支付服务中使用 TCC 模式的部分代码示例:
@TwoPhaseBusinessAction(name = "deductBalance")
public boolean freeze(@BusinessActionContextParameter(paramName = "userId") Long userId,
@BusinessActionContextParameter(paramName = "amount") BigDecimal amount) {
// Try阶段:冻结金额
return accountService.freeze(userId, amount);
}
@Commit
public boolean commit(BusinessActionContext ctx) {
// Confirm阶段:正式扣除
Long userId = (Long) ctx.getActionContext("userId");
BigDecimal amount = (BigDecimal) ctx.getActionContext("amount");
return accountService.deduct(userId, amount);
}
@Rollback
public boolean rollback(BusinessActionContext ctx) {
// Cancel阶段:释放冻结
Long userId = (Long) ctx.getActionContext("userId");
BigDecimal amount = (BigDecimal) ctx.getActionContext("amount");
return accountService.releaseFreeze(userId, amount);
}
在订单服务中调用方式如下:
@GlobalTransactional
public void placeOrder(OrderDTO orderDTO) {
try {
inventoryApi.reserve(orderDTO.getProductId(), orderDTO.getCount());
paymentApi.freezeBalance(orderDTO.getUserId(), orderDTO.getAmount());
orderService.createOrder(orderDTO); // 插入订单
} catch (Exception e) {
throw new RuntimeException("下单失败", e);
}
}
七、踩过的坑和解决办法汇总
✅ 1. 幂等性问题(非常重要)
TCC 中 Confirm 和 Cancel 必须是幂等的!否则在重试过程中会出现“重复扣除”这样的严重错误。
解决方法:
- 使用唯一业务 ID(比如订单 ID + 操作类型)做幂等校验
- 数据库字段加版本号 or 时间戳
- 或者使用 Redis 缓存记录是否执行过对应操作
✅ 2. 事务传播问题(Dubbo + Seata 的兼容)
Seata 默认是基于 ThreadLocal 存储事务上下文,而 Dubbo 在异步线程池中执行时,会导致 XID 丢失。
解决方法:
- 将 Dubbo 的线程池改为继承当前线程的 ThreadLocal,或者
- 使用 Seata 提供的 RpcContext 透传 XID(需配置)
✅ 3. 状态一致性难保障
TCC 只是保证事务完整性,并不能完全避免状态不一致。
解决方法:
- 加定时任务扫描未完结的订单(超过一定时间未确认的主动 Cancel)
- 加监控告警:订单、库存、支付状态不一致则报警
八、上线后的表现如何?
我们将这套 TCC + Seata 的方案在生产环境中稳定运行了一年半,经历过大促、系统升级、服务扩容等多个场景。
以下是几个关键指标:
| 指标 | 改进前 | 改进后 |
|---|---|---|
| 平均事务完成时间 | 600ms | 180ms |
| 事务失败率 | 0.7% | < 0.1% |
| 日均故障数 | 3~5起 | 0~1起 |
| 故障响应时间 | 数小时 | 十分钟内 |
而且最重要的是,我们再也没有收到过用户关于“钱扣了单没下来”这类问题的投诉。这是最大的安慰。
九、我的几点建议 & 总结
如果你也在面临分布式事务的挑战,以下是我亲测有效的几点建议:
别迷信“无侵入”方案,有时候越简单的越不可控
Seata 的 AT 模式确实方便,但如果业务复杂、并发高,还是推荐 TCC 模式。业务逻辑越复杂,TCC 的优势越明显
它允许你针对不同业务自定义 Try/Confirm/Cancel,更加贴近实际业务流。一定要做好日志追踪、事务状态监控
推荐结合 SkyWalking 或 Pinpoint 监控整个事务链路,出问题能快速定位。最终一致性兜底方案必不可少
不管怎么设计,总会有极端情况漏掉。加个定时补偿任务,哪怕每天跑一次,也能极大提升容灾能力。不要怕写重复代码,分布式系统就得“容忍冗余”
有时候为了性能、稳定性,牺牲一点开发效率也是值得的。
写在最后:分布式事务不是终点,而是起点
说实话,分布式事务只是我们拆微服务旅程中的一个小小门槛。真正的难点在于如何做好服务治理、如何权衡一致性与可用性、如何设计健壮的服务间通信机制。
但正是这一系列挑战,推动着我们去深入理解系统设计的本质。每解决一个问题,就是对工程能力和架构思维的一次锤炼。
希望这篇文章对你有所帮助,少走一些弯路。如果还有疑问,欢迎留言交流 👇
📌 作者:一名曾在电商、金融、IoT 多领域实战过的 Java 工程师,喜欢从真实项目出发分析问题本质。
💬 GitHub / 微信公众号搜 “TechGrower”,不定期分享干货技术文章。

评论 0