用实战告诉你,分布式事务到底怎么搞?

可爱鹿
2025-06-29 06:08
阅读 627

我是一个在互联网公司工作的后端开发者,过去几年里,我参与并主导过多个大型系统的重构和优化工作。从最初的单体应用到后来的微服务架构,系统拆分越来越细、模块越来越多,随之而来的最头疼的问题之一就是:跨服务的数据一致性问题

尤其是在支付、订单、库存这些核心业务场景下,我们常常会遇到这样的需求:一个操作必须保证多个服务同时成功或同时失败。如果其中一个环节出错,其他已经执行的操作也得回退,否则就会出现数据不一致,最终影响用户体验,甚至带来财务风险。

这篇文章想跟你聊聊我在实际工作中,面对分布式事务这个问题时的一些思考、踩过的坑,以及我们最终选择的技术方案和落地效果。


背景介绍:为什么会有“分布式事务”的烦恼?

背景介绍:为什么会有“分布式事务”的烦恼?

我们公司的核心业务系统,最初是基于单体架构搭建的。随着用户量增长和服务复杂度提升,我们逐步将系统拆分为多个独立的微服务模块,例如:

  • 用户中心
  • 订单服务
  • 支付中心
  • 库存服务

每个服务都有自己独立的数据库,使用 Spring Boot + MyBatis 开发,部署在 Kubernetes 上。整个系统通过 REST API 进行调用通信。

当我们要处理一笔订单时,流程大致如下:

  1. 订单服务创建订单;
  2. 库存服务扣减库存;
  3. 支付中心进行支付;
  4. 用户中心更新积分或者账户余额。

这四个步骤涉及四个不同的服务和数据库,而且它们之间是强依赖关系——只要其中一步失败,整个业务流程都得回滚。这时候你可能觉得:那加个事务就行了呗?可问题是,在分布式系统中,本地事务无法跨越服务边界

于是我们遇到了一个很典型的问题:

如何在多个服务之间保证事务的一致性?


遇到的挑战:分布式事务不是个简单题

遇到的挑战:分布式事务不是个简单题

一开始,我们尝试手动维护状态,比如先记录日志,然后顺序执行各个服务接口,失败后再主动去补偿。但很快发现这种做法不可持续,因为:

  • 日志逻辑复杂,难以调试;
  • 出现网络异常或超时怎么办?
  • 补偿逻辑容易漏掉边界情况;
  • 不易扩展,一换需求就得大改代码;
  • 没有统一的事务管理机制,缺乏灵活性。

举个例子:某次我们上线了一个优惠券核销流程,订单服务调用优惠券服务扣减额度时网络超时,订单服务以为失败了,但优惠券那边其实已经成功执行,结果用户没下单成功但优惠券却被扣掉了。这就导致大量客诉,运营还要做人工退款。

这让我们意识到:光靠自己写补偿逻辑,太容易出错了,需要一个更可靠、可控、易于维护的解决方案。


解决方案:我们选用了 Seata,它是我们的“分布式事务管家”

调研过后,我们决定引入开源项目 Seata 来解决分布式事务问题。

Seata 是阿里巴巴开源的分布式事务中间件,提供了一种叫 AT 模式(Auto Transaction Mode) 的解决方案。它本质上是在本地事务的基础上加上“全局锁”,并通过日志记录实现回滚与提交控制。

我们选择 Seata 的几个理由:

  1. 侵入性低:只需对数据库、SQL 和注解稍作改造即可接入;
  2. 性能较好:相比传统的两阶段提交协议(2PC),AT 模式在第一阶段直接提交本地事务,第二阶段异步清理;
  3. 社区活跃:官方文档完善,社区支持丰富;
  4. 适合微服务架构:天然兼容 Spring Cloud 生态;
  5. 具备容灾能力:即使某个服务宕机,也能依靠事务日志恢复。

实施过程:如何一步步接入 Seata?

第一步:架构调整

我们在系统中引入了一个名为 Seata Server 的角色,作为协调者(Transaction Coordinator)。每个服务节点安装 Seata 客户端 SDK,并连接到 TC。

整体结构变成了这样:

+------------------+       +------------------+
|   Order Service  |<----->|   Seata Server   |
+------------------+       +------------------+
         |
         v
+------------------+  
| Inventory Service|<-----+
+------------------+      |
                          |
+------------------+      |
|  Payment Service |<-----+
+------------------+      |
                          |
+------------------+      |
| User Center      |<-----+
+------------------+

Seata Server 管理事务的全局状态,客户端负责执行本地事务,并上报状态。

第二步:服务改造

以订单服务为例,在创建订单的方法上添加了 Seata 的事务控制注解:

@GlobalTransactional(name = "create_order_tx")
public void createOrder(OrderDTO orderDTO) {
    // 调用本地数据库插入订单
    orderMapper.insert(order);

    // 调用库存服务远程接口扣减库存
    inventoryService.decreaseStock(orderDTO.getProductId(), orderDTO.getProductNum());

    // 调用支付服务完成支付
    paymentService.charge(orderDTO.getAmount());

    // 调用用户中心更新积分
    userService.updatePoints(order.getUserId(), orderDTO.getPoints());
}

这里的关键是 @GlobalTransactional 注解。这个注解标识的是一个全局事务入口,当方法被调用时,Seata 会自动开启一个全局事务,并为后续的服务调用生成唯一的 XID,传递给下游服务。

