分布式事务解决方案:一次电商平台重构实战记录

刘芳~
2025-06-13 20:44
阅读 376

我是李工,一个在后端开发领域摸爬滚打了5年的老码农。今天想和大家聊聊我在一次电商平台重构过程中踩过的“分布式事务”坑。如果你也遇到过服务拆分之后订单、支付、库存之间数据一致性的问题,那这篇文章应该能给你一些启发。

项目背景:从单体架构到微服务的阵痛期

项目背景:从单体架构到微服务的阵痛期

去年我所在的电商公司开始做系统架构升级,目标是把原来的大单体系统拆分成订单中心、商品中心、库存中心、交易中心等多个微服务模块。整体采用SpringCloud Alibaba技术栈,MySQL作为主要数据库,RocketMQ用于异步通信,Seata准备用来解决跨服务的数据一致性问题。

最核心的一次业务流程就是下单,涉及三个关键操作:

  • 扣减库存(InventoryService)
  • 创建订单(OrderService)
  • 冻结用户余额(PayService)

这三个操作分别属于不同的服务模块,并且每一步都涉及到各自的数据写入。一旦出现部分执行成功、部分失败的情况,后果会很严重——比如订单创建了但库存没扣减,或者库存扣减了但余额没有冻结,这些都会导致资损或超卖问题。

挑战一:传统本地事务行不通

挑战一:传统本地事务行不通

我们最开始尝试沿用传统的本地事务机制,也就是每个服务单独提交自己的事务。举个例子,在订单服务里这样处理:

@Transactional
public void createOrder(...) {
    // 调用库存接口预扣库存
    inventoryFeignClient.reduceStock(productId, quantity);
    
    // 创建订单主表
    orderDao.insert(order);

    // 调用支付接口冻结余额
    payFeignClient.freezeAmount(userId, amount);
}

这种方式最大的问题是无法保证全局一致性。假设调用支付服务时失败了,前面已经完成的操作就变成了脏数据。即使使用try-catch回滚,也无法回退远程服务已经执行成功的部分。

当时我们在压测环境发现了一个特别离谱的问题:某个测试账号余额为0,但竟然完成了下单操作,原因是payService调用失败后,异常被吞掉了。最终系统中出现了无效订单和未扣减库存,只能人工介入处理。

这显然是不能接受的。

解决方案:引入 Seata 实现 TCC 型分布式事务

解决方案:引入 Seata 实现 TCC 型分布式事务

我们经过调研和讨论,决定采用 Seata 来实现基于 TCC 模式的分布式事务管理。这里说句实在话,TCC 对业务侵入性比较强,但对于我们的业务场景来说还是可以接受的。

架构设计思路

整个事务流程分为两个阶段:

  1. Try 阶段(资源预留)
    • 检查可用性但不真正修改数据(如判断库存是否足够)
    • 预留状态标记(如冻结库存数量)
  2. Confirm / Cancel 阶段(提交或回滚)
    • Confirm:执行实际变更(如正式扣减库存)
    • Cancel:撤销 Try 中的操作(如解冻库存)

以库存服务为例,它需要提供如下接口:

// Try阶段
boolean prepareReduceStock(Long productId, Integer quantity);

// Confirm阶段
void confirmReduceStock();

// Cancel阶段
void cancelReduceStock();

订单服务作为发起方,通过 Feign 调用其他服务的 Try 方法完成预检查和资源锁定,在所有 Try 成功后再统一触发 Confirm;若某一步失败,则触发所有参与者的 Cancel 操作进行回滚。

关键配置与代码片段

首先在各个服务中添加 Seata 的相关依赖:

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

然后配置 Seata 客户端配置文件application.yml

seata:
  enabled: true
  application-id: ${spring.application.name}
  tx-service-group: my_tx_group
  service:
    vgroup-mapping:
      my_tx_group: default
    grouplist:
      default: seata-server:8091

API接口文档-1

订单服务的核心事务入口示例:

@GlobalTransactional(rollbackFor = Exception.class)
public OrderDTO createOrder(CreateOrderRequest request) {
    try {
        // 调用库存 Try 接口
        inventoryService.prepareReduceStock(request.getProductId(), request.getQuantity());

        // 创建订单(本地事务)
        Order order = saveOrderLocal(request);

        // 调用支付 Try 接口
        payService.prepareFreezeAccount(request.getUserId(), request.getAmount());

        // 提交事务,Seata自动处理后续Confirm或Cancel
        return convertToDto(order);
    } catch (Exception e) {
        log.error("下单失败", e);
        throw new BizException("下单失败");
    }
}

各服务需实现 TCC 接口:

@Component
public class InventoryTccAction {

    @Autowired
    private InventoryMapper inventoryMapper;

