分布式事务解决方案:一场真实战斗的总结
背景介绍:为什么我们要面对分布式事务?

我至今还记得那个让我“彻夜未眠”的项目。那是一个典型的金融类系统,核心功能是用户充值和账户余额变更。表面上看起来很常规,但问题出在我们用了两个服务——一个负责订单管理,一个负责账户余额管理。
起初只是简单的调用链路:用户提交充值请求 → 创建充值订单 → 调用余额服务扣款。但事情并没有那么简单,随着交易量的增长,我们发现有时候订单创建成功了,但扣款失败了;或者反过来,余额扣了但订单没建起来。这时候我们就意识到:我们遇到了典型的分布式事务问题。
更糟的是,系统当时没有任何补偿机制。一旦失败,整个流程就会留下脏数据,甚至导致账务对不上。这个问题直接动摇了系统的可靠性,领导层开始关注,我们也被“赶鸭子上架”,必须解决它。
问题描述:到底什么是分布式事务?

简单来说,当我们多个服务(微服务)需要在一个业务逻辑中一起做状态变更,并且要求它们要么全部成功、要么全部失败的时候,就需要处理分布式事务。
比如:
- 订单服务新增一条订单
- 库存服务减库存
- 支付服务扣钱
这三个操作都必须一致,如果其中一个失败,整个流程都要回滚。而传统的本地事务只能保证单数据库的操作一致性,跨服务时就不行了。
我们的具体场景
在我们的充值流程中:
- 用户发起充值 → 创建订单(order-service)
- 扣款 → 修改账户余额(account-service)
- 充值完成后发送通知(通知服务)
我们面临的核心问题是:如何保证创建订单与扣款这两个操作的原子性?也就是说,不能出现订单创建成功但未扣款,或者扣款完成但订单没生成的情况。
最开始尝试过“两步走”方式:
// step1: 创建订单
orderService.createOrder();
// step2: 扣款
accountService.charge();
结果可想而知,任何一个步骤出错,整个系统就进入了一个不一致的状态。而且由于网络不稳定、服务宕机等因素,根本无法预判错误发生在哪里。
于是我们开启了这场“对抗分布式事务”的战役。
解决方案:我们是如何搞定这个问题的?

在整个过程中,我们调研了很多种方案,包括 TCC、Saga 模式、消息队列最终一致、Seata 等。最终选择了一个混合方案,结合 TCC 和定时任务补偿机制,效果非常好。
先简单介绍下我们选中的几种策略:
1. TCC(Try-Confirm-Cancel)
这是业界主流的一种分布式事务实现方式,分为三个阶段:
- Try 阶段:资源检查 & 预留(冻结资金)
- Confirm 阶段:执行业务逻辑(正式扣除)
- Cancel 阶段:回滚操作(解冻资金)
这个模式的优点是强一致性,缺点是开发成本高,每个服务都需要实现 Try、Confirm、Cancel 接口。
我们最初尝试用 TCC 的框架(如阿里开源的 Hmily),但在实际使用中发现了几个问题:
- TCC 对代码侵入性强,改造成本大
- 依赖本地事务日志表管理事务上下文,维护麻烦
- 在高并发环境下,会出现重复请求、幂等性等问题
虽然可行,但我们决定暂时不用这种方式,因为项目时间紧、上线压力大。
2. 消息队列 + 最终一致性
我们采用了第二种思路:通过 RabbitMQ 实现异步消息补偿机制,这也是我们最后选择落地的方式。
基本流程如下:
- 用户发起充值请求
- order-service 创建订单(status=processing)
- 发送一条“待支付”消息到 MQ
- account-service 消费消息后进行扣款
- 成功后发送“已支付”消息更新订单状态为 success
- 如果某一步失败,定期任务检查未完成订单,重新触发补偿
这样做的优点是:
- 架构轻量化,不依赖重装的分布式事务中间件
- 容错能力强,即使部分环节失败也能自动恢复
- 易于扩展,后续可以接入更多服务
当然也有缺点:
- 不是强一致性,而是最终一致性
- 需要自己实现幂等、去重、重试、补偿机制
- 整体链路变长,排查问题可能较复杂
不过对于当时的业务需求而言,这是一个折中但高效的方案。
我们的实践:基于 MQ + 补偿机制的分布式事务实现
整体架构设计
[前端] -> [网关] -> [order-service]
↓
[RabbitMQ](charge.created)
↓
[account-service]
↓
[update.order.status]
数据库设计要点
我们在 orders 表中增加了以下几个关键字段:
status:枚举类型(created, processing, paid, failed)retry_count:失败重试次数locked_at:订单锁定时间戳(用于超时清理)
同时增加了一张 charge_logs 表记录所有支付动作,便于审计和追踪。
核心代码示例
1. 创建订单并发送消息
@Transactional
public Order createOrder(ChargeRequest request) {
Order order = new Order();
order.setUserId(request.getUserId());
order.setStatus("created");
order.setAmount(request.getAmount());
orderRepository.save(order);
// 发送消息到MQ
Message message = new Message();
message.setType("charge.created");
message.setContent(new ObjectMapper().writeValueAsString(order));
rabbitMQTemplate.convertAndSend("charge.queue", message);
return order;
}
2. 消费消息并进行扣款
@RabbitListener(queues = "charge.queue")
public void handleChargeMessage(String message) {
Order order = objectMapper.readValue(message, Order.class);
try {
// 调用Account Service API 扣款
boolean result = accountService.charge(order.getUserId(), order.getAmount());
if (result) {
// 扣款成功,更新订单状态为paid
order.setStatus("paid");
orderRepository.save(order);
// 再发个通知消息
notifyService.notify(order.getUserId(), "充值成功!");
} else {
// 失败更新为failed
order.setStatus("failed");
orderRepository.save(order);
}
} catch (Exception e) {
// 日志记录 + 更新为failed
log.error("支付失败: {}", e.getMessage());
order.setStatus("failed");
orderRepository.save(order);
}
}

