分布式事务解决方案:我的实战经验分享

编译通过了吗
2025-06-24 09:14
阅读 782

开篇:为什么我要写这篇文章?

开篇:为什么我要写这篇文章?

作为一个从业务系统开发逐渐转向微服务架构的后端工程师,我这些年参与过不少中小型系统的架构设计和核心模块开发。其中,最让我印象深刻的,是一次典型的“分布式事务”问题。

事情发生在一次重构旧系统、拆分单体应用为多个微服务的过程中。原本一个简单的库存扣减+订单创建的操作,在服务拆分后变成了跨两个服务(订单服务 & 库存服务)的操作,而这两个服务又分别依赖各自的数据库。结果上线没多久,就出现了“订单创建了但库存没扣”,或者更糟的情况,“库存扣了但订单没生成”的问题。

这背后其实就是分布式事务的难题。我当时带着团队踩了不少坑,也尝试过各种方案,最终找到了一条在我们业务场景下相对平衡的路子。于是,就有了这篇结合我实际经历的总结文章。

希望看完以后,你能少走弯路。


问题描述:从单体到微服务,事务失效了

问题描述:从单体到微服务,事务失效了

项目背景是我们公司要做一次系统改造,把原来的一个电商平台的单体应用拆分成多个独立部署的服务。具体来说,包括:

  • 订单服务
  • 商品服务
  • 库存服务
  • 支付服务

这些服务都使用 Spring Boot + MySQL 的组合,并且通过 RPC 接口互相调用。

我们遇到的问题是:用户下单时需要同时完成两个操作:

  1. 创建订单
  2. 扣除商品库存

在单体应用中,这两个操作天然在一个本地事务里,要么一起成功,要么回滚。可拆成服务后,这两个操作分布在不同的服务中,数据也分散在不同数据库中,原有的事务机制失效了。

第一次上线后,我们就收到了几个奇怪的异常数据:

  • 用户支付成功了,但库存没有扣减,出现超卖风险。
  • 订单状态是“已提交”,但对应商品仍然显示“有库存”,导致客服频繁投诉。

这个问题必须解决,否则系统无法正常运营。


解决方案选择:从本地事务到TCC再到消息队列补偿

面对这种跨库、跨服务的事务问题,常见的解决方案包括:

  1. 两阶段提交(2PC)/三阶段提交(3PC)
  2. TCC(Try - Confirm - Cancel)
  3. Saga 模式
  4. 基于消息队列的异步补偿机制
  5. 本地事务表 + 异步处理

我们在讨论选型的时候,首先排除了 2PC 和 3PC,因为这类方案对数据库支持要求高,而且性能差,对于电商业务这种并发量较高的场景来说不太现实。

剩下的几种我们都考虑过。比如:

  • TCC 理论上能保证一致性,但开发成本较高,每一个接口都需要实现 Try / Confirm / Cancel 三个方法;
  • Saga 模式适合长流程、多步骤的场景(比如订票),但在订单这种短流程场景有点“杀鸡用牛刀”;
  • 基于 RocketMQ 或 Kafka 的消息队列方式,在我们之前做过类似实践,有一定积累。

最后,我们决定采用一种混合策略:主流程使用本地事务 + 消息队列做后续补偿的方式来实现最终一致性的保障。

这个思路的核心是:

  1. 在订单服务先创建订单,并记录状态为“待确认”
  2. 同一本地事务中,插入一条待消费的消息到“事务消息表”
  3. 异步消费这条消息,远程调用库存服务进行库存扣减
  4. 如果扣减失败,定时任务会重新投递这条消息

这样既避免了强一致性带来的复杂性和性能开销,又能通过异步补偿来保证最终一致性。


实现细节:代码与架构如何配合工作

接下来分享一下我们这套方案的具体实现方式,涉及到几个关键点:

1. 数据库结构设计

我们新增了一个 order_message 表,用于保存订单创建后的待消费消息,结构如下:

CREATE TABLE order_message (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id BIGINT NOT NULL COMMENT '对应的订单ID',
    msg_body TEXT NOT NULL COMMENT '消息内容 JSON 格式',
    status ENUM('WAIT', 'SENT', 'SUCCESS', 'FAILED') DEFAULT 'WAIT' COMMENT '消息状态',
    retry_count INT DEFAULT 0 COMMENT '重试次数',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME ON UPDATE CURRENT_TIMESTAMP
);

这个表的设计目的是为了在创建订单的同时插入一条消息记录,利用本地事务保证订单创建和消息记录同时成功或失败。

数据库设计模型-2

2. 代码实现逻辑(Spring Boot + MyBatis)

订单服务中的简化伪代码如下:

@Transactional
public void createOrder(OrderDTO dto) {
    // 创建订单
    Order order = buildOrder(dto);
    orderMapper.insert(order);

    // 构建发送给库存服务的消息
    InventoryDeductMessage msg = new InventoryDeductMessage();
    msg.setOrderId(order.getId());
    msg.setSkuId(dto.getSkuId());
    msg.setCount(dto.getCount());

    // 插入消息表,和订单创建保持在同一个本地事务
    orderMessageService.save(msg);
}

这里的关键在于,插入订单和消息这两步是在一个本地事务中完成的,只要其中一步出错就会整体回滚,不会出现“订单没创建成功但消息已经发出去了”的情况。

3. 异步消息消费

