一次真实项目中的分布式事务实践:从踩坑到落地的全过程

小镇程序员
2025-06-12 18:20
阅读 783

引言:为什么我们需要关注分布式事务?

引言:为什么我们需要关注分布式事务?

记得我在上一家公司接手一个电商订单系统时,第一次真正面对“分布式事务”这个概念。当时我们正在做微服务拆分,原本单体应用中简单的下单流程,拆分成商品、库存、订单、支付等多个独立服务后,问题来了——用户提交订单后,扣减库存失败怎么办?支付成功后订单状态却没更新怎么办?
这些看似简单的问题背后,其实都离不开“一致性”的核心需求。而这种多个服务之间保证数据一致性的需求,正是分布式事务要解决的核心问题。

这篇文章就是想分享我在这次项目中对分布式事务的理解与实践过程。我会用具体的场景、遇到的坑和解决方案来展开讲述,希望对同样在做微服务架构演进的同学有些帮助。


项目背景:一次典型的电商业务拆分

微服务架构示意图-1

项目背景:一次典型的电商业务拆分

我们原来的电商系统是一个基于Spring Boot的单体架构,随着业务增长逐渐臃肿,决定进行微服务化改造。新架构将系统拆分为以下几个核心模块:

  • 订单中心(order-center):负责创建订单、处理订单状态
  • 商品中心(product-center):管理商品信息
  • 库存中心(inventory-center):控制库存数量
  • 支付中心(payment-center):对接第三方支付渠道

在这个背景下,一个完整的下单流程需要调用四个服务:

  1. 用户点击下单 → 创建订单
  2. 扣减库存
  3. 发起支付
  4. 支付完成后更新订单状态为已支付

看起来流程很清晰,但一旦某一步失败,就会带来数据不一致的风险,比如:

  • 库存扣减了,但订单没有生成
  • 支付成功了,但订单状态未更新
  • 服务间网络超时导致重复操作

这些问题推动我们必须引入一套可靠的分布式事务机制


遇到的挑战:微服务环境下的一致性难题

遇到的挑战:微服务环境下的一致性难题

在实际开发中,我们遇到了几个典型问题:

1. 接口幂等性设计不足

一开始,我们在某些接口没有做幂等性处理,例如订单创建接口被重复调用,最终生成了两条相同的订单。

2. 事务边界混乱

服务之间的调用顺序、异常捕获方式混乱,导致部分操作已经执行完成,后续操作失败后无法回滚,出现脏数据。

3. 分布式环境下的资源锁定难

为了防止超卖,在下订单时需要预占库存。但在高并发下,如果多个请求同时进入系统,很容易出现并发竞争导致库存扣错或少扣等问题。

4. 系统容错能力弱

最初方案依赖同步RPC调用链路,一个环节出错就直接抛错给前端,用户体验差,而且容易引发雪崩效应。


解决方案:基于消息队列 + 本地事务表 + 最终一致性

经过评估不同技术方案(如两阶段提交、Seata、TCC),我们最终选择了本地事务表+消息队列+补偿任务的方式作为主线方案。这套方案更适合我们的业务特点和团队成熟度,下面详细介绍。

整体思路

我们将整个下单流程拆解为多个可异步执行的动作,并通过事件驱动的方式推进流程,确保每个动作之间最终保持一致性。

具体步骤如下:

  1. 订单服务创建订单记录并写入本地事务表
  2. 发送“扣减库存”事件消息到MQ
  3. 库存服务消费消息,尝试扣减库存
    • 成功:发送“支付确认”事件
    • 失败:触发补偿任务或重试
  4. 支付服务接收支付确认事件,启动支付流程
  5. 支付完成后回调通知订单服务更新状态

整个过程中,关键点在于:

  • 所有变更都在本地事务表内先落盘
  • MQ用于异步通知其他服务参与处理
  • 每个服务自行保证自己的本地事务逻辑
  • 异常情况由补偿任务兜底处理

核心实现:本地事务表 + RocketMQ 的结合使用

这里以“订单创建 + 扣减库存”为例,展示关键代码逻辑。

本地事务表结构

CREATE TABLE `order_transaction_log` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
  `order_id` VARCHAR(64) NOT NULL,
  `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型(create_order, deduct_inventory...)',
  `status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending / success / failed',
  `content` JSON,
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

创建订单并发送消息(伪代码)

@Transactional
public void createOrder(OrderDTO orderDTO) {
    // 1. 插入订单记录
    Order order = new Order();
    order.setId(UUID.randomUUID().toString());
    order.setStatus("created");
    orderMapper.insert(order);

    // 2. 写入事务日志
    TransactionLog log = new TransactionLog();
    log.setOrderId(order.getId());
    log.setBizType("create_order");
    log.setStatus("success");
    log.setContent(JSONObject.toJSONBytes(orderDTO));
    transactionLogMapper.insert(log);

    // 3. 发送“需要扣减库存”的MQ消息(同一个事务)
    Message message = new Message("DEDUCT_INVENTORY_TOPIC", "", JSONObject.toJSONBytes(order));
    rocketMQTemplate.convertAndSend(message);
}

