分布式事务实战:从踩坑到掌控,我的经验分享

一个会部署的人
2025-06-23 14:13
阅读 449

一、开篇:为什么我要写这个话题?

一、开篇:为什么我要写这个话题?

去年我在一家中型互联网公司参与了一个电商项目的重构,其中最让我头大的一个模块就是订单系统。它牵涉到多个微服务,比如库存服务、支付服务、用户服务和物流服务。这些服务彼此独立部署,使用各自数据库,但业务逻辑上又高度耦合。

有一次我们上线了一个促销活动,订单量瞬间飙涨,结果出现了一些匪夷所思的问题:

  • 用户付了款,但是库存没扣;
  • 库存扣了,但订单没有生成;
  • 系统里出现了“脏数据”,退款也退不出去……

后来复盘发现,这些问题的根因都出在——分布式事务处理不当

那段时间,我几乎是被这些事务问题“按在地上摩擦”,每天都在排查、日志分析、回滚数据、修 Bug……痛定思痛之后,我开始系统学习分布式事务的相关方案,并在项目中落地了一个相对稳定可靠的解决方案。今天就借这篇文章,把我踩过的坑、收获的经验分享出来,希望能帮到你。


二、真实场景下的问题描述

二、真实场景下的问题描述

我们的订单服务采用的是 Spring Cloud + MyBatis + MySQL 架构,每个核心服务(如订单、支付、库存)都是独立微服务,通过 Dubbo 进行远程调用。整个流程大致如下:

  1. 用户下单 → 订单服务创建订单;
  2. 调用库存服务,冻结商品库存;
  3. 调用支付服务完成付款;
  4. 支付成功后,提交订单并减少库存;
  5. 发送消息通知物流服务。

理论上看起来没问题,但在高并发或网络波动时就会出乱子。例如:

  • 支付成功后,订单状态更新失败;
  • 某个服务宕机,导致事务处于中间态;
  • 消息队列延迟,造成重复消费或丢失数据。

这些都是典型的分布式事务不一致的场景。


三、解决方案选型与设计思路

在调研了几种主流方案后,我决定采用 Seata + TCC + 异步消息补偿机制 结合的方式:

1. Seata 实现全局事务管理

我们引入了阿里开源的 Seata,实现基于 AT 模式的全局事务管理,适用于大多数场景下的一致性保障。

  • 优点:对开发透明,无需改动原有业务代码太多;
  • 缺点:AT 模式依赖于数据库本地事务,需要引入 Undo Log 表,在大并发情况下会带来一定性能损耗。

我们在订单服务中添加了 Seata 客户端,所有跨服务调用都会被纳入同一个全局事务中:

spring:
  cloud:
    alibaba:
      seata:
        tx-service-group: my_tx_group

并配置好了 TC(Transaction Coordinator)服务器,部署在 Kubernetes 上,作为中心协调器。

2. 针对库存等关键资源,使用 TCC 模式

由于库存操作非常频繁,且不能容忍任何误差,所以我们为库存服务单独实现了 TCC 模式:

  • Try 阶段:检查并冻结库存;
  • Confirm:正式扣减库存;
  • Cancel:解冻库存。

这样即使后续服务失败,也能保证最终一致性。

举个例子,下面是 Try 接口的核心逻辑:

public boolean deductStockTry(String productId, int quantity) {
    int available = stockRepository.getAvailable(productId);
    if (available < quantity) {
        return false;
    }
    // 冻结库存
    stockRepository.freeze(productId, quantity);
    return true;
}

Confirm 的实现:

public void deductStockConfirm(String productId, int quantity) {
    stockRepository.deduct(productId, quantity);
}

Cancel 的实现:

public void deductStockCancel(String productId, int quantity) {
    stockRepository.unfreeze(productId, quantity);
}

3. 异步补偿机制兜底

为了应对极端情况(如 Seata Server 崩溃),我们还引入了异步消息补偿机制:

  • 所有事务操作完成后,发送一条事务状态变更的消息到 Kafka;
  • 另一个消费者监听这个 Topic,如果发现某笔事务卡在中间状态超过一定时间,自动触发补偿逻辑。

这部分是系统的“保险丝”,虽然平时可能不会动,但关键时刻能救命。


四、关键代码实践

下面我贴一段 Seata 在订单服务中的调用示例,方便你更好地理解集成方式:

@GlobalTransactional
public void createOrderAndPay(OrderDTO orderDTO) {
    // 1. 创建订单
    Order order = orderService.createOrder(orderDTO);

    try {
        // 2. 调用库存服务冻结库存(需支持 Seata)
        inventoryService.freezeInventory(order.getProductId(), order.getQuantity());

        // 3. 调用支付服务扣款
        paymentService.charge(order.getUserId(), order.getTotalPrice());

        // 4. 提交订单
        orderService.submitOrder(order.getId());

    } catch (Exception e) {
        // 异常时,Seata 会自动回滚
        log.error("分布式事务执行失败,将进行回滚", e);
        throw e;
    }
}

