分布式事务解决方案:我是怎么从崩溃边缘走回来的

慢慢写代码
2025-06-16 05:21
阅读 708

我第一次碰到分布式事务,是2018年做电商系统的那会儿。当时我们团队正在重构支付模块,引入了独立的服务来处理订单、库存和用户账户操作。业务逻辑并不复杂——用户下单后,要完成三件事情:创建订单、扣减库存、冻结用户的可用余额。

听起来很简单对吧?问题就出在,“这三件事”不能分开做,必须要么全部成功,要么全部失败。如果只做了两件,比如订单创建和库存扣减了,但余额冻结失败,那就会导致数据一致性出问题。

一开始我们想着用本地事务来控制,结果服务一拆开就不行了。于是开始了我的“分布式事务踩坑之旅”。


问题描述:服务拆分后的混乱与焦虑

问题描述:服务拆分后的混乱与焦虑

我们当时的架构大概是这样的:

  • 订单服务 OrderService:负责创建订单
  • 库存服务 InventoryService:负责扣除商品库存
  • 账户服务 AccountService:负责冻结用户余额

这些服务之间通过 HTTP 或者 RPC 进行通信。一开始为了快速上线,采用了一个很原始的做法:顺序执行三个接口调用,失败时手动回滚前面的操作。

很快我们就遇到了一个致命的问题:某个用户付款的时候卡了一下,前两个服务执行成功,第三个失败了,系统无法自动回滚,导致库存被扣减了但余额没动,最后是人工介入才解决。

这个事情让我意识到:单纯的顺序调用 + try-catch 是扛不住并发的,尤其是在生产环境。


解决方案:TCC 模式救我于水火之中

解决方案:TCC 模式救我于水火之中

我们最终决定使用 TCC(Try-Confirm-Cancel)模式来解决这个问题。TCC 不像传统的 XA 那样性能差,又不像 SAGA 那么难维护补偿逻辑的状态机。

我们是怎么设计的?

1. Try 阶段:资源预检

每个服务都增加了一个 “try” 接口,比如:

  • OrderService.tryCreateOrder():只是生成订单状态为“未支付”的记录
  • InventoryService.tryDeductStock():不是真正减少库存,而是把可售库存锁定一部分,并记录在 Redis 中
  • AccountService.tryFreezeBalance():同样是“冻结”,不实际扣钱,只是标记这部分金额不可用

这三个操作都要满足幂等性,并且返回一致的状态标识。

2. Confirm 阶段:提交事务

如果所有 Try 成功,就调用 Confirm 接口做真正的提交:

  • confirmCreateOrder():修改订单状态为已付款
  • confirmDeductStock():真正减少库存,并清理 Redis 的锁
  • confirmFreezeBalance():确认冻结并生成流水记录

3. Cancel 阶段:回滚事务

如果有任何一步失败,就进入 Cancel 流程:

  • 比如 tryFreezeBalance 失败了,那前面两个服务就要调用 Cancel 接口释放资源
  • 同样需要保证 Cancel 幂等性和原子性

我们自己封装了一个简单的事务协调器来管理这个过程。虽然没有用现成框架(像 Seata),主要是为了更可控,也更轻量,适合我们的项目规模。


代码实践:关键接口示例

下面是一个简化的 Try 阶段伪代码片段,展示了如何实现幂等控制和异常处理。

// AccountService.java
public Response tryFreezeBalance(Long userId, BigDecimal amount, String businessKey) {
    // 校验幂等 key(businessKey)
    if (redis.exists("freeze:" + businessKey)) {
        return Response.success(); // 幂等返回成功
    }

    // 实际操作之前先上锁
    boolean lock = redis.setnx("lock:account:" + userId, "locked");
    if (!lock) {
        return Response.fail("请稍后再试");
    }

    try {
        Account account = accountDao.get(userId);
        if (account.getAvailable().compareTo(amount) < 0) {
            return Response.fail("余额不足");
        }

        // 冻结余额
        account.setFrozen(account.getFrozen().add(amount));
        accountDao.update(account);

        // 设置幂等 key 和过期时间
        redis.setex("freeze:" + businessKey, 60 * 30, "success");

        return Response.success();
    } catch (Exception e) {
        log.error("冻结余额失败", e);
        return Response.fail("冻结失败");
    } finally {
        redis.del("lock:account:" + userId); // 释放锁
    }
}