下游服务接收到请求后,也会自动注册到当前事务,并在本地数据库记录 undo_log(用于事务回滚)。等到主事务调用方 commit/rollback 时,Seata 再统一处理各服务的提交或回滚动作。

第三步:配置调整

每个服务都新增了如下的配置项(Spring Boot application.yml):

seata:
  enabled: true
  application-id: order-service
  tx-service-group: my_test_tx_group
  service:
    vgroup-mapping:
      my_test_tx_group: default
  config:
    type: file
  registry:
    type: nacos
    nacos:
      server-addr: 127.0.0.1:8848
      group: SEATA_GROUP
      data-id: seataServer.properties

此外,还需要对数据库做一些准备工作:

  • 每个数据库都要增加一张 undo_log 表,用于事务回滚。
  • 修改数据访问层,确保所有 SQL 操作都在同一个数据库连接池中执行(避免死锁或隔离级别冲突)。

踩坑经验分享:别让这些坑绊倒你!

虽然 Seata 很强大,但在实际开发过程中我们也踩了不少坑,下面是一些关键点和教训总结:

1. 注意 SQL 语法限制

Seata 的 AT 模式对 SQL 有一定的限制,不能识别一些复杂的语句,比如嵌套子查询、联合 UPDATE 等。建议:

  • 使用简单的 insert、update、delete 操作;
  • 尽量避免动态 SQL;
  • 如果确实需要用复杂语句,可以切换成 TCC 模式,自行编写 cancel 方法。

2. 数据库连接池设置不当引发死锁

我们在初期使用了 HikariCP,连接池大小设置为 5,但发现当并发较大时,经常卡死在获取 Undo Log 锁的地方。

解决办法:适当调大连接池大小,并且根据压测结果做参数调优。

3. XID 未正确透传导致事务不生效

有些时候,我们并没有把 XID 透传到下游服务,导致事务管理失效。

比如在 Feign 调用时,我们需要自定义拦截器将 XID 添加到 header 中:

@Configuration
public class SeataFeignConfig implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        String xid = RootContext.getXID();
        if (xid != null) {
            template.header("XID", xid);
        }
    }
}

下游服务也要拦截请求头,还原 XID 上下文:

@Component
public class SeataContextFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String xid = httpRequest.getHeader("XID");
        if (StringUtils.isNotBlank(xid)) {
            RootContext.bind(xid);
        }
        try {
            chain.doFilter(request, response);
        } finally {
            RootContext.unbind();
        }
    }
}

4. 幂等性和重试机制必须配合使用

在某些网络不稳定的情况下,RPC 可能会出现重复请求。如果没有幂等设计,可能导致库存多次扣减。

因此我们建议:

  • 在关键接口设计中加入幂等字段(如流水号、交易ID);
  • 对重复的幂等请求直接返回 success;
  • 结合 Redis 缓存防重。

5. 不要过度依赖全局事务

有时候我们会为了图省事,给所有接口都加上 @GlobalTransactional,但实际上并不是所有业务都需要这么高的事务一致性。

建议结合业务场景,明确哪些流程必须严格一致(如支付、库存),哪些可以接受 eventual consistency(如日志记录、通知类操作),从而合理使用分布式事务。


效果总结:我们的收益是什么?

引入 Seata 后,我们取得了以下显著成效:

  • 业务一致性增强:订单成功率提高约 5%,异常数据基本消失;
  • 代码清晰易维护:不再需要自己写补偿逻辑,代码结构更干净;
  • 运维成本降低:Seata 提供了可视化界面和日志追踪功能,方便排查问题;
  • 开发效率提升:新业务接入分布式事务更快捷,节省时间;
  • 系统可用性提升:通过合理的回滚机制,即使个别服务故障也不会造成大面积阻塞。

经验总结:几点建议送给各位同行

如果你也在面临分布式事务的问题,我想分享以下几个小建议:

  1. 不是所有地方都适合用全局事务:能接受最终一致性的场景尽量不用;
  2. 事务粒度要精细:一个事务不要太长,避免占用资源过多;
  3. 日志一定要完整:XID 要打印进日志,方便定位问题;
  4. 要有降级方案:比如事务失败后,走人工补偿通道;
  5. 技术不是银弹:引入任何框架前都要评估团队熟悉程度和学习成本。

说到最后,我其实有一个感受特别深的感悟:

分布式系统中的问题,很多时候都不是单纯靠一个框架就能解决的。解决问题的背后,是我们对系统设计的理解、对业务边界的把握、对错误容忍度的权衡。

Seata 是一把好工具,但它只是一个手段,真正重要的还是我们作为架构师或开发者如何去理解和应用它。


结语

分布式事务不是一个轻松的话题,但也不是高不可攀的难题。只要你理解了它的本质,选对了工具,再结合具体的业务场景去做调整,就一定能找到一个适合自己系统的最佳实践方案。

这篇文章讲完了我个人在工作实践中的一些经验和思考,希望对你有所帮助。如果你有不同的看法,或者也有自己的实战案例,欢迎留言交流!


作者备注:本文内容全部来源于本人亲身参与的项目经历,如有雷同纯属巧合。文中部分示例代码经过简化展示,实际生产环境请根据自身情况做适配和测试。

评论 0

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