分布式事务解决方案:最佳实践分享

线上救火队
2025-06-27 17:29
阅读 734

引言:为什么会写这篇文章?

作为全栈开发工程师,我在过去几年参与过多个中大型系统的设计与开发。在这些项目中,有一个问题几乎每次都让我头疼不已——分布式事务的处理。尤其当我们从一个简单的单体架构迁移到微服务架构时,数据一致性就成了必须面对的一道坎。

我至今记得第一次在生产环境中因为一个未处理好的跨服务操作导致数据不一致的情况:用户下单成功,库存减少了,但订单状态却没更新……那一次我们不得不手动回滚,整个过程耗费了整整两个小时。那次经历对我冲击挺大的,也促使我下定决心去深入了解和掌握分布式事务的各种解决方案。

这篇文章,我想用真实的项目经验来带你一步步走过这段“痛苦”的旅程,并把我在实战中学到的经验毫无保留地分享出来。


背景介绍:我们面临的是什么样的问题?

项目是一个电商平台后端,初期是单体结构,后来随着业务扩展,逐渐拆分成了多个微服务模块,包括:

  • 订单服务(Order Service)
  • 库存服务(Inventory Service)
  • 用户服务(User Service)
  • 支付服务(Payment Service)

每个服务都有自己的数据库,使用 Spring Boot + MyBatis + MySQL 构建,前后端分离,前端为 React,网关是 Zuul(后面升级为 Spring Cloud Gateway)。

我们的挑战来自于这样一个业务场景:

用户下单购买商品,需同时完成以下操作:

  1. 创建订单(调用订单服务)
  2. 减少库存(调用库存服务)
  3. 扣除积分或余额(调用用户服务)

如果其中任意一个步骤失败,其他服务也要回退到原始状态,保证数据最终一致性。

这明显就是一个典型的跨服务、跨数据库的分布式事务场景


初期方案尝试:本地事务 + 人工补偿

最初,我们采用了最简单的方式:本地事务 + 手动补偿机制

举个例子,当订单服务创建完订单之后,会依次调用库存服务和用户服务。如果某个服务调用失败,就调用对应的反向接口(如增加库存、恢复积分)来回滚。

代码结构大致如下:

@Transactional(rollbackFor = Exception.class)
public void createOrderWithInventory(String userId, String productId) {
    try {
        // 创建订单
        orderService.create(userId, productId);

        // 减库存
        inventoryService.reduce(productId, 1);

        // 扣用户余额
        userService.deductBalance(userId, productPrice);
        
    } catch (Exception e) {
        // 手动触发补偿逻辑
        compensateWhenFail(userId, productId);
        throw new RuntimeException("下单失败", e);
    }
}

这个方案在早期小规模时还能勉强运转,但随着用户量增长和服务复杂度提升,很快暴露出几个致命问题:

  1. 补偿逻辑复杂且难以维护,特别是在多层调用中容易出错。
  2. 无法处理网络延迟和异步情况,比如某个服务宕机时,根本不知道是否执行成功。
  3. 存在并发竞争的问题,尤其是在高并发下单场景中。

于是我们意识到,必须要找一个更可靠、更稳定的分布式事务解决方案。


最终选择:基于 Seata 的柔性事务方案

经过调研和对比,我们最终选择了 Seata 作为我们核心分布式事务框架。它支持 AT 模式、TCC 模式、Saga 模式等,适合我们这种基于关系型数据库的系统架构。

我们采用的是 AT 模式(Auto Transaction Mode),因为它对现有代码改动最小,只需要添加注解即可。

为什么选 Seata?

  1. 对已有项目的侵入性低;
  2. 社区活跃,文档丰富;
  3. 可以与 Spring Boot、MyBatis 等主流框架良好集成;
  4. 提供可视化管理界面(虽然我们在生产环境没有启用);
  5. 成熟应用于多个大型电商系统案例。

技术方案详解:如何接入 Seata?

1. 架构调整

我们引入了 Seata Server 作为统一的事务协调者(TC),所有需要进行分布式事务的服务都作为 RM(资源管理器)注册进去,TM(事务管理器)由发起事务的服务担任。

Seata 架构图

(此处应插入实际部署结构图)

2. 核心依赖引入

pom.xml 中添加 Seata starter:

<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-spring-boot-starter</artifactId>
    <version>1.6.1</version>
</dependency>

配置文件添加 seata 相关信息:

seata:
  enabled: true
  application-id: order-service
  tx-service-group: my_test_tx_group
  service:
    vgroup-mapping:
      my_test_tx_group: default
    grouplist:
      default: 127.0.0.1:8091
  config:
    type: nacos
  registry:
    type: nacos