库存服务监听消息并处理

@RocketMQMessageListener(topic = "DEDUCT_INVENTORY_TOPIC", consumerGroup = "INVENTORY_CONSUMER_GROUP")
public class InventoryConsumer implements RocketMQListener<Order> {

    @Override
    public void onMessage(Order order) {
        try {
            boolean result = inventoryService.deductInventory(order.getProductId(), order.getProductCount());

            if (result) {
                // 更新事务日志状态为成功
                transactionLogService.updateStatus(order.getId(), "deduct_inventory", "success");

                // 发送下一步事件
                paymentEventPublisher.publish(new PaymentConfirmEvent(order));
            } else {
                // 更新为失败状态,等待补偿任务
                transactionLogService.updateStatus(order.getId(), "deduct_inventory", "failed");
            }
        } catch (Exception e) {
            log.error("扣减库存失败", e);
            transactionLogService.updateStatus(order.getId(), "deduct_inventory", "failed");
        }
    }
}

补偿任务兜底处理失败事务

我们还部署了一个定时任务,定期扫描状态为 failedpending 的事务日志,重新发起相关操作,例如重新发消息或者调用远程服务。

@Component
public class CompensateJob {

    @Scheduled(cron = "0 */1 * * * ?") // 每分钟执行一次
    public void compensateFailedTransactions() {
        List<TransactionLog> logs = transactionLogService.listByStatus(Arrays.asList("pending", "failed"));

        for (TransactionLog log : logs) {
            if ("deduct_inventory".equals(log.getBizType())) {
                // 重新发MQ消息
                Order order = parseFromJson(log.getContent());
                Message message = new Message("DEDUCT_INVENTORY_TOPIC", "", JSONObject.toJSONBytes(order));
                rocketMQTemplate.convertAndSend(message);
            }
        }
    }
}

踩坑经验:那些只有经历过才知道的细节

坑一:MQ投递不一定可靠

我们最开始直接在try-catch外发MQ,结果有一次数据库插入成功但MQ投递失败,导致事务日志里记录的是成功,但实际上下游服务压根没收到消息。

解决方法
使用本地事务表 + 同步发MQ的模式,将MQ发消息封装进本地事务,保证日志和消息一起落库或一起回滚。

坑二:消息重复消费没有做幂等处理

我们最初只做了订单去重,但支付接口没有考虑幂等,导致同一笔订单可能被多次支付。

解决方法
每个事件增加唯一业务ID,例如businessId=orderId:pay, 消费前先检查是否已处理过该ID,避免重复操作。

坑三:MQ消费失败无限重试打垮数据库

补偿任务如果没有限制重试次数,可能导致某个服务故障时不断重试,把数据库压爆。

解决方法
设置最大重试次数,超过后标记为“人工介入”。引入延迟队列或重试队列,减少频繁冲击。


实施效果:稳定性和扩展性双提升

上线后,系统的整体表现得到了明显改善:

  • 下单成功率提升了98%
  • 交易流程更加健壮,能自动恢复异常情况
  • 微服务之间耦合度降低,扩展更方便
  • 容灾能力增强,即使某一环节挂掉也能靠补偿机制兜底

特别是在大促期间,整套机制经受住了流量压力测试,表现出良好的稳定性。


经验总结:关于分布式事务的一些思考

✅ 什么时候才需要强一致性?

很多同学一上来就想用TCC、XA这类强一致性方案。其实很多时候,最终一致性已经足够满足业务需求。比如库存、订单、支付这类场景,并不要求实时准确,只要在几分钟内能够修正即可。

建议:

  • 先评估业务容忍度
  • 对于非金融级场景,优先考虑轻量方案(如MQ+事务日志)
  • 对关键核心流程再采用TCC等方案

✅ 技术选型要匹配团队能力

我们曾经尝试引入Seata,但由于事务上下文传递复杂、配置繁琐,最终还是放弃了。技术方案必须和团队成熟度匹配,别为了“高大上”选择自己驾驭不了的技术。

✅ 做好监控与可观测性

我们后来加上了:

  • 事务日志的状态监控
  • MQ堆积报警
  • 补偿任务失败统计

这些对于发现线上问题非常有帮助。


结语:分布式事务不是魔法,是工程的艺术

回顾这段经历,我最大的感触是:真正的分布式事务并不是某种技术框架,而是一种设计思维。它要求你清楚地知道哪些数据是必须一致的,哪些可以允许短暂不一致;也考验你在性能、可靠性、复杂度之间的平衡取舍。

如果你也在做微服务拆分,不妨从当前业务中最关键的一条链路入手,逐步引入类似的方案。不用追求一步到位,而是随着系统演进而不断优化。

希望这篇分享能给你一些启发,少走些弯路。如有疑问,欢迎留言交流!


作者:张亮,某互联网公司资深架构师,主导多个大型系统微服务重构与稳定性保障项目,热爱技术与写作,公众号《TechGrow》主理人。

评论 0

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