分布式事务的那些事儿:踩过的坑、走过的路都在这里了

精准太阳
2025-06-22 00:56
阅读 220

引子:为啥要写这么一篇?

引子:为啥要写这么一篇?

我是一个五年经验的后端工程师,从最早在创业公司折腾单体应用,到后来进入中大型互联网企业负责核心业务模块的设计与开发,一路走来对“分布式系统”的复杂性和挑战深有体会。特别是分布式事务这玩意儿,刚开始我以为它就是“多个库一起commit/rollback”那么简单,结果实际工作中被它狠狠上了一课。

这篇东西不是教科书式的技术论文,也不是高大上的架构设计白皮书,而是想以一个开发者的真实经历为出发点,聊聊我在项目中碰到的分布式事务问题、当时的应对策略、踩过的坑和最终怎么把它搞定的心得。

这篇文章会结合我的一个真实项目场景展开——电商平台订单支付模块的重构。如果你也经历过类似的系统改造或服务拆分,相信你会找到共鸣。


项目背景:从单体到微服务的痛

项目背景:从单体到微服务的痛

去年我们公司决定把原来基于Spring MVC的单体电商系统逐步拆分成微服务架构。其中最核心的几个模块包括:

  • 用户中心(用户管理)
  • 商品服务(商品信息)
  • 库存服务(库存扣减)
  • 支付服务(第三方对接)
  • 订单服务(下单、支付状态变更等)

这次的重点是订单支付流程,涉及多个服务之间的协同操作:

  1. 创建订单 -> 写入订单表
  2. 扣除用户积分(优惠券、积分抵现)
  3. 扣减商品库存
  4. 调用外部支付系统完成支付动作
  5. 修改订单状态为已支付

这些操作分散在不同服务中,数据也在各自的数据库里。而我们最开始只用了最简单粗暴的做法:先下订单,再调其他服务异步处理后续步骤。这样做最大的问题是:如果其中一个环节失败,整个流程就很难回滚了

举个例子:

用户提交订单时系统正常创建了订单,然后调用库存服务扣减库存成功,但调用支付接口失败。这时候库存已经扣了,用户没支付成功,系统又没有机制自动恢复库存,这就导致了库存不准的问题。

这个问题在测试环境还可以靠人工修复,但到了生产环境,数据量一上来,根本没法手工处理。于是,我们不得不正视一个问题:我们需要一个可靠的方式来保证这一系列跨服务操作要么全部成功,要么全部失败


真实挑战:面对分布式的无力感

一开始我们在团队内部讨论过几个方案:

  • 本地事务 + 补偿机制:先做一次主操作,失败就回退前面的操作。
  • 引入TCC模式:尝试搞出Try - Confirm - Cancel三个阶段的动作。
  • 使用Saga模式:通过一系列本地事务加补偿操作组合实现。
  • 引入Seata这类开源分布式事务框架:看看有没有成熟的组件能直接用。

这些听起来都挺靠谱的,但在实际落地时才发现每种方案都有各自的适用场景和局限性。

我们遇到的主要难点包括:

  1. 每个服务的数据模型差异很大,有的服务压根不支持“反向操作”;
  2. 服务之间存在依赖关系,补偿机制复杂
  3. 并发环境下幂等性难以保障
  4. 事务跨度长、链路复杂,日志跟踪和调试成本高

最后我们决定采用一种混合方式:对于强一致性要求高的环节使用TCC,对于弱一致性要求的使用异步补偿机制


解决方案:TCC + 补偿机制的混搭打法

技术选型:为什么选TCC?

我们之所以选择TCC而不是Saga或本地事务补偿,是因为:

  • TCC可以在关键路径上提供比较可控的一致性;
  • 在订单支付这种金额敏感的操作中,不能容忍中间状态长时间悬挂(如Saga可能出现的状态等待);
  • 框架层面可以借助像ByteTCC或者Lcn这类轻量级TCC实现来快速搭建。

不过由于我们当时对TCC理解还不到位,直接引用了一个GitHub上的轻量级TCC框架(现已弃用),结果踩了不少坑。

我们是怎么设计的?

1. 对关键操作封装TCC接口

我们为每一个需要参与分布式事务的服务定义了三个方法:

public interface InventoryService {
    boolean prepare(String businessKey);  // Try阶段:预占库存
    boolean commit(String businessKey);   // Confirm阶段:正式扣减
    boolean rollback(String businessKey); // Cancel阶段:释放预占库存
}

所有服务在接入的时候都需要实现这三个接口。

2. 主流程中使用TCC协调器

我们使用了一个轻量级的TCC调度器来协调各个服务间的调用顺序,并维护事务上下文。大致逻辑如下:

try {
    // 开启事务
    tcContext.begin();
    
    // Step 1: 下单并锁定积分(用户服务)
    userPointService.prepare(orderId);
    
    // Step 2: 预占库存(库存服务)
    inventoryService.prepare(orderId);

    // Step 3: 创建订单(订单服务)
    orderService.create(orderId);

    // Step 4: 发起支付请求(支付服务)
    paymentService.charge(orderId);

    // 提交事务
    tcContext.commit();

} catch (Exception e) {
    log.error("分布式事务执行异常", e);
    // 回滚整个事务
    tcContext.rollback();
}

当然这段代码只是示意,实际的实现远比这个复杂得多。

3. 同步 vs 异步 + 定时补偿

并不是所有的操作都需要走TCC。比如:

  • 支付完成后发送通知邮件、更新用户统计信息等属于弱一致性的操作;
  • 这些我们采用了本地事务+消息队列+定时任务的组合拳。

