分布式事务解决方案:一次生产环境踩坑后的实战总结

李秀珍_创新
2025-06-13 22:53
阅读 713

开篇

开篇

还记得两年前我参与一个大型金融类系统重构时,第一次在生产环境遇到跨服务的数据一致性问题。那时候,我们刚从单体架构拆分成微服务架构,业务模块之间通过 RESTful 接口调用完成操作。某个核心支付流程中,需要同时更新用户账户余额和创建支付记录,但这两部分分别属于不同的服务模块。

有一天突然出现一笔订单的支付状态不一致:钱扣了,但支付记录没生成。更糟的是这个错误不是每次都发生,而是偶发性的。那次故障让我们意识到:如果不正视分布式事务的问题,再完美的微服务架构也撑不起高可用、高一致性的系统需求。

于是我们开始探索适合当前技术栈的分布式事务方案。今天这篇文章就来分享我当时是如何一步步解决这个问题的,包括踩过的坑、做过的权衡、以及最终落地的效果。


问题描述

问题描述

项目背景

我们的系统是一个 SaaS 化的金融服务平台,主要提供企业级支付结算、账务管理等功能。随着客户规模增长,原来的单体架构暴露出性能瓶颈、部署困难、可维护性差等问题。我们在2022年启动了微服务化改造,使用 Spring Cloud + Dubbo 搭建微服务框架,数据层采用 MySQL 分库分表 + ShardingSphere。

改造完成后不久,就遇到了典型的分布式事务问题:

  • A服务(账户服务)负责扣减用户余额;
  • B服务(支付服务)负责记录支付流水并触发下游处理逻辑;
  • 调用顺序是先 A 扣款,再 B 记录流水;
  • 偶尔会出现“A 成功而 B 失败”,导致资金扣除但没有对应流水,客户投诉严重。

当时我们只是做了最基本的接口幂等处理,并未对整个事务链路进行统一控制。


解决方案选型与设计思路

解决方案选型与设计思路

为了解决上述问题,我们对比了几种常见的分布式事务方案:

方案 特点 缺点 是否适合当前项目
XA两阶段提交 强一致性 性能低,存在同步阻塞风险 ❌ 不适合
TCC 灵活,适合金融场景 业务侵入性强,补偿机制复杂 ✅ 可以尝试
Saga 易于实现,支持长事务 回滚逻辑复杂,需人工干预 ✅ 部分场景适用
Seata AT 模式 透明化,无需改业务代码 对数据库版本有要求,日志写压力大 ✅ 技术调研中
本地事务+消息队列异步补偿 实现简单,解耦强 最终一致性,失败后需人工介入 ✅ 结合其他手段可行

我们最终选择了 TCC + 消息队列异步补偿 的组合方案,原因是:

  1. 支付金额涉及真实资金,必须保证强一致性;
  2. 服务间已经基于 RabbitMQ 构建了事件驱动架构;
  3. 已有的 DB 和 ORM 框架对 XA 协议支持有限;
  4. TCC 允许我们灵活控制每个环节的 commit / cancel 操作。

TCC方案落地实践

TCC方案落地实践

下面我以“支付”流程为例,讲解我们是如何实现 TCC的。

Step 1:定义 TCC 接口契约

public interface PaymentTccService {

    @TwoPhaseBusinessAction(name = "preparePay")
    boolean preparePay(BusinessActionContext ctx);

    @Commit
    boolean commitPay(BusinessActionContext ctx);

    @Rollback
    boolean rollbackPay(BusinessActionContext ctx);
}

我们使用的是 Alibaba Seata 提供的 TCC 编程模型(虽然后来没用 AT),但接口契约的设计可以复用。

Step 2:实现 Try 阶段 - 准备资源

Try 阶段用于预留资源或检查前置条件。比如:

@Override
public boolean preparePay(BusinessActionContext ctx) {
    String userId = (String) ctx.getActionContext("userId");
    BigDecimal amount = (BigDecimal) ctx.getActionContext("amount");

    // 检查账户余额是否足够
    if (!accountService.checkBalance(userId, amount)) {
        return false;
    }

    // 预冻结账户余额(不会实际扣除)
    accountService.freezeAmount(userId, amount);
    return true;
}

Step 3:Confirm 阶段 - 正式提交

如果所有 Try 都成功,则执行 Confirm:

@Override
public boolean commitPay(BusinessActionContext ctx) {
    String userId = (String) ctx.getActionContext("userId");
    BigDecimal amount = (BigDecimal) ctx.getActionContext("amount");

    // 解冻之前冻结的资金
    accountService.deductFreezedAmount(userId, amount);
    
    // 创建支付记录
    paymentRecordService.createRecord(userId, amount);

    return true;
}

Step 4:Cancel 阶段 - 回滚操作

如果有任意服务失败,则触发 Cancel:

