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

还记得两年前我参与一个大型金融类系统重构时,第一次在生产环境遇到跨服务的数据一致性问题。那时候,我们刚从单体架构拆分成微服务架构,业务模块之间通过 RESTful 接口调用完成操作。某个核心支付流程中,需要同时更新用户账户余额和创建支付记录,但这两部分分别属于不同的服务模块。
有一天突然出现一笔订单的支付状态不一致:钱扣了,但支付记录没生成。更糟的是这个错误不是每次都发生,而是偶发性的。那次故障让我们意识到:如果不正视分布式事务的问题,再完美的微服务架构也撑不起高可用、高一致性的系统需求。
于是我们开始探索适合当前技术栈的分布式事务方案。今天这篇文章就来分享我当时是如何一步步解决这个问题的,包括踩过的坑、做过的权衡、以及最终落地的效果。
问题描述

项目背景
我们的系统是一个 SaaS 化的金融服务平台,主要提供企业级支付结算、账务管理等功能。随着客户规模增长,原来的单体架构暴露出性能瓶颈、部署困难、可维护性差等问题。我们在2022年启动了微服务化改造,使用 Spring Cloud + Dubbo 搭建微服务框架,数据层采用 MySQL 分库分表 + ShardingSphere。
改造完成后不久,就遇到了典型的分布式事务问题:
- A服务(账户服务)负责扣减用户余额;
- B服务(支付服务)负责记录支付流水并触发下游处理逻辑;
- 调用顺序是先 A 扣款,再 B 记录流水;
- 偶尔会出现“A 成功而 B 失败”,导致资金扣除但没有对应流水,客户投诉严重。
当时我们只是做了最基本的接口幂等处理,并未对整个事务链路进行统一控制。
解决方案选型与设计思路

为了解决上述问题,我们对比了几种常见的分布式事务方案:
| 方案 | 特点 | 缺点 | 是否适合当前项目 |
|---|---|---|---|
| XA两阶段提交 | 强一致性 | 性能低,存在同步阻塞风险 | ❌ 不适合 |
| TCC | 灵活,适合金融场景 | 业务侵入性强,补偿机制复杂 | ✅ 可以尝试 |
| Saga | 易于实现,支持长事务 | 回滚逻辑复杂,需人工干预 | ✅ 部分场景适用 |
| Seata AT 模式 | 透明化,无需改业务代码 | 对数据库版本有要求,日志写压力大 | ✅ 技术调研中 |
| 本地事务+消息队列异步补偿 | 实现简单,解耦强 | 最终一致性,失败后需人工介入 | ✅ 结合其他手段可行 |
我们最终选择了 TCC + 消息队列异步补偿 的组合方案,原因是:
- 支付金额涉及真实资金,必须保证强一致性;
- 服务间已经基于 RabbitMQ 构建了事件驱动架构;
- 已有的 DB 和 ORM 框架对 XA 协议支持有限;
- TCC 允许我们灵活控制每个环节的 commit / cancel 操作。
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);
}
}

坑三:日志混乱、调试困难
早期没有统一的日志上下文追踪,一旦出错很难定位到底是哪个步骤出了问题。后来我们接入了 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 |
更重要的是,这套事务机制提升了开发团队信心:我们可以放心地继续扩展服务边界,而不用担心因为新增一个模块而导致数据紊乱。
给开发者的几点建议
如果你也在做类似微服务下的数据一致性保障,这里是我的几点亲身体验:
不要迷信“开箱即用”的分布式事务框架,它们往往伴随着性能损耗和兼容性问题。优先结合业务场景自己设计。
TCC 不一定要全量覆盖所有服务。我们只在支付、转账、库存变更等核心流程上使用 TCC,其余普通操作使用消息队列+最终一致性兜底。
务必做好幂等设计,无论是 HTTP 接口还是 MQ 消费,都要加唯一标识去重。这会让你在各种异常场景下多一层防护。
引入监控手段非常关键。除了日志跟踪,建议加上 Prometheus+Grafana 的成功率统计面板,便于及时发现问题。
别低估回滚成本。TCC 虽好,但业务越复杂,编写 Cancel 的工作量和风险都会指数级上升。必要时要考虑 Saga 或者状态机方式。
保持开放思维。TCC 是目前我们选择的主方案,但也正在测试 Seata AT 模式,未来不排除根据业务发展切换方案。
写在最后
分布式事务是个老话题,但直到你真正经历过一次资金损失、一次用户投诉升级、一次凌晨紧急回滚才知道它有多重要。希望这篇实战经验能给你一点启发,少走一些弯路。
最后送大家一句工作中常用来提醒自己的话:
“事务不只是代码的事,它是整个系统设计的一部分。”
祝各位在各自的服务中,事事可回滚,人人无焦虑 😄
📌 作者简介:
我是一名从事 Java 后端开发6年的程序员,先后主导多个金融系统架构演进,从单体到微服务转型过程中踩过不少坑。欢迎关注我的公众号「架构日常」获取更多实战干货。

评论 0