例如,支付成功后将事件发往MQ,由下游服务消费后异步处理;同时后台还有一个定时任务定期检查未完成的任务进行补偿。


踩坑记:那些年我们一起翻过的车

坑1:幂等性没做好,补偿变成了二次错误

曾经有一次线上故障:用户一笔订单居然扣了两次库存。排查发现是因为Cancel操作重复执行了,而我们的rollback并没有检查是否已经释放过了。

教训:任何涉及到Cancel或Confirm的操作都要有幂等判断。建议:

  • 使用唯一标识符(businessKey) + 状态字段(如:已提交 / 已取消);
  • 或者用Redis记录当前事务ID的状态,防止重复提交。

坑2:TCC性能拖垮系统吞吐量

上线初期我们发现系统QPS严重下降,高峰期甚至出现了超时和服务雪崩的现象。

分析发现是TCC协调器每次都要串行调用多个服务的prepare、commit接口,并且中间还要持久化事务日志。

优化手段

  • 将TCC事务控制粒度缩小,非关键路径尽量用异步;
  • 引入缓存机制减少Prepare阶段不必要的数据库访问;
  • 异步刷盘事务日志,牺牲一定的可靠性换性能;
  • 升级底层RPC框架,改为Netty传输 + 异步回调机制。

坑3:事务链过长导致“悬挂事务”

所谓“悬挂事务”,指的是某个节点卡住了,其他节点都等着,结果整个系统阻塞。

比如:某个服务因网络问题未响应,整个事务一直处于waiting状态。

解决方案

  • 设置合理的超时时间;
  • 对于长时间处于挂起状态的事务,引入定时检查机制,主动触发回滚;
  • 关键服务要有降级策略,确保系统整体可用性。

一些实用代码片段分享

以下是我们事务上下文管理类的一个简化版:

@Component
public class TcTransactionContext {

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

    public void begin() {
        String txId = UUID.randomUUID().toString();
        context.set(txId);
        // 存储到线程上下文中
    }

    public void commit() throws Exception {
        String txId = context.get();
        List<Participant> participants = TransactionManager.getParticipants(txId);
        for (Participant p : participants) {
            p.confirm(); // 调用confirm
        }
    }

    public void rollback() {
        String txId = context.get();
        List<Participant> participants = TransactionManager.getParticipants(txId);
        for (Participant p : participants) {
            p.cancel(); // 调用cancel
        }
    }


![系统架构设计图-1](https://code-guide.oss.shanghai.autogptai.club/common/file/download?name=date2025062200/141fc4a2-2466-4e55-bc37-20cfeea79e91.jpg)


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

服务接口实现示例:

@Override
public boolean prepare(String businessKey) {
    // 先冻结库存,记录冻结状态
    return inventoryDao.freeze(businessKey);
}

@Override
public boolean commit(String businessKey) {
    return inventoryDao.deduct(businessKey);
}

@Override
public boolean rollback(String businessKey) {
    return inventoryDao.release(businessKey);
}

当然实际的DAO层还需要加入乐观锁、重试机制以及幂等判断,才能真正落地。


效果评估:系统稳定性显著提升

经过半年多的打磨和优化,新版本的支付流程上线后效果非常不错:

指标 上线前 上线后
支付成功率 97.2% 99.8%
数据一致性问题 每周20+ 几乎为零
平均TPS 800左右 提升至2500+
事务平均耗时 280ms 降低至150ms以内

更关键的是,运维同学反馈说因为数据不一致导致的人工干预几乎消失,系统健壮性大大增强。


经验总结:给后端同学的一些建议

如果你也在做类似的事情,以下是我根据实践经验总结的一些小建议:

✅ 优先考虑业务场景的容错能力

并不是所有场景都需要用TCC或者其他严格一致性方案。比如日志、通知、打点等弱一致性业务完全可以用“最终一致+补偿”来解决。

✅ 不要迷信框架,一定要理解底层原理

我当时就是因为看到一个TCC框架文档看起来简单易懂,直接引入进来用,结果在线上出了不少问题。后来自己动手改了一套简化的TCC内核才稳定下来。

✅ 异步补偿机制也要设计好失败兜底方案

比如MQ发送失败怎么办?下游服务宕机怎么办?定时任务挂了会不会数据丢失?这些问题都要提前想到并留有回旋余地。

✅ 日志监控要到位

事务上下文ID必须贯穿整个调用链,每个服务的日志中都要带上txId,这样排障时才能快速定位问题发生在哪一步。

✅ 权衡性能与一致性

在分布式系统中,“鱼和熊掌不可兼得”的情况太常见了。你要清楚你的业务到底能不能接受一定概率的数据“延迟一致”。


结语:别怕复杂的系统,关键是要有章法

分布式事务这玩意儿说实话真的挺难搞的。尤其是当系统规模一大,服务数量一多,各种边界条件和异常情况就层出不穷。

但从我的经历来看,只要你能做到以下几点:

  • 明确需求,合理划分一致性边界;
  • 掌握常用技术方案的核心思想;
  • 设计良好的降级、补偿和监控机制;
  • 有足够的耐心去踩坑、填坑、写文档;

那么再复杂的系统也可以慢慢理顺,最终形成一套属于自己的“分布式事务治理体系”。

希望我的实战经验对你有所帮助。也欢迎留言交流你们在实际项目中遇到的分布式事务难题,咱们一起踩坑、一起成长 🚀


本文作者:一位正在搬砖路上坚持输出的Java程序员,走过路过不要吝啬点赞评论~

评论 0

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