分布式事务解决方案:我的一次真实战斗记录

独立开发小站
2025-06-16 01:22
阅读 603

引言:从一个“看似简单的转账”开始

引言:从一个“看似简单的转账”开始

记得去年初,我在一个做金融系统的公司参与核心交易服务的开发。当时,我们正在重构一笔核心业务操作 —— 用户账户之间的资金划转。这个功能乍看之下非常简单:“A 用户转 100 元给 B 用户”,不就是两个数据库操作嘛?减 A 的余额、加 B 的余额?

但随着我们系统逐渐微服务化,这个看似简单的操作就被拆分到了不同的服务中:account-service 负责处理用户的余额变动,而 transfer-service 则作为发起交易的主控方。这个时候问题就来了:

如果 A 减款成功了,但 B 增款失败,那钱丢了怎么办?反之呢?

更头疼的是,我们引入了 Kafka 做消息队列来异步通知下游服务(比如短信通知、风控审计等)。这时候整个事务链就变得更长了。

我第一次真正意识到:这已经不是一个本地事务能解决的问题,而是典型的分布式事务场景。

遇到的挑战:事务边界变模糊、数据一致性难以保障

遇到的挑战:事务边界变模糊、数据一致性难以保障

我们当时遇到的具体问题包括:

  • 数据库是 MySQL,跨服务无法使用本地事务;
  • 使用了多个服务模块,每个模块都有自己的数据库;
  • 消息队列作为中间件,存在投递失败或重复消费的风险;
  • 并发操作下,容易出现竞态条件,导致余额计算错误;
  • 系统上线初期,出现了几次用户投诉“转账失败但扣款”的情况。

更糟糕的是,由于没有统一的事务协调机制,我们在排查日志时常常一头雾水 —— 不知道哪一个环节出了错,也不知道怎么补偿。

我们尝试的第一种方案:两阶段提交(2PC)

在调研了常见分布式事务方案后,我们最初考虑用的是经典的 2PC(Two-phase Commit)协议。我们选择了 Seata 作为框架支持。Seata 提供了一个比较完整的 TCC 模式和 AT 模式的实现。

我们先试了一下 AT 模式:只要添加注解 @GlobalTransactional,就能把整个方法加入全局事务。听起来很美好,对吧?

但实际部署到测试环境之后,问题就来了:

  • 性能下降明显,尤其是在高并发情况下,TPS 下降一半以上;
  • 锁冲突严重,MySQL 表级锁被持有时间太久,导致死锁频发;
  • Seata 对数据库版本、驱动等要求严格,升级维护成本高;
  • 一旦某个服务宕机,整个事务就会处于“不确定状态”,需要人工介入恢复。

更尴尬的是,我们的 Kafka 生产者也想纳入事务控制范围。但 Seata 不支持消息队列的事务性写入,只能手动配合 RocketMQ 的事务机制,复杂度陡增。

最后,我们不得不放弃了 Seata + 2PC 这套方案。

最终采用的方案:TCC + 最终一致性 + 补偿机制

在评估了多种方案之后,我们决定采用相对轻量且灵活的 TCC 模式,并结合“最终一致性”的设计理念进行优化。

什么是 TCC?

TCC 是一种补偿型事务模型,它的核心思想是将原本一个原子事务拆分为三个部分:

  1. Try(预留资源)
  2. Confirm(确认执行)
  3. Cancel(取消操作)

在我们这笔转账业务中,可以这样设计:

  • Try:冻结 A 的 100 元;
  • Confirm:扣除 A 的 100 元,增加 B 的 100 元;
  • Cancel:解除 A 的 100 元冻结。

这种方式的好处在于:

  • 事务粒度可控,适合拆分为多个服务调用;
  • 无需长时间锁住资源,减少了阻塞;
  • 出现失败时可以通过 Cancel 手段进行补偿;
  • 可与重试机制结合使用,提升系统容错能力。

实现思路详解

我们采用了自定义 TCC 框架的方式,而不是直接接入成熟组件(比如京东的 jdtcc 或蚂蚁的 tcc-transaction),主要是因为团队熟悉度、学习成本等原因。

整体架构如下:

[ transfer-service ]
       |
       v
[ account-service - try ] → 冻结金额
       |
       v
[ account-service - confirm / cancel ]

我们通过本地事务表来记录每一步的状态,并引入了一个定时任务来做兜底补偿。

核心数据库设计(简化版)

CREATE TABLE transfer_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    from_uid BIGINT NOT NULL,
    to_uid BIGINT NOT NULL,
    amount DECIMAL(18,2) NOT NULL,
    status ENUM('try', 'confirm', 'cancel') DEFAULT 'try',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP
);

