分布式事务的实战指南:从踩坑到破局

乐观锁玩家
2025-06-23 21:24
阅读 447

大家好,我是阿飞。我在一家中大型电商平台负责后端架构的设计与优化。从业这些年,我深刻体会到,分布式系统最难搞的问题之一就是分布式事务

今天我要分享的是我们在一次关键版本上线过程中遇到的真实案例——如何在一个微服务架构下保障订单、支付和库存之间的数据一致性。这不仅关乎用户体验,也直接影响着公司的营收与风控能力。

这篇文章将结合我的亲身经历,带大家走进真实的分布式事务场景,聊聊我们是怎么一步步从踩坑走向落地的。

一、背景与问题描述:一个看似简单的下单操作背后的风险

一、背景与问题描述:一个看似简单的下单操作背后的风险

负载均衡配置-2

项目背景其实并不复杂:我们要做一个支持秒杀功能的电商系统,系统拆分了多个微服务模块:

  • 订单服务(Order Service)
  • 库存服务(Inventory Service)
  • 支付服务(Payment Service)

整个流程大致如下:

  1. 用户下单;
  2. 订单服务创建订单;
  3. 库存服务减库存;
  4. 支付服务处理支付;
  5. 系统最终完成交易。

听起来很常见对吧?但在高并发场景下,比如秒杀活动开始的一分钟内,有几十万个用户同时下单,整个系统的压力就会暴增。

这时候我们发现了一个严重的问题:如果库存扣减成功,但支付失败或超时,系统会出现“脏数据”:库存减少了,订单却无法完成,造成用户投诉以及公司损失。

更糟的是,由于是异步调用,不同服务间的事务边界很难统一,传统的本地事务机制完全失效。也就是说,在这种情况下,我们需要解决跨服务的数据一致性问题。

于是,“分布式事务”这个老朋友又找上了门。

二、解决方案选型:在 TCC、SAGA、消息队列和 Seata 中做出权衡

二、解决方案选型:在 TCC、SAGA、消息队列和 Seata 中做出权衡

面对这个问题,我们一开始的想法是:能不能用最简单的方案搞定?毕竟开发时间有限,运维成本也要考虑。

我们先回顾了一下常见的几种分布式事务方案:

1. XA 协议(两阶段提交)

理论可行,但在微服务环境下几乎不推荐使用。因为网络延迟、锁资源长时间占用等问题会严重影响性能,尤其在电商这种高并发环境下根本不现实。

2. 消息队列 + 最终一致性

这是我们最初尝试的方向之一。我们想通过 RabbitMQ 或 Kafka 发送消息进行回调补偿。比如:

  • 创建订单 → 发送消息给库存服务减库存;
  • 减库存成功 → 回调支付服务发起支付;
  • 支付失败则回滚库存。

但这种方式最大的问题是:消息可能丢失或重复,且补偿逻辑需要非常严谨。 同时还涉及幂等性的设计,一旦某一步失败,重试机制就得上马。

3. TCC(Try-Confirm-Cancel)

TCC 是一种经典的补偿事务模式,适合业务逻辑明确、可拆分为 Try/Confirm/Cancel 的场景。

  • Try 阶段做资源预留(例如冻结库存);
  • Confirm 做真正执行(扣除库存);
  • Cancel 做回滚处理(解冻库存)。

优点很明显:性能较好、适用于高并发;缺点也很明显:开发工作量大,每一步都需要人工实现,业务代码入侵性强。

4. SAGA 模式

SAGA 类似于长事务,通过事件驱动方式逐个执行步骤,并自动触发逆向操作。虽然流程自然,但对异常恢复机制要求很高,尤其是中间状态难以追踪。

5. Seata(阿里开源的分布式事务框架)

我们后来重点研究了这套方案。Seata 提供了 AT 模式(基于全局锁和日志),可以自动拦截 SQL 并生成 undo_log 进行回滚。它对业务代码侵入性低,是我们当时比较倾向的选择。