我们使用的是 RocketMQ 作为消息中间件。异步消费者定时从数据库拉取状态为 WAIT 的消息并发送到 MQ:

@Scheduled(fixedDelay = 5000)
public void sendMessages() {
    List<OrderMessage> messages = messageService.listWaitMessages();

    for (OrderMessage msg : messages) {
        try {
            rocketMQTemplate.convertAndSend("INVENTORY_TOPIC", msg.getMsgBody());
            msg.setStatus("SENT");
            messageService.update(msg);
        } catch (Exception e) {
            log.warn("消息发送失败: {}", e.getMessage());
            msg.setRetryCount(msg.getRetryCount() + 1);
            if (msg.getRetryCount() > MAX_RETRY_COUNT) {
                msg.setStatus("FAILED");
            }
            messageService.update(msg);
        }
    }
}

库存服务监听该 topic,执行真正的库存扣减动作。

4. 失败重试与补偿机制

我们还增加了一个定时任务用于扫描失败的消息并重新发送:

@Scheduled(fixedDelay = 60_000)
public void retryFailedMessages() {
    List<OrderMessage> failed = messageService.listFailedMessages();

    for (OrderMessage msg : failed) {
        try {
            rocketMQTemplate.convertAndSend("INVENTORY_TOPIC", msg.getMsgBody());
            msg.setStatus("SENT");
            msg.setRetryCount(0); // 重置重试计数器
            messageService.update(msg);
        } catch (Exception e) {
            log.error("重试失败的消息依旧失败: {}", e.getMessage());
        }
    }
}

这样即使某一时刻网络抖动或库存服务不可用,也能通过不断重试来最终完成整个流程。


踩坑经验分享:那些年我掉过的坑

虽然这个方案最终跑起来了,但我们也不是一蹴而就的,期间遇到了很多坑,现在回头看看特别值得分享。

1. “双写”问题导致数据不一致

初期,我们在消息发送失败时没有及时标记状态,导致某些情况下消息重复发送或者漏发。例如:

  • 发送成功但没更新状态 -> 下次调度再次发送同一消息
  • 数据库异常导致事务回滚 -> 消息没发但订单已经生成

后来我们引入“乐观锁”更新状态字段的方式,确保只有当前状态是 WAIT 的才会被更新为 SENT。

2. 重试太多反而引起雪崩

一开始我们的最大重试次数设得太高(比如10次),在系统高峰期遇到消息积压,短时间内大量重试请求打到库存服务,直接把库存服务打挂了……

后来调整为最多重试3次,然后进入人工介入流程(比如告警 + 手动补单)。

3. 消费者幂等处理不到位

由于消息可能重复,如果库存服务未做好幂等控制,会导致重复扣减库存。我们在库存服务中增加了去重表(redis缓存最近一段时间内的订单号)来防止重复消费。

4. 日志监控缺失,排查困难

最初消息失败的原因只能靠日志去分析,效率很低。后来我们增加了监控看板,将消息状态流转可视化,大大提升了排障效率。


效果总结:上线后的收益和表现

微服务架构示意图-1

这个方案上线后,我们跟踪了一段时间的订单链路和库存变动:

  • 几乎杜绝了“订单创建但库存未扣减”的现象
  • 系统吞吐量相比以前提高了约30%,因为库存扣减不再阻塞主流程
  • 即使库存服务短暂不可用,也能通过消息队列和重试机制完成最终一致

运维同学反馈说这套机制上线后生产环境的异常订单数量大幅下降,客服工单减少了70%左右,老板满意,我也松了一口气 😅


经验分享:给你的几点建议

如果你也在面临分布式事务的问题,以下是我这几年踩坑后总结出来的几点建议:

1. 别一开始就追求“强一致性”

很多时候我们以为必须用 TCC 来保证强一致,其实对于多数业务场景(尤其是电商类),最终一致性 + 一定的补偿机制是可以接受的,而且实施起来更容易。

2. 本地事务表是个好东西

不要小看那个小小的事务消息表,它其实是很多异步处理的基础。在不能使用消息事务的情况下,本地事务表可以很好地承担“桥梁”角色。

3. 幂等性是分布式系统设计的生命线之一

不管你是用 TCC 还是消息队列,都要记得给每个接口加上幂等校验(比如唯一订单 ID 或 UUID)。否则一旦消息重复,后果很严重。

4. 监控比你想象的重要得多

一个优秀的分布式事务方案,必须配套一套完善的监控体系,比如:

  • 消息状态统计图
  • 失败消息告警
  • 自动补偿失败提示

没有监控的系统就像瞎子开车。

5. 技术方案要匹配业务场景

并不是所有业务都需要复杂的分布式事务方案。比如有些系统可以通过合并 DB 或服务边界的方式规避跨库事务,也是一种解法。技术方案永远服务于业务目标。


结语:别怕分布式事务,理解本质才是王道

回头看这次分布式事务的实践,我觉得最大的收获不是技术方案本身,而是对分布式系统中“一致性”这个命题有了更深的理解。

事务的本质,就是“状态的变化过程是否可控”。而所谓的“分布式事务”,不过是当你的变化跨越了多个服务、数据库甚至数据中心之后,依然能够维持这种可控的一种努力。

这条路很长,但我相信,只要我们理解了业务、掌握了方法、愿意去踩坑,就能越走越稳。

希望这篇文章能帮到你。如果你有类似的实战案例,欢迎留言交流 🤝

评论 0

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