分布式事务解决方案:从一次生产事故中学到的最佳实践

长安码客
2025-06-13 16:15
阅读 342

引言:分布式系统避不开的“硬骨头”

引言:分布式系统避不开的“硬骨头”

我第一次真正意义上面对分布式事务问题,是在我加入一个金融支付项目的时候。这个项目负责的是用户充值、积分兑换、账务结算等关键流程,涉及多个服务模块协同操作数据——比如充值中心、账户系统、积分中心、风控系统。

刚开始接手需求时,我以为就是一个标准的业务逻辑处理:用户下单 → 调扣库存 → 扣减余额 → 更新订单状态。结果部署上线之后没几天就出事了,系统在高峰期出现了“余额被扣但订单未生成”、“积分已扣除但交易失败”等一系列奇怪的问题,甚至引发了一些客户投诉。那时候我们排查了好几天才意识到问题的根本是——没有统一的数据一致性保障机制

于是,我和团队不得不重新审视整个系统的事务边界和跨服务调用方式。这场“战役”让我们深刻认识到,分布式环境下要保证 ACID 的强一致性,远比单体应用复杂得多。今天这篇分享,就是基于那次实战经历的经验总结。


项目背景:一次典型的微服务化改造案例

缓存策略对比-1

项目背景:一次典型的微服务化改造案例

项目是一个中型电商+积分运营平台,采用 Spring Cloud 搭建微服务架构,各主要服务如下:

  • order-service:订单管理
  • user-account-service:用户资金账户
  • point-service:积分管理系统
  • inventory-service:商品库存服务
  • payment-gateway:支付网关

所有服务之间通过 OpenFeign + Ribbon 实现通信,并采用 Nacos 做服务发现,数据库使用 MySQL(5.7)主从部署 + MyBatis Plus 实现 ORM 层。

当时为了提升开发效率,每个服务都独立维护自己的数据,没有考虑跨服务事务处理问题。直到一次促销活动中出现订单创建失败导致账户余额被扣的严重不一致问题,我们才发现这个问题不能再拖了。


遇到的挑战:当业务变得复杂后,一致性成了烫手山芋

遇到的挑战:当业务变得复杂后,一致性成了烫手山芋

问题一:本地事务控制失效

订单创建流程大概如下:

  1. 创建订单记录;
  2. 调用 user-account-service 扣除用户账户余额;
  3. 调用 point-service 扣除奖励积分;
  4. 减少对应商品库存。

在之前的代码里,我们只对本地数据库操作做了事务控制,例如:

@Transactional(rollbackFor = Exception.class)
public Order createOrder(...) {
    // 插入订单
    orderMapper.insert(order);

    // 远程调用扣钱
    userAccountClient.deductBalance(userId, amount);

    // 远程调用扣积分
    pointClient.deductPoint(userId, points);

    // 扣库存
    inventoryClient.reduceStock(productId, quantity);
}

这段代码看似合理,但实际上,一旦某一步远程调用失败或超时,前面已经完成的操作就无法回滚了。比如第 2 步成功扣款,第 3 步失败,用户的余额就被扣了,而积分却没有扣除,这显然不合理。

问题二:补偿机制缺失

我们最初尝试手动做补偿机制:当某个步骤失败时,通过日志或者定时任务来回退之前的操作。但这样带来的问题是:

  • 补偿代码重复且复杂;
  • 容易漏掉某些异常情况;
  • 出现并发调用、网络抖动时,状态难以追踪;
  • 日常运维成本高,定位问题困难。

问题三:幂等性设计不到位

由于接口没有幂等性设计,一些异步回调或重试机制会导致同一笔交易被多次执行,进而引发数据错乱。


解决方案:引入 Seata 做分布式事务协调

经过调研,我们最终选择了 Seata 来解决分布式事务问题,主要是因为它支持 TCC、AT、SAGA 等多种模式,能与现有的 Spring Cloud 项目无缝集成。

我们的目标很明确:

  • 确保跨服务操作要么全部成功,要么全部回滚;
  • 不影响现有接口结构的前提下进行改造;
  • 尽量降低对性能的影响。

我们采用的是 AT 模式(自动两阶段提交),因为它对业务代码侵入性最小,适合快速上手。

技术选型对比表:

模式 是否需要改造代码 幂等性要求 性能损耗 适用场景
AT 较小 数据一致性要求较高的业务场景
TCC 高性能要求 + 精准回滚控制
SAGA 无长时间阻塞的长流程场景

因为我们希望尽快落地稳定版本,所以最终选择了 AT 模式


具体实现思路:如何让分布式事务像本地事务一样用

1. 架构改造

整体架构变化不大,在原有基础上增加了:

  • Seata Server 作为事务协调器;
  • 在各个微服务中引入 Seata Client 依赖并配置;
  • 修改数据源为 Seata Proxy 数据源,确保 SQL 自动注册到事务中。

2. 关键代码片段

主事务入口(订单创建)