不过我们团队当时没有太多 Seata 经验,直接上生产心里没底。所以我们最终采取了一个折中的策略:核心交易链路采用 TCC 模式,非关键路径采用消息+补偿的方式。

这样既保证了关键流程的可靠性,也兼顾了开发效率和维护成本。

三、具体实现:TCC 的落地方案详解

下面我来详细讲讲我们是如何在实际项目中落地 TCC 的。

以“创建订单”为入口,我们定义了一个分布式事务的主流程:

// 主事务发起者
public class OrderService {

    @TccActionLogInterceptor // 标记这是一个 TCC 事务入口
    public void createOrder(OrderDTO order) {
        try {
            // 调用库存服务预占库存(Try阶段)
            inventoryService.reserveStock(order.getProductId(), order.getCount());

            // 调用支付服务预授权(Try阶段)
            paymentService.authorizePayment(order.getUserId(), order.getAmount());

            // 创建订单记录
            orderRepository.save(order);

            // 提交确认所有服务(Confirm阶段)
            inventoryService.confirmStockDeduction();
            paymentService.confirmPayment();

        } catch (Exception e) {
            // 触发 Cancel 阶段
            inventoryService.cancelReservation();
            paymentService.cancelAuthorization();

            log.error("订单创建失败", e);
            throw e;
        }
    }
}

每个子服务(如库存服务和支付服务)都实现了完整的 tryconfirmcancel 接口,并通过注解和拦截器管理事务上下文。

关键点:

  • 幂等性设计:Cancel 操作必须幂等,防止重复调用导致反向出错;
  • 事务ID传递:每个接口都要携带全局事务ID,以便追踪和补偿;
  • 数据库设计调整
    • 库存表增加 reserved_stock 字段,专门用于 Try 阶段冻结;
    • 增加事务日志表用于记录每一步的操作状态,便于后续排查与补偿。

这里顺便提个小插曲:我们最早在测试 Cancel 逻辑时,忘记清空某个事务上下文,结果导致 Cancel 被反复调用多次,把库存给扣成了负数。那次事故后我们赶紧加上了幂等检查和事务隔离层。

补偿机制的构建:

除了标准的 Try/Confirm/Cancel 外,我们也引入了一套定时任务做“兜底补偿”。

每晚跑一次未完成事务扫描:

SELECT * FROM distributed_transaction WHERE status = 'pending' AND updated_at < now() - interval 10 minute

对于这类“悬而未决”的事务,我们会主动调用对应服务的查询接口判断状态是否已完成,否则强制 Cancel。

四、代码实践:真实场景下的关键代码片段

系统架构设计图-1

下面是一些具体的代码片段,方便你理解整体架构。

库存服务中的 TCC 实现示例:

@Service
public class InventoryServiceImpl implements InventoryService {

    @Override
    public boolean reserveStock(String productId, int count) {
        // Try:冻结库存
        return inventoryDao.freeze(productId, count);
    }

    @Override
    public boolean confirmStockDeduction(String transactionId) {
        // Confirm:正式扣除库存
        return inventoryDao.deduct(transactionId);
    }

    @Override
    public boolean cancelReservation(String transactionId) {
        // Cancel:释放被冻结的库存
        return inventoryDao.release(transactionId);
    }
}

数据库表结构简化示意:

CREATE TABLE inventory (
    product_id VARCHAR(36) PRIMARY KEY,
    total_stock INT NOT NULL,
    reserved_stock INT DEFAULT 0 -- 冻结的库存
);

-- 每笔事务单独记录
CREATE TABLE transaction_log (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    transaction_id VARCHAR(36),
    service_name VARCHAR(100),
    operation VARCHAR(50), -- 'reserve', 'deduct', 'release'
    status ENUM('success','failed','pending') DEFAULT 'pending',
    created_at DATETIME,
    updated_at DATETIME
);

这部分逻辑简单粗暴,但胜在稳定可控。我们还加上了 Redis 缓存和热点 key 监控,避免 DB 成为瓶颈。