伪代码示例

public void executeTransfer(Long fromUid, Long toUid, BigDecimal amount) {
    // 1. 创建订单,进入 try 状态
    TransferOrder order = createOrder(fromUid, toUid, amount);

    // 2. Try:冻结 fromUid 的金额
    boolean frozen = accountService.freezeAmount(fromUid, amount);
    if (!frozen) {
        // 冻结失败,直接标记为 cancel 状态
        updateOrderStatus(order.getId(), "cancel");
        return;
    }

    // 3. Confirm:向 toUid 加款
    boolean transferred = accountService.addBalance(toUid, amount);
    if (!transferred) {
        // 加款失败,执行 Cancel
        accountService.unfreezeAmount(fromUid, amount);
        updateOrderStatus(order.getId(), "cancel");
        return;
    }

    // 4. 完成,更新为 confirm
    updateOrderStatus(order.getId(), "confirm");

    // 5. 发送 Kafka 消息通知下游
    kafkaProducer.send(new TransferEvent(fromUid, toUid, amount));
}

当然,这只是主线逻辑。真正的重点是在异常处理和补偿流程上。

踩坑经验分享

这一路上踩了不少坑,下面挑几个最痛的说一说。

1. 幂等性没做好,Kafka 消费重复导致余额出错

刚开始只顾着事务本身,忽略了 Kafka 消费端的幂等性。结果某天压测之后发现用户的账上莫名其妙多出几笔到账记录 —— 消费者重复处理了。

解决方案:给每条消息加上唯一的业务 ID,消费者本地做一个“已处理 ID 缓存”或落盘记录,在处理前先判断是否已处理过。

2. Cancel 失败,资源一直冻结

在某个高峰期,Cancel 接口超时了,用户的钱一直被冻结,投诉电话都打爆了 😅。

解决方案:

  • 给 Cancel 操作设置最大重试次数;
  • 设置一个“冻结有效期”,超过一定时间自动解冻;
  • 使用延迟队列或定时任务来兜底。

3. TCC 服务之间依赖过深,修改困难

TCC 的问题是,你每新增一个参与服务,都需要为其设计 Try/Confirm/Cancel 方法,耦合度很高,后续改起来也很麻烦。

建议:

  • 尽量将业务拆小,避免一个 TCC 协议覆盖太多模块;
  • 对外提供幂等接口,方便外部系统调用;
  • 把通用的 Cancel 操作封装为独立服务或组件。

项目上线后的效果与收益

虽然前期调试花了不少时间,但上线后效果还是很明显的:

指标 改造前 改造后 提升幅度
TPS ~200 ~600 3x
故障率 0.5% <0.01% 显著下降
投诉率 平均每天1-2单问题 月均0-1单 极大降低

最重要的是,我们的日志可追踪性和事务完整性大大提升。出现问题也能快速定位,甚至可以通过补偿任务自动修复。

我的一些思考与建议

如果你也在考虑分布式事务的解决方案,这里是我这几年实战下来的一些总结建议:

✅ 选择合适不是最好,要根据业务特点选型

  • 本地事务优先,不要一开始就想分布式;
  • 如果只是两个服务之间,可以考虑用数据库 XA 事务消息队列事务
  • 对于长周期、多步骤的操作,推荐使用 TCC 或 Saga 模式;
  • 微服务拆得越细,越需要设计好幂等性和回滚机制。

✅ 设计上一定要有“兜底策略”

  • 每个关键点都要考虑失败如何恢复;
  • 建议保留事务历史记录;
  • 写一个补偿任务扫描器,定时检查未完成的事务并尝试修复;
  • 结合报警系统,出现异常及时通知。

✅ 一切以用户体验为准绳

技术再牛,最终还是要服务于业务。比如我们曾纠结要不要上 Saga 模式,但它会导致 Cancel 步骤变得复杂,反而影响响应速度。

所以在选型的时候,不妨问自己一个问题:这个问题如果发生在除夕夜,用户会怎么看?

总结一下

这篇文章讲了我在一个真实项目中碰到的一个分布式事务问题,以及我们是如何一步步分析、选型、实践并最终解决问题的过程。

其实到现在我也还在探索更好的方式,比如最近在研究 Event Sourcing 和 CQRS,希望能进一步解耦系统的状态变化。

但无论如何,有一点始终不变:

分布式事务的本质,是对“一致性”与“可用性”权衡的艺术。

希望你在面对类似问题时,能少走弯路,多一些从容 🧠✨


如果你觉得这篇文章对你有用,欢迎点赞、评论或者转发给身边的朋友。也欢迎关注我的公众号【码农日常】,我会继续分享更多接地气的技术实践。

评论 0

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