分布式事务解决方案:一个老开发的真实踩坑经历

朱浩然_算法
2025-06-22 07:08
阅读 570

引言:分布式系统带来的“甜蜜烦恼”

引言:分布式系统带来的“甜蜜烦恼”

作为一名在后端摸爬滚打多年的老兵,我经历过单体架构的简单粗暴,也见证了微服务架构的崛起和普及。如今再回到大型系统中,最大的挑战之一就是——分布式事务

记得几年前,我在一家金融科技公司负责交易系统的重构,核心业务是用户的资金变动、积分兑换以及订单结算等。为了提升系统的可维护性和扩展性,我们采用了Spring Cloud + Dubbo的微服务架构,各个服务通过RPC调用互相协作完成一次完整的业务流程。

然而正是这种松耦合的设计,带来了不少头疼的问题,尤其是在数据一致性方面——比如用户下单的时候,库存服务扣减库存成功,但订单服务保存订单失败,或者支付服务处理完付款,积分服务却挂了……

这个时候,你就会明白什么叫:分布式事务不解决,系统就离出事不远了。

这篇文章就来聊聊我是如何一步步解决这个问题的,从最初的妥协方案到最终落地的可靠机制,过程充满挣扎与教训,希望能给正在这条路上的你一些启发。


项目背景:一场高并发下的数据一致性危机

项目背景:一场高并发下的数据一致性危机

我们的系统是典型的电商类金融平台,主要功能包括:

  • 用户下单(订单服务)
  • 扣减库存(库存服务)
  • 支付接口调用(支付服务)
  • 积分变更(积分服务)

这些服务部署在不同的节点上,使用的是 Spring Boot + Dubbo 构建的 RPC 调用链路。

一开始我们为了追求上线速度,采用了最简单的“伪本地事务”方式:每个服务内部加事务,外层靠try-catch重试兜底。例如:

// 简化后的伪代码示例
public void placeOrder(OrderDTO order) {
    try {
        orderService.createOrder(order);
        inventoryService.decreaseStock(order.getSkuId(), order.getCount());
        paymentService.chargeUser(order.getUserId(), order.getAmount());
        pointService.addPoint(order.getUserId(), order.getPoints());
    } catch (Exception e) {
        // 出现异常,只能记录日志,人工介入补偿
    }
}

这看似能处理大部分情况,但在某次促销活动中,问题集中爆发:有些订单创建失败但积分已经增加、库存被扣减但订单未生成……一时间运营团队焦头烂额,投诉电话不断。

我们这才意识到:必须引入一套完整的分布式事务机制,才能真正保证一致性。


挑战:不是没技术选型,而是太多选择让人迷茫

挑战:不是没技术选型,而是太多选择让人迷茫

当时摆在我们面前的选择其实并不少,但每种都带着它的“副作用”:

  1. 2PC/XA方案(Seata)

    • 优点:强一致性,支持回滚
    • 缺点:性能差,锁资源,容易成为瓶颈;对数据库版本、驱动兼容要求高
  2. TCC(Try-Confirm-Cancel)

    • 优点:灵活控制资源锁定、适用于复杂业务场景
    • 缺点:需要为每一个服务编写三个阶段的方法,业务逻辑变得复杂,实现成本高
  3. SAGA模式

    • 优点:无需全局锁,适合长事务
    • 缺点:补偿逻辑复杂,一旦某个服务无法撤销操作就可能出错
  4. 本地事务表+消息队列(基于MQ异步补偿)

    • 优点:解耦性强、吞吐量高
    • 缺点:不能实时保证一致性,延迟补偿可能引起状态混乱
  5. Event Sourcing / CQRS

    • 不是我们当时的首选方案,复杂度太高,学习曲线陡峭

我们尝试过几种方式,最后结合自身业务特点,选择了基于 TCC 模式的自研框架 + 最终一致性校验机制


解决思路:TCC 的实践之路

解决思路:TCC 的实践之路

初识 TCC