    @TwoPhaseBusinessAction(name = "prepareReduceStock")
    public boolean prepareReduceStock(BusinessActionContext ctx,
                                      @BusinessActionContextParameter(paramName = "productId") Long productId,
                                      @BusinessActionContextParameter(paramName = "quantity") Integer quantity) {
        // 减少可用库存,增加冻结库存
        return inventoryMapper.updateStock(productId, quantity) > 0;
    }

    @Commit
    public boolean confirmReduceStock(BusinessActionContext ctx) {
        Long productId = (Long) ctx.getActionContext("productId");
        Integer quantity = (Integer) ctx.getActionContext("quantity");
        // 真正扣减库存
        return inventoryMapper.confirmStock(productId, quantity) > 0;
    }

    @Rollback
    public boolean cancelReduceStock(BusinessActionContext ctx) {
        Long productId = (Long) ctx.getActionContext("productId");
        Integer quantity = (Integer) ctx.getActionContext("quantity");
        // 回退冻结库存
        return inventoryMapper.rollbackStock(productId, quantity) > 0;
    }
}

踩坑经历:生产环境的那些事儿

说实话,部署上线之后并没有立刻顺利跑通,我们遇到了几个典型问题:

1. 网络超时导致 Xid 丢失

在高峰期时,由于 Feign 调用默认超时时间较短(1s),导致部分请求在 Try 阶段就超时。这种情况下 Seata 无法感知事务状态,容易造成悬挂事务(Dangling Transactions)

解决办法:

  • 增加 Feign + Ribbon 的超时设置:

    ribbon:
      ReadTimeout: 3000
      ConnectTimeout: 3000
    feign:
      client:
        config:
          default:
            http-read-timeout: 3000
    
  • 在网关层面设置合理的超时重试策略(不要盲目重试)

2. Cancle 操作幂等性问题

我们初期的取消逻辑没有考虑重复执行的问题。例如,Cancel 被执行了两次,结果将原本应保留的库存又多减了一次。

修复方案:

  • 引入唯一事务ID(branchId)到 Cancel 方法中
  • 在服务端维护一个 Cancel 日志表,记录已执行的 Cancel 操作,执行前先校验是否已经处理过该 branchId

3. Seata Server 性能瓶颈

随着系统访问量的增加,Seata Server 单节点扛不住压力。特别是在大促期间,经常出现 Global Lock 获取失败的情况。

我们最终采用了以下优化措施:

  • 使用 DB 模式持久化事务日志,而不是默认的 file 模式
  • 增加多个 Seata Server 实例,负载均衡接入
  • 做了定制开发:对非高一致性场景放宽锁竞争策略

效果总结:系统稳定性显著提升

上线 TCC 分布式事务方案后,系统整体表现非常稳定。从监控数据来看:

  • 下单交易成功率由原来的 97% 提升至 99.95%
  • 每天因分布式异常导致的人工干预次数基本归零
  • 最终一致性得到了保障,客户投诉率下降明显

当然,代价也不是没有。比如 TCC 增加了代码复杂度,每个多资源操作都需要编写 Try、Confirm、Cancel 三个接口。此外,对运维人员也提出了更高的要求——必须熟悉 Seata 的日志结构、事务恢复机制等。

给读者的一些经验分享

如果你也在考虑使用 Seata 或者其他的分布式事务框架,以下几个建议或许对你有用:

✅ 优先考虑本地事务 + 最终一致性方案

并不是所有场景都适合使用强一致的分布式事务。比如某些日志类数据、非敏感金额类字段,可以通过 RocketMQ + 补偿任务来实现最终一致性,反而更轻量高效。

🛠️ 提前做好事务隔离级别设计

不同数据库的默认隔离级别可能影响事务行为。建议统一设置为 Read Committed 并配合 SELECT FOR UPDATE 显式加锁。

🔐 注意日志安全与敏感信息脱敏

Seata 会在事务日志中记录业务参数,务必注意隐私数据的脱敏处理,避免泄露风险。

🧪 加强压测与回滚演练

每次上线前我们都会模拟各类网络故障和节点宕机,测试 Cancel 是否能正确执行、数据是否可恢复。建议你也这样做。

📈 结合监控平台做实时追踪

我们将 Seata 的 TM、RM、TC 监控指标接入了 Prometheus + Grafana,实现了对分布式事务状态的可视化管理,非常推荐这套组合。


分布式事务从来不是一个轻松的话题,它关乎系统的健壮性和用户体验。通过这次项目的磨练,我也深刻体会到,“稳”的背后往往是一点一点细节打磨出来的。希望我的经验能帮你在面对类似问题时少走些弯路。如果你也遇到过这类问题,欢迎留言交流!

评论 0

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