分布式事务解决方案:我踩过的那些坑和走过的路

码上见山
2025-06-12 04:47
阅读 576

分布式系统越来越流行,随着业务增长和架构拆分的深入,我们项目中不可避免地遇到了一个经典问题——分布式事务。这个问题让我在项目上线前焦虑不已,也逼着我从理论到实战重新梳理了一遍自己对“一致性”的理解。

这篇文章不是教科书式的介绍 TCC、Saga、XA、Seata 这些概念的文章,而是我在真实项目中遇到的问题、思考过程、技术选型的纠结与取舍,以及最后落地的方案和经验总结。如果你正在面临或者即将面临分布式事务的问题,相信这篇来自一线实战的文章能给你一些启发。

一、背景介绍:为什么我们需要处理分布式事务?

一、背景介绍:为什么我们需要处理分布式事务?

2022年的时候,我参与公司的一个核心项目——供应链平台重构,目标是把原来高度耦合的大单体服务模块化,按照业务领域拆分为用户中心、订单中心、库存中心、支付中心等微服务模块。

刚开始一切顺利,直到进入联调阶段,一个问题逐渐暴露出来:

当用户下单后,需要同时扣减库存、生成订单、发起预支付请求,这些操作必须保证全部成功或全部失败,否则就会出现库存扣了但订单没生成,或者订单生成了但用户没付款,导致资金损失。

这显然就是一个典型的分布式事务场景。各个服务各自维护自己的数据库(MySQL),而一次完整的下单操作涉及多个服务的数据修改。一旦其中一个服务出错,整个流程都必须回退到初始状态。

于是,我和团队开始认真研究各种分布式事务方案,并尝试在实际项目中落地。


二、第一次尝试:直接用本地事务 + 消息队列补偿?

二、第一次尝试:直接用本地事务 + 消息队列补偿?

最开始,我们的想法很朴素:“既然不能强一致,那就靠补偿机制来兜底吧!”于是我们设计了一套基于消息队列的最终一致性方案:

  1. 下单服务先开启本地事务,插入订单数据
  2. 向库存服务发送 MQ 消息,要求减少库存
  3. 同时向支付服务发送 MQ 消息,要求冻结金额
  4. 如果其中任意一步失败,就记录日志,然后人工介入

看起来没问题吧?可现实啪啪打脸。

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 的大致步骤

  1. 安装部署 Seata Server(TC),配置注册中心为 Nacos
  2. 在每个服务中集成 Seata 客户端,配置好数据源代理
  3. 在下单入口方法上添加 @GlobalTransactional 注解
  4. 正常调用库存、支付服务接口(通过 Dubbo)
  5. 所有事务由 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. 改造后的下单逻辑如下:

  1. 订单服务启动事务,在 Try 阶段调用:
    • 库存服务 Try 方法:冻结指定商品数量
    • 支付服务 Try 方法:冻结用户账户余额
  2. 如果都成功,进入 Confirm 阶段:
    • 扣除库存
    • 扣除预冻结金额
  3. 如果任何一步失败,触发 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起
故障响应时间 数小时 十分钟内

而且最重要的是,我们再也没有收到过用户关于“钱扣了单没下来”这类问题的投诉。这是最大的安慰。


九、我的几点建议 & 总结

如果你也在面临分布式事务的挑战,以下是我亲测有效的几点建议:

  1. 别迷信“无侵入”方案,有时候越简单的越不可控
    Seata 的 AT 模式确实方便,但如果业务复杂、并发高,还是推荐 TCC 模式。

  2. 业务逻辑越复杂,TCC 的优势越明显
    它允许你针对不同业务自定义 Try/Confirm/Cancel,更加贴近实际业务流。

  3. 一定要做好日志追踪、事务状态监控
    推荐结合 SkyWalking 或 Pinpoint 监控整个事务链路,出问题能快速定位。

  4. 最终一致性兜底方案必不可少
    不管怎么设计,总会有极端情况漏掉。加个定时补偿任务,哪怕每天跑一次,也能极大提升容灾能力。

  5. 不要怕写重复代码,分布式系统就得“容忍冗余”
    有时候为了性能、稳定性,牺牲一点开发效率也是值得的。


写在最后:分布式事务不是终点,而是起点

说实话,分布式事务只是我们拆微服务旅程中的一个小小门槛。真正的难点在于如何做好服务治理、如何权衡一致性与可用性、如何设计健壮的服务间通信机制。

但正是这一系列挑战,推动着我们去深入理解系统设计的本质。每解决一个问题,就是对工程能力和架构思维的一次锤炼。

希望这篇文章对你有所帮助,少走一些弯路。如果还有疑问,欢迎留言交流 👇


📌 作者:一名曾在电商、金融、IoT 多领域实战过的 Java 工程师,喜欢从真实项目出发分析问题本质。
💬 GitHub / 微信公众号搜 “TechGrower”,不定期分享干货技术文章。

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