TCC 的核心思想可以理解为三个步骤:

  1. Try 阶段:业务资源检查 & 锁定(如预扣库存)
  2. Confirm 阶段:执行真正的业务操作(如正式扣库存、生成订单)
  3. Cancel 阶段:释放已锁定的资源(如还原库存)

TCC 要求每一个服务都提供这三个方法,并且整个协调由“事务协调器”来掌控。

我们最初考虑用开源的 TCC 框架,比如 Hmily、ByteTCC 等,但由于业务较为复杂,定制化较高,最终决定基于 Spring AOP 自己写一套简化版的 TCC 流程引擎。

设计目标

  • 实现基本的 Try-Confirm-Cancel 三段式流程
  • 支持事务ID透传,方便日志追踪
  • 事务日志持久化,用于后续补偿或核对
  • 支持手动/自动补偿机制

整体结构

[Transaction Coordinator]
       |
       v
[Try 阶段] -> [各服务Try方法]
       |
       v
[Confirm or Cancel] -> 根据Try结果决定是否执行 Confirm 或 Cancel
       |
       v
[事务落库 & 补偿队列入队]

我们设计了一张事务日志表,用来跟踪每个事务的状态:

CREATE TABLE tcc_transaction_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    transaction_id VARCHAR(64) NOT NULL,
    service_name VARCHAR(100),
    status ENUM('TRYING', 'CONFIRMED', 'CANCELLED'),
    create_time DATETIME,
    update_time DATETIME
);

每当一个事务开始,我们就记录一条 TRYING 记录;执行完成后更新状态;如果某一步失败,则触发 Cancel 并记录取消动作。

示例:订单服务中的 Try 方法

@Transactional
public boolean tryCreateOrder(OrderDTO dto) {
    Order order = new Order();
    order.setUserId(dto.getUserId());
    order.setStatus("CREATED");
    orderMapper.insert(order);

    // 注册事务事件日志
    logService.recordTry(transactionId, "order-service", true);

    return true;
}

其他服务类似,比如库存服务则是在 Try 阶段“冻结”库存,而不是直接扣除。


关键代码片段分享

这里给出几个关键部分的实现,供参考。

事务上下文管理器(简化版)

@Component
public class TccContextManager {

    private ThreadLocal<String> context = new ThreadLocal<>();

    public void set(String txId) {
        context.set(txId);
    }

    public String get() {
        return context.get();
    }

    public void clear() {
        context.remove();
    }
}

AOP拦截器控制事务流程

@Aspect
@Component
public class TccTransactionAspect {

    @Autowired
    private TransactionCoordinator coordinator;

    @Around("@annotation(tcc)")
    public Object handleTcc(ProceedingJoinPoint pjp, Tcc tcc) throws Throwable {
        String txId = UUID.randomUUID().toString();
        TccContextHolder.setTxId(txId);

        try {
            // 执行 Try 阶段
            Object result = pjp.proceed();

            // 执行 Confirm
            if (!(boolean) result) throw new RuntimeException("Try failed");

            coordinator.confirm(txId);
            return result;
        } catch (Throwable e) {
            // 执行 Cancel
            coordinator.cancel(txId);
            throw e;
        } finally {
            TccContextHolder.clear();
        }
    }
}

事务协调器主流程

public class TransactionCoordinator {

    public void confirm(String txId) {
        List<TransactionLog> logs = logService.findByTxId(txId);
        for (TransactionLog log : logs) {
            ServiceInvoker.invokeConfirm(log.getServiceName(), txId);
        }
    }

    public void cancel(String txId) {
        List<TransactionLog> logs = logService.findByTxId(txId);
        for (TransactionLog log : logs) {
            ServiceInvoker.invokeCancel(log.getServiceName(), txId);
        }
    }
}

当然,这只是简化模型,真实项目中还需要考虑幂等、重试、补偿任务调度等问题。


踩过的那些坑:都是血泪教训

坑一:Cancel 失败怎么办?

我们在测试阶段发现,Cancel 方法有时候会抛异常,甚至服务不可用,导致事务状态处于一种“中间态”。为此,我们引入了一个定时补偿任务,每天凌晨跑一遍所有“悬而未决”的事务日志,发起 Cancel 补偿。