五、那些年我们一起踩过的坑

说了这么多技术实现,还是得聊聊开发过程中的教训和经验:

1. 忘记 Cancel 的幂等性

有一次发布上线后,Cancel 方法因为某种原因被重复调用两次,导致原本已经解冻的库存再次被释放,变成了正数。虽然金额不大,但影响了库存统计,被产品组狂喷一顿。

教训: 所有的 Cancel 操作都要带上唯一标识(如 transaction_id),并在数据库中标记是否已执行过。

2. TCC 日志没写清楚

早期我们为了快速上线,很多日志信息都是 debug 级别的,出了问题根本查不到是谁干的。后来我们专门整理了 TCC 流程的 trace ID 并打印到日志里,结合 ELK 做实时监控,大大提升了排查效率。

3. 异常捕获不全

有一段时间订单总是莫名地卡住,后来才发现是一个支付失败的异常被吞掉了。TCC 的 Cancel 没有被正确触发,导致事务一直挂着。

建议: 所有抛出的异常都要做分类处理,特别是自定义异常要明确标记是否需要 Cancel,最好配合统一异常处理器。

4. 不同服务的响应时间差异大

有些服务响应快,有些慢。我们曾尝试同步调用,结果 TCC 的 Confirm 总是滞后,造成大量事务等待。

解决办法:

  • 使用 CompletableFuture 实现异步化处理;
  • 对于非核心依赖,引入断路器和降级策略。

六、效果总结:稳定性提升显著,收益远超预期

这套 TCC 方案上线后,我们的交易一致性有了极大保障,特别是在高并发场景下表现稳定。

主要收获包括:

  • 错误率下降 90%+:以前经常出现“库存扣完了但没人下单”的情况,现在基本绝迹;
  • 事务耗时平均控制在 150ms 左右:相比之前的长锁机制,性能提升明显;
  • 运维复杂度降低:通过统一的日志追踪、定时补偿任务和告警机制,出了问题也能快速定位;
  • 支撑了双十一促销:在高峰期单机 QPS 达到了 8k+,扛住了流量冲击。

当然,这套方案也不是万能的。像那种涉及多个外部系统的交易场景(比如对接银行网关),我们就只能退而求其次采用“消息+人工核对”的方式。

七、几点经验和建议给读者

如果你也在做分布式系统,尤其是在做电商业务,我建议你在做分布式事务选型时注意以下几点:

1. 优先评估业务场景

不是所有操作都需要强一致性。如果是非核心链路(比如积分变动、通知发送),完全可以走消息+补偿,没必要硬套 TCC。

2. 做好幂等性和重试机制

TCC 的 Cancel 很容易出问题,特别是网络波动大的时候。一定要做好幂等设计,不然很容易把自己绕进去。

3. 监控体系要及时跟上

光靠日志是不够的。我们后来接入了 Prometheus + Grafana 做可视化展示,还能设置阈值报警。这样出了问题能第一时间感知。

4. 不要迷信开源框架

虽然 Seata 很强大,但我们最终选择自己实现部分逻辑,也是考虑到灵活性和可维护性。有时候,适合自己的才是最好的。

5. 培养团队意识比技术更重要

分布式事务牵扯的服务多、角色多,沟通协调必不可少。我们建立了一个事务治理小组,定期复盘各个服务之间的调用关系,确保每一步都有人负责、有人追踪。

结语:技术的本质是解决问题

回头来看,分布式事务从来不是一个纯粹的技术问题,它是业务复杂性的集中体现。

我们不能指望一套方案打天下,而是要根据实际业务场景选择合适的模型,再通过不断的迭代和优化去完善。

如果你还在为分布式事务头疼,不妨从一个小流程开始试点,慢慢积累经验。不要怕踩坑,只要踩得有价值,总会看到曙光。

希望今天的分享能给大家带来一些启发,欢迎留言交流。

—— 阿飞,一位热爱编码的老程序员

评论 0

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