@GlobalTransactional(timeoutMills = 30000)  // 开启全局事务
public Order createOrder(Long userId, BigDecimal amount, Long productId) {
    Order order = new Order();
    order.setUserId(userId);
    order.setAmount(amount);
    order.setStatus("CREATED");
    orderMapper.insert(order);


![负载均衡配置-2](https://code-guide.oss.shanghai.autogptai.club/common/file/download?name=date2025061316/afa1c30b-010f-4b2b-a229-dc5ffa3303d3.jpg)


    // 调用账户服务
    accountClient.charge(userId, amount);  // 内部也开启了本地事务

    // 调用积分服务
    pointClient.consumePoints(userId, 100L);

    // 扣减库存
    inventoryClient.decreaseStock(productId, 1);

    return order;
}

只需要添加 @GlobalTransactional 注解即可,无需额外处理任何回滚逻辑,Seata 会在发生异常时自动通知各参与方回滚。

配置示例(Spring Boot + MyBatis Plus)

application.yml 中添加:

seata:
  enabled: true
  application-id: ${spring.application.name}-${server.port}  # 标识唯一服务实例
  tx-service-group: my_test_tx_group                      # 事务分组,对应 TC 地址
  registry:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
  config:
    type: file

同时注意使用 Seata 提供的自动代理数据源:

@Bean
public DataSource dataSource(DataSourceProperties properties) {
    return new DataSourceProxy(properties.initializeDataSourceBuilder().build());
}

这样,SQL 操作都会被自动包装进 Seata 的事务上下文中。


踩坑经验:别以为加个注解就能解决问题!

虽然 Seata 做得比较完善,但在实际开发中还是遇到了不少坑,下面挑几个重点说一下。

坑点一:必须开启全局事务才能传递上下文

我们在调试过程中发现,如果在 Controller 直接调用 Service 方法,且方法没有标注 @GlobalTransactional,则会报错:

“no transaction context found for branch”

后来才明白,Seata 的事务上下文是 ThreadLocal 的,需要在最外层开启事务才能正确传播给子服务。因此我们约定:所有的对外 API 接口必须由开启全局事务的方法来接收请求

坑点二:锁竞争造成死锁/超时

Seata 使用行锁机制避免脏写,当多个事务同时修改同一条记录时,会出现等待锁释放的情况。我们有一次在压测环境中,遇到大量超时,错误日志显示:

“Lock wait timeout exceeded; try restarting transaction”

我们后来做了以下优化:

  • 缩短全局事务的执行时间;
  • 增加 TC 和 RM 的线程池大小;
  • 对热点数据进行读写分离或批量处理。

坑点三:幂等性设计不做好,回滚反而带来副作用

有一次用户重复点击下单按钮,触发了两次相同订单号的请求,导致事务冲突,其中一个事务被回滚,另一个成功。但因为接口设计没有做幂等处理,同一个订单号竟然产生了两条不同的订单记录。

吸取教训之后,我们在每个接口里加入了幂等校验:

String lockKey = "ORDER:" + userId + ":" + orderId;
Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, "LOCKED", 5, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(isLocked)) {
    throw new BusinessException("请勿重复提交");
}

效果与收益:终于不再担心数据不一致了

自从引入 Seata 后,我们系统的数据一致性得到了极大增强:

  • 跨服务事务操作成功率接近 100%;
  • 复杂场景下的回滚逻辑几乎不需要手动编写;
  • 运维人员反馈系统异常减少,客服投诉率下降 60%;
  • 原来需要几个小时修复的数据问题,现在几分钟内就能自动恢复。

同时,我们也发现了一个意想不到的收益:因为所有事务操作都通过 Seata 记录,我们可以很容易地查到哪个节点出了问题,大大提升了故障定位效率。


经验分享:这些原则让你少踩坑

结合这次实战,我想给正在做分布式系统的同学几点建议:

1. “强一致性”不是免费午餐,要权衡利弊

不要盲目追求数据完全一致,而是根据业务重要性决定是否引入分布式事务。对于非核心流程,可以采用最终一致性的设计方案,比如 MQ 异步补偿。

2. 尽早规划事务边界

在系统初期就要考虑哪些流程必须保持一致性,否则后期改起来代价极高。最好在设计文档中明确标注哪些是“原子操作”。

3. 接口务必有幂等性设计

无论你用不用 Seata,只要是外部调用,都一定要加上幂等校验,不然迟早会被“重试风暴”打崩溃。

4. 监控和日志不能省

Seata 自带了很多监控指标,比如事务数、回滚次数、锁等待数等等。如果你接入了 Prometheus + Grafana,可以在大屏上实时查看事务运行状况,及时发现问题。

5. 定期演练容灾能力

你可以模拟网络抖动、服务宕机、事务超时等异常情况,测试你的事务协调机制是否健壮。有时候线上不会发生的问题,在压测环境却暴露无遗。


结语:分布式事务,是一场修行

这篇文章写了差不多 3000 字,但它背后是我们团队整整三个月的摸索和踩坑。说实话,我现在回头看,觉得那段时间的痛苦都是值得的——因为正是这些问题,逼着我去深入理解分布式系统中的本质问题。

如果你正在构建一个复杂的业务系统,强烈建议你提前思考事务边界和一致性策略。哪怕暂时不引入 Seata,也可以先做一些简单的幂等和补偿机制,等到业务成熟后再升级也不迟。

最后送大家一句话:“技术的本质,是解决人的烦恼。而不是制造新的烦恼。” —— 在工程实践中不断迭代、找到平衡点,才是真正的高手之道。


如果你对 Seata 或其他分布式事务框架感兴趣,欢迎留言交流,我可以分享更多具体的实战配置、压测报告以及生产部署细节 😊

评论 0

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