坑二:TCC 的幂等问题

由于网络原因,同一个 Confirm/Cancle 请求可能会重复到达。如果不做幂等判断,会导致库存多扣、积分多加等问题。因此我们在每个服务中都加了幂等校验:

@Override
public void confirmInventory(String txId) {
    if (redisTemplate.hasKey("confirm:" + txId)) {
        return; // 已经执行过
    }

    // 正式扣减库存
    inventoryMapper.reduceStockByTxId(txId);

    redisTemplate.opsForValue().set("confirm:" + txId, "done", 1, TimeUnit.DAYS);
}

坑三:跨服务事务 ID 传递

原本我们通过 ThreadLocal 保存事务 ID,但在 Dubbo 调用中线程切换会导致丢失,后来改用 RpcContext 来进行参数透传:

RpcContext.getContext().setAttachment("tx_id", txId);

并在消费者端拦截器中读取这个值并设置进 ThreadLocal。

坑四:性能瓶颈明显

尽管 TCC 性能比 2PC 强很多,但在高峰时期还是会拖慢整体响应。于是我们做了以下优化:

  • Try 阶段尽量轻量级操作,不做复杂查询或写入
  • 尽可能合并多个服务的调用为批量操作
  • 最终一致性下放至 MQ 或定时任务处理

效果总结:终于稳住了

自从上线这套基于 TCC 的分布式事务机制后,系统稳定性大大提升,具体收益如下:

指标 上线前 上线后
交易失败率 1.2% < 0.05%
人工补单次数 每天几十次 接近零
数据一致性故障 每周数起 几乎没有

虽然开发成本上升了不少,但换来的是系统的健壮性和运维的省心,尤其是节假日大促时再也不怕流量洪峰了。


经验分享:过来人的忠告

如果你也在考虑引入分布式事务,请记住以下几点:

1. 不要盲目迷信理论框架

分布式事务没有银弹。不要一听别人说“XA 是强一致”,就一头扎进去。实际应用中,你需要结合业务场景来做权衡。比如金融转账更适合 TCC,而日志写入这类操作更适合最终一致性。

2. 先做好基础设施建设

  • 统一事务ID传递机制
  • 完善的日志追踪体系
  • 事务补偿机制
  • 幂等支持

否则你永远都无法定位到底哪一个环节出错了。

3. 把一致性分级对待

并不是所有业务都要求严格的一致性。比如:

  • 下单、支付这些关键路径要用强一致性
  • 日志记录、数据分析这些可以用异步最终一致性搞定

合理地将业务分层,能有效降低系统复杂度。

4. 写好 Cancel 是最难的部分

很多时候我们都能把 Confirm 写清楚,但 Cancel 反而成了最不确定的环节。建议在业务设计初期就要考虑“反向操作”的可行性。

5. 补偿机制要作为标配存在

无论是定时任务还是消息队列,一定要有一套兜底机制。就像我之前做的那样,每天扫一遍事务日志,及时修复脏数据。

6. 监控必不可少

我们后来接入了 Prometheus + Grafana,监控事务成功率、Cancel 次数、悬挂事务数量等指标,做到心中有数。


写在最后:分布式事务这条路,没人能绕过去

说实话,写这篇文章的时候我也在回忆当初那个焦头烂额的自己。那个时候真想放弃,觉得不如重回单体世界算了。但现在回想起来,正是那段时间的折腾,让我真正理解了什么叫“系统一致性”,什么叫“工程思维”。

分布式事务从来不是一件轻松的事,但它却是迈向复杂系统必经的一道坎。希望今天的分享能帮助你少走弯路,走得更远。

如果你在实践中遇到任何疑问,欢迎留言交流。毕竟这条路,我们都在走着。😊


本文作者:一名坚持码农本色的全栈工程师,专注高并发系统设计与落地实战。
个人博客https://www.jianshu.com/u/yourprofile(如有)
GitHubhttps://github.com/yourusername(如有)
技术群组:欢迎关注我的公众号【TechMaster】,一起探讨一线开发经验与行业趋势。

评论 0

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