这里的 vgroup-mapping 是为了将不同的事务组绑定到 TC 上。

3. 使用 @GlobalTransactional 注解

这是最核心的部分。我们在业务方法上加上该注解,就能自动开启全局事务。

例如:

@GlobalTransactional
@Override
@Transactional(rollbackFor = Exception.class)
public void createOrder(String userId, String productId) {
    orderService.create(userId, productId);     // 插入订单
    inventoryClient.reduceStock(productId);     // RPC 调用库存服务
    userClient.deductBalance(userId, price);    // RPC 调用用户服务
}

这样只要任何一个服务调用失败,Seata 就会自动执行“二阶段提交”的“回滚”部分。


踩坑记录:那些年我们一起掉过的坑

坑一:事务未生效?!

刚接入的时候,我们遇到了事务不生效的问题,原因其实很简单:Feign 客户端调用服务没有带上 XID

解决办法是在 Feign 配置中加上拦截器,传递事务上下文。

@Bean
public RequestInterceptor requestInterceptor() {
    return template -> {
        String xid = RootContext.getXID();
        if (StringUtils.isNotBlank(xid)) {
            template.header("TX_XID", xid);
        }
    };
}

同时,在被调用服务中也要注册解析器来接收 XID:

@Configuration
public class SeataConfig {
    @Bean
    public WebMvcConfigurer webMvcConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                registry.addInterceptor(new HandlerInterceptorAdapter() {
                    @Override
                    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
                        String xid = request.getHeader("TX_XID");
                        if (StringUtils.isNotBlank(xid)) {
                            RootContext.bind(xid);
                        }
                        return true;
                    }

                    @Override
                    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
                        RootContext.unbind();
                    }
                });
            }
        };
    }
}

坑二:死锁 or 长时间等待

在高并发压测过程中,发现某些请求卡住不动。排查后发现是因为数据库行锁冲突,事务迟迟未能释放。

解决方案有几种:

  1. 减少事务粒度,只锁定必要数据;
  2. 优化 SQL 查询性能,尽量避免索引失效;
  3. 设置合理的超时时间
    seata:
      client:
        async-committing:
          retry-limit: 3
        lock:
          retry:
            internal: 10
            retry: 3
        report-retry-count: 5
        table-meta-check-enable: false
    

坑三:Seata Server 挂了怎么办?

我们曾遇到测试环境中 TC 突然宕机的情况,结果发现事务直接阻塞,无法继续。

应对策略:

  • 生产环境建议部署多个 Seata 实例做 HA,借助 Nacos 或 Zookeeper 进行注册;
  • 同时做好故障转移机制,比如降级成本地事务或异步重试。

效果总结:带来的收益

迁移完成后,系统稳定性显著提升:

  1. 事务一致性大大增强,跨服务操作失败率下降了 99.9%;
  2. 异常情况下的恢复能力变强,基本可以自动完成回滚;
  3. 日志可追踪性强,通过 XID 可以快速定位事务链路;
  4. 开发效率提高,只需关注业务逻辑,不用再为补偿逻辑烦恼。

我们还配合 SkyWalking 做了全链路追踪,进一步提升了排查问题的效率。


经验分享:给开发者的几点建议

如果你正在面临或者即将面临分布式事务问题,以下是我亲身踩坑后的几点建议:

  1. 尽早规划事务边界,不是所有操作都需要严格一致性,有些场景可以用最终一致 + 补偿来做。

  2. 不要迷信某一个方案,要根据业务类型、吞吐量、可用性要求来选择合适的模型:

    • 高一致性场景用 AT 模式;
    • 高性能场景考虑 TCC;
    • 复杂流程推荐 Saga 模式。
  3. 一定要有监控和报警机制,一旦出现事务挂起或异常,必须第一时间通知运维。

  4. 持续压测 & 日常演练非常重要。我们每次上线前都会跑一遍压力测试,模拟断点和异常,确保事务机制在各种极端情况下仍然可用。

  5. 不要忽略运维层面的支持,像 Seata Server、Nacos 等中间件的稳定性、扩容、备份都需要提前规划好。


写在最后

分布式事务这个问题说难也难,说简单也不简单。关键在于理解每种方案的适用场景,结合业务需求做出权衡。

我在这一路上学到了很多,也摔了不少跟头。希望这篇来自真实工作场景的技术总结,能帮助你少走一些弯路。

如果你也在用 Seata 或者正在研究分布式事务,欢迎留言交流经验,也许我们可以一起讨论更多实用的方案和思路 😊


本文作者:一位坚持写出靠谱代码的程序员老赵。
公众号:CodeInThought / GitHub:zhaoolee

评论 0

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