可以看到,使用 Seata 后业务代码变化不大,只需要加上 @GlobalTransactional 注解即可。

而 TCC 模式则需要开发者自己实现 Try/Confirm/Cancel 接口,并注册到 Seata 中:

@Component
public class InventoryTccAction implements TccActionOnePhase {

    @Autowired
    private InventoryService inventoryService;

    @TwoPhaseBusinessAction(name = "deductStock")
    public boolean prepare(BusinessActionContext ctx) {
        String productId = (String)ctx.getActionContext("productId");
        int quantity = (Integer)ctx.getActionContext("quantity");
        return inventoryService.freezeInventory(productId, quantity);
    }

    @Commit
    public boolean commit(BusinessActionContext ctx) {
        String productId = (String)ctx.getActionContext("productId");
        int quantity = (Integer)ctx.getActionContext("quantity");
        inventoryService.deductInventory(productId, quantity);
        return true;
    }

    @Rollback
    public boolean rollback(BusinessActionContext ctx) {
        String productId = (String)ctx.getActionContext("productId");
        int quantity = (Integer)ctx.getActionContext("quantity");
        inventoryService.unfreezeInventory(productId, quantity);
        return true;
    }
}

五、踩坑经验分享

坑 1:Seata 和本地事务冲突

我们一开始为了提升接口性能,开启了本地事务和 Seata 共同工作,结果导致事务嵌套,出现了死锁和锁等待超时。

解决方法

  • 统一使用 Seata 来管理所有分布式事务;
  • 关闭本地事务控制(如去掉 spring 的 @Transactional)。

坑 2:TCC 未幂等导致重复扣减

在一次测试中发现,库存多次被重复扣除,最后查出是因为 TCC 的 Commit 方法没有做好幂等处理。

解决方法

  • 在每个 Confirm/Cancel 方法里加入唯一事务 ID 判断;
  • 使用 Redis 缓存事务 ID,防止重复执行。
public boolean commit(BusinessActionContext ctx) {
    String txId = ctx.getXid();
    if (redisTemplate.opsForValue().get("tx_" + txId) != null) {
        return true; // 已经处理过
    }
    // 扣减库存逻辑...
    redisTemplate.opsForValue().set("tx_" + txId, "done", 24, TimeUnit.HOURS);
    return true;
}

坑 3:消息补偿机制失效

有一阵子因为 Kafka 消费积压,导致很多订单无法正常释放库存,甚至影响到了第二天的业务。

解决方法

  • 增加消费者的并发数;
  • 设置合理的重试策略和监控报警;
  • 加入定时任务兜底查询未处理完成的事务。

六、实施效果与收益

自打这套分布式事务体系上线后,系统稳定性明显提升:

指标 上线前 上线后
订单异常率 0.8% 0.05%
库存一致性 偶尔错乱 几乎完美
平均事务耗时 680ms 420ms
数据回滚次数 每天约 3~5 次 每周不到 1 次

更关键的是,现在每次促销活动再也不怕“崩”了,研发团队的焦虑指数也降了不少 😄。


七、我的经验和建议

做分布式事务不是一锤子买卖,也不是某个技术组件一加就能解决的事,它是架构设计、编码习惯、运维能力等多个维度的综合体现。

以下几点是我在这趟“长征”中学到的重要经验,分享给你:

✅ 技术选型要因地制宜

不要盲目追求某种“银弹”,像 Seata 这类工具虽然好用,但也存在性能瓶颈和复杂度。对于简单场景,也可以考虑用“本地事务表+定时对账”的方式来做兜底。

✅ 接口设计要统一标准

不管是 Dubbo 还是 REST,都要定义清晰的错误码、幂等标识、事务上下文传递机制,这对后续扩展至关重要。

✅ 异常处理要做得体面

无论采用哪种分布式事务方案,都无法做到 100% 成功,务必设置完善的补偿机制和异常记录手段。出了问题要能快速定位,而不是靠人肉排查日志。

✅ 日志和监控不能省

每一步事务的状态、请求参数、返回结果都要完整记录,最好接入链路追踪系统(比如 SkyWalking、Zipkin)。只有看得到,才能治得好。

✅ 性能也要兼顾

像 Seata 的 AT 模式,在写压力较大的系统里可能会成为瓶颈。可以结合业务特点做一些优化,比如只在必要路径启用事务,或者把部分非关键操作做成异步化。


八、小结一下

分布式事务从来都不是一件轻松的事情,它考验的是你的架构能力和工程素养。在我亲身经历的这次实践中,我深刻体会到:

“所谓成熟,不是不犯错,而是在一次次踩坑中找到了属于自己的节奏。”

如果你正在面对这类问题,或者正准备踏上这条路,希望我的这段经历能给你一点点帮助和信心。毕竟我们都是一边踩坑一边成长的程序员,不是吗?


欢迎留言交流,一起探讨你在分布式事务方面的实战经验!

评论 0

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