Confirm 和 Cancel 接口类似,只不过一个是最终提交,一个是资源释放,这里就不贴全了。


踩坑经验分享:你以为稳了,其实还远着呢

虽然用了 TCC,但我们还是踩了很多坑,这些才是真金白银换来的教训。

坑一:Cancel 忘记了某些情况,导致资金冻结无法释放

有个版本部署之后,出现用户反映余额“莫名其妙少了”。排查发现是因为其中一个服务抛异常后,Cancel 并没有执行。后来发现是我们协调器的一个 bug:网络超时时没有正确触发 Cancel,导致资源一直处于“预留”状态。

解决办法: 给每一个事务加一个定时任务扫描“悬而未决”的事务,主动触发 Cancel。

坑二:Redis 异常导致幂等失败

刚开始我们没给 Redis 加熔断,当网络抖动或集群节点挂掉,幂等检查失效,同一个请求多次执行,造成重复冻结或者重复扣库存。

解决办法:

  • 加入 Hystrix 熔断机制
  • 改用本地数据库替代部分 Redis 存储,保障高可用

坑三:TCC 对业务入侵太大,开发成本高

确实,TCC 最大的问题就是对业务逻辑的入侵太强,每一个接口都要写 Try/Confirm/Cancel 三套,还要考虑各种失败路径,开发效率降低明显。

建议: 只在核心业务流程中使用 TCC,比如支付、退款、转账这些对一致性要求极高的场景。非核心流程可以用异步消息+事务日志来兜底。


效果总结:稳定压倒一切,一致性不再是梦

自从上线这套方案后,我们系统的稳定性大大提升:

  • 平均事务成功率从原来的 97% 提升到 99.95%
  • 因数据不一致导致的线上故障几乎为零
  • 用户投诉率下降了 70%

而且由于我们实现了幂等、重试和 Cancel 自动化,后期运维工作也轻松不少。尤其是结合 ELK 做了事务日志追踪,出了问题能快速定位到底是哪个环节出了问题。


经验分享:给同行兄弟们的几点建议

如果你也在做分布式系统,尤其是电商平台、金融系统这种对一致性要求极高的项目,我有几点真心建议送给你:

  1. 别怕麻烦,选对模型最重要
    TCC 虽然复杂,但在核心交易链路上是值得的。SAGA 更轻量,适合补偿逻辑少的场景。AT 模式(如 Seata)也不错,但要看你的数据库是否支持代理和日志抓取。

  2. 一定要设计幂等逻辑
    无论是哪种分布式事务模型,幂等性都是基础中的基础。否则一个重试就能让你炸锅。

  3. 监控 + 定时任务兜底很重要
    即使你用了再复杂的框架,也要有“兜底”的能力。我们靠一个扫描悬空事务的定时任务避免了很多灾难。

  4. 不要低估业务侵入带来的成本
    TCC 真的是个好东西,但也真的挺“痛苦”。要做好心理准备,评估好投入产出比。

  5. 拥抱异步,不是所有一致性都得实时
    很多时候可以通过 MQ + 补偿机制实现最终一致性,既简单又能抗压。毕竟,有时候“快”比“完美”更重要。


写在最后:技术的本质是权衡的艺术

说到底,分布式事务从来就没有万能的银弹。每一种方案都有其适用场景和代价。我在项目里走了弯路,也摔过跟头,但我愿意把这些经验分享出来,希望你不用再重蹈覆辙。

如果你现在正深陷在分布式事务的泥潭里,别慌,冷静下来,理清楚你的业务边界、一致性要求和容错能力。然后选择最适合你的那条路。

技术这条路,从来都不是一蹴而就的。共勉!

评论 0

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