@Override
public boolean rollbackPay(BusinessActionContext ctx) {
    String userId = (String) ctx.getActionContext("userId");
    BigDecimal amount = (BigDecimal) ctx.getActionContext("amount");

    // 解除冻结,不做扣款
    accountService.unfreezeAmount(userId, amount);
    return true;
}

⚠️ 注意:TCC 需要你自己维护上下文传递。我们是通过 ThreadLocal + MDC 实现的请求上下文管理。


踩过的坑和经验总结

坑一:Cancel重复调用导致负值

刚开始我们没有考虑幂等性,结果在高并发下 Cancel 方法被多次执行,导致账户余额变成负数。后来我们加了个幂等判断字段,在 Redis 中记录 cancel 请求 ID:

String lockKey = "cancel_lock:" + businessId;
if (!redisTemplate.opsForValue().setIfAbsent(lockKey, "processing", 5, TimeUnit.MINUTES)) {
    log.warn("Cancel already processed for: {}", businessId);
    return true;
}

坑二:事务超时引发连锁反应

最开始我们把整个 TCC 流程放在同一个事务中执行,结果网络波动时卡住整个事务管理器节点。后来我们改为将各阶段作为独立事务,并引入定时任务扫描未完成事务:

# 定时任务配置
spring:
  task:
    scheduling:
      pool:
        size: 4
@Scheduled(fixedRate = 30000)
public void checkUnfinishedTccTransactions() {
    List<UnfinishedTx> txs = transactionService.listUnfinished(60_000L);
    for (UnfinishedTx tx : txs) {
        handleTimeoutTransaction(tx);
    }
}

数据库设计模型-1

坑三:日志混乱、调试困难

早期没有统一的日志上下文追踪,一旦出错很难定位到底是哪个步骤出了问题。后来我们接入了 SkyWalking,并自定义了一个 TxTraceFilter 来绑定交易ID到 MDC:

String txId = request.getHeader("X-Tx-ID");
MDC.put("txId", txId);

这样每一条日志都能带上当前交易ID,排查效率提高不少。


使用 RabbitMQ 辅助事务兜底

为了进一步提升可靠性,我们还借助了 RabbitMQ 做异步补偿:

  • 当 Confirm 阶段完成后,往 MQ 发送一条“支付完成”消息;
  • 消费端监听该消息,执行后续异步动作(如通知风控系统、发送短信等);
  • 如果消费失败,自动重试,超过最大次数则告警人工处理。

示例代码如下:

@Transactional
public void onPaymentConfirmed(String userId, String orderId) {
    try {
        rabbitTemplate.convertAndSend("payment.complete", new PaymentEvent(userId, orderId));
    } catch (Exception e) {
        log.error("发送MQ失败,进入补偿队列", e);
        fallbackQueue.add(new RetryMessage(userId, orderId));
    }
}

效果总结

上线后近一年运行平稳,数据一致性问题几乎不再出现。以下是部分关键指标的变化:

指标 上线前 上线后
日均异常订单数量 18~25 单 0~2 单
用户投诉率 0.3% 下降到 0.02%
TPS 维持在 1500 左右 波动不大
系统响应延迟 平均 230ms 提升至平均 190ms

更重要的是,这套事务机制提升了开发团队信心:我们可以放心地继续扩展服务边界,而不用担心因为新增一个模块而导致数据紊乱。


给开发者的几点建议

如果你也在做类似微服务下的数据一致性保障,这里是我的几点亲身体验:

  1. 不要迷信“开箱即用”的分布式事务框架,它们往往伴随着性能损耗和兼容性问题。优先结合业务场景自己设计。

  2. TCC 不一定要全量覆盖所有服务。我们只在支付、转账、库存变更等核心流程上使用 TCC,其余普通操作使用消息队列+最终一致性兜底。

  3. 务必做好幂等设计,无论是 HTTP 接口还是 MQ 消费,都要加唯一标识去重。这会让你在各种异常场景下多一层防护。

  4. 引入监控手段非常关键。除了日志跟踪,建议加上 Prometheus+Grafana 的成功率统计面板,便于及时发现问题。

  5. 别低估回滚成本。TCC 虽好,但业务越复杂,编写 Cancel 的工作量和风险都会指数级上升。必要时要考虑 Saga 或者状态机方式。

  6. 保持开放思维。TCC 是目前我们选择的主方案,但也正在测试 Seata AT 模式,未来不排除根据业务发展切换方案。


写在最后

分布式事务是个老话题,但直到你真正经历过一次资金损失、一次用户投诉升级、一次凌晨紧急回滚才知道它有多重要。希望这篇实战经验能给你一点启发,少走一些弯路。

最后送大家一句工作中常用来提醒自己的话:

“事务不只是代码的事,它是整个系统设计的一部分。”

祝各位在各自的服务中,事事可回滚,人人无焦虑 😄


📌 作者简介:
我是一名从事 Java 后端开发6年的程序员,先后主导多个金融系统架构演进,从单体到微服务转型过程中踩过不少坑。欢迎关注我的公众号「架构日常」获取更多实战干货。

评论 0

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