3. 定时补偿任务
我们写了一个每天凌晨运行的定时任务,用来查找状态为 failed 或 created 时间超过一定阈值的订单,重新触发扣款流程。
@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨执行
public void retryFailedOrders() {
List<Order> orders = orderRepository.findByStatusIn(Arrays.asList("created", "failed"));
for (Order order : orders) {
boolean result = accountService.charge(order.getUserId(), order.getAmount());
if (result) {
order.setStatus("paid");
log.info("订单 {} 已补扣款", order.getId());
} else {
int retryCount = order.getRetryCount() + 1;
order.setRetryCount(retryCount);
if (retryCount > MAX_RETRY_TIMES) {
order.setStatus("failed");
}
}
orderRepository.save(order);
}
}
踩坑经验分享:踩过的坑比写的代码还多
在这个过程中,我们踩了不少坑,下面列出几个特别典型的问题和我们是怎么解决的。
1. 消息重复消费
问题现象:有时候同一个订单会被多次处理,导致重复扣款。
原因分析:RabbitMQ 默认是“最多一次”投递,但我们没有做好消费端幂等控制,导致重复消费。
解决方法:
- 使用订单ID作为唯一幂等Key,每次处理前查询是否已经执行过
- 增加 Redis 缓存记录消费历史(key: charge_{orderId})
if (redisTemplate.hasKey("charge_" + order.getId())) {
log.warn("该订单已处理,跳过...");
return;
}
redisTemplate.opsForValue().set("charge_" + order.getId(), "processed", 1, TimeUnit.DAYS);
2. 死信队列未处理导致消息丢失
问题现象:MQ 中的消息积压了几天,没人知道为什么。
根本原因:某些消息在反复失败后变成死信,但没有设置 DLQ(Dead Letter Queue)处理机制。
解决方法:
- 配置 RabbitMQ 的死信队列插件
- 将失败的消息自动转移到另一个队列
- 单独监控这个队列,进行人工干预或自动补偿
3. 事务边界不清
一开始我们把 order 的保存和消息发送放在同一个事务里,结果导致事务提交前就发消息,消息内容读不到刚插入的数据。
解决方法:分离消息发送为一个单独事务,确保数据落库后再发消息。
效果总结:这套方案带来了哪些好处?
自从上线这套机制后,我们系统的稳定性有了显著提升:
| 维度 | 上线前 | 上线后 |
|---|---|---|
| 异常订单数 | 每天上百条 | 几乎为零 |
| 手工对账频率 | 每天都要查账 | 周级别检查即可 |
| 平均修复时长 | 小时级 | 秒级自动修复 |
| 用户投诉率 | 明显上升 | 明显下降 |
| 架构灵活性 | 难以横向扩展 | 可快速接入新服务 |
更重要的是,我们建立了一套完整的补偿和监控机制,为后续类似系统打下了良好的基础。
给读者的经验建议:少走弯路,从别人的坑里爬出来
如果你也在面对分布式事务的困扰,这里是我根据实战总结的一些经验建议:
✅ 1. 别一上来就追求“强一致性”
很多场景其实是可以容忍短暂的不一致性的。比如电商系统下单后1秒才显示支付成功,用户不会介意。但如果一味追求强一致性,反而会让系统变得复杂难维护。
✅ 2. 消息队列是最好的搭档
无论是 Kafka 还是 RabbitMQ,只要搭配好幂等机制和补偿逻辑,完全可以用相对简单的手段做到高可靠、低延迟的数据一致性。
✅ 3. 做好补偿机制的设计
无论你用哪种方案,必须有兜底机制。比如定时任务扫描 + 自动修复,这能让你睡得安稳些。
✅ 4. 日志+监控是救命稻草
记录每一步的状态变化,结合 ELK 做检索,出现问题能迅速定位。同时配合 Prometheus + Grafana 做可视化监控。
✅ 5. 分布式事务不要只靠中间件,更要靠设计
Seata、LCN、Hmily 等方案都有其适用场景,但它们不是万能钥匙。有时候更合适的方案,是结合业务场景做“最小化”处理。
结语:技术这条路,走得慢没关系,关键是稳
这次项目经历让我深刻理解了一个道理:技术没有银弹,只有合适与否的选择。分布式事务不是一个简单的问题,但它也不是不可逾越的大山。
从最初的焦虑不安,到后来的从容应对,这段经历不仅让我成长,也让我明白了系统设计背后的哲学:稳定高于一切,简洁胜过复杂。
希望这篇文章能帮到你,哪怕只是避开了一个小坑,也是一种缘分吧 😊
如果你正在处理类似的问题,欢迎留言交流,我也很乐意听听你们的实战故事。

评论 0