分布式事务解决方案:从实战中摸索出的最佳实践
引言:分布式系统避不开的坎——事务一致性
我至今还记得,第一次在项目中踩进“分布式事务”的大坑时有多绝望。那是一个电商系统改造项目,订单服务、库存服务、支付服务已经各自拆分成独立的微服务,但问题也随之而来:用户下单后,如果支付失败了,库存怎么回滚?订单怎么取消?我们尝试用本地事务做保障,结果是数据错乱频发,最终不得不临时写了个定时任务来兜底清理异常数据,场面非常狼狈。
分布式事务成了我们团队当时最棘手的技术难题之一。后来我也参与了不少类似场景的开发,逐渐积累了一些经验。这篇文章我想结合自己的实际工作经历,和大家一起聊聊我们在生产环境中遇到的真实挑战、采用过的技术方案、踩过的坑,以及总结出来的最佳实践。希望能给大家一些启发。
问题描述:微服务架构下的数据一致性困境
我们的业务背景其实很常见:一个典型的电商平台,核心模块包括订单中心、库存中心、支付中心、优惠券中心等。这些服务各自运行在独立的JVM进程里,数据库也是彼此隔离的。为了支持高并发和快速迭代,架构上采用了Spring Boot + Dubbo + RocketMQ,并且数据库使用MySQL作为主存储。
典型问题场景如下:
当用户提交一个订单,流程大概是这样的:
- 订单服务创建订单记录;
- 扣减库存服务的可用库存;
- 调用支付服务发起支付;
- 支付成功后再回调订单服务更新状态;
- 同时给用户发放优惠券。
这一系列操作需要保证要么全部成功,要么全部失败,不能出现“钱收了,货没发”或“发货了,钱没收”的尴尬局面。
起初我们采用远程调用 + try-catch补偿机制的方式处理,比如在支付失败的时候去调用订单回滚接口。但由于网络波动、接口幂等性不足、重试策略混乱等问题,这种简单粗暴的方法频频引发数据不一致。
解决方案:选型对比与最终选择
为了解决这个问题,我们开始研究常见的分布式事务方案。市面上主流的有以下几种:
| 方案 | 说明 | 特点 |
|---|---|---|
| XA两阶段提交(2PC) | 基于XA协议的强一致性方案 | 强一致性,性能差,对数据库要求高,不适合高并发场景 |
| TCC(Try-Confirm-Cancel) | 自定义补偿事务,分三步执行 | 灵活、轻量、适合高性能要求,需开发者自己实现逻辑 |
| SAGA模式 | 长时间运行的一系列本地事务,失败时通过反向操作恢复 | 实现较简单,适合顺序性强的操作,难处理并发冲突 |
| 消息队列 + 本地事务表 | 利用消息队列异步协调多个服务事务 | 易实现,依赖MQ可靠性,最终一致,可接受一定延迟 |

我们评估下来觉得这四个各有适用场景,但综合性能、实现成本和系统复杂度考虑,最终决定采用基于TCC + RocketMQ的消息驱动机制的组合方案。
补充说明一下为什么不用Seata: Seata的确是个不错的开源框架,在阿里内部也有成熟应用案例,但在我们当时的项目背景下,引入Seata意味着大量的适配工作和侵入式代码修改。而我们希望尽量少改动现有服务结构,所以选择了更轻量、自主可控的TCC实现。
实践方案设计:TCC + 最终一致性保障
TCC的核心思想是将业务逻辑抽象成三个步骤:
Try:资源预留(冻结库存、检查账户余额)Confirm:真正执行业务动作(扣款、扣库存)Cancel:发生异常时回退资源(解冻库存、撤销支付)
我们以订单创建为例,来看下TCC如何落地:
核心设计思路
- 各个子服务暴露
try,confirm,cancel接口; - 主事务由订单服务协调执行;
- 如果任意一步失败,就触发Cancel流程;
- 为了防止Cancel失败,Cancel操作必须幂等;
- 增加一个事务日志表记录每个事务的状态和重试次数;
- 异常情况由定时任务扫描日志进行自动补偿。
接口设计示例(伪代码):
// 库存服务 TCC 接口定义
public interface InventoryService {
boolean reduceInventoryTry(Long productId, Integer quantity);
boolean reduceInventoryConfirm(String txId);
boolean reduceInventoryCancel(String txId);
}
代码实践:关键片段分享
下面是一段简化版的订单服务中执行TCC事务的逻辑:
@Transactional
public void createOrderWithTCC(Order order) throws Exception {
String txId = UUID.randomUUID().toString();
// 初始化事务日志
transactionLogService.saveTx(txId, "START");
try {
// Step 1: Try阶段 - 冻结库存
inventoryClient.reduceInventoryTry(order.getProductId(), order.getQuantity());
// Step 2: 创建订单
orderDao.create(order);
// Step 3: Confirm阶段 - 正式扣除库存
inventoryClient.reduceInventoryConfirm(txId);
transactionLogService.updateStatus(txId, "CONFIRMED");
// Step 4: 发送支付指令(异步)
messageQueue.send("PAYMENT", order);
} catch (Exception e) {
// Step 5: Cancel阶段 - 回滚
inventoryClient.reduceInventoryCancel(txId);
transactionLogService.updateStatus(txId, "CANCELED");
throw new RuntimeException("Transaction failed and rolled back", e);
}
}

注意:这里只是演示流程,真实环境下要配合幂等控制、重试机制、分布式锁等,后面会详细说明。
踩坑经验:那些血泪教训教会我的事
1. Cancel接口一定要幂等
某次线上故障是因为Cancel接口没有幂等处理,导致同一个事务被多次Cancel,结果库存被多扣一次,客户投诉爆棚。解决办法是在Cancel逻辑前加上redis标识位或者数据库唯一索引判断。
2. Cancel操作也要落盘记录日志
刚开始我们把Cancel的日志放在内存里,结果Cancel失败后根本查不到记录,排查起来非常困难。后来改成落库+异步补偿,才解决了这个痛点。
3. Try阶段容易误操作释放资源
有一个场景是用户拍下商品后没付款,系统应自动释放冻结的库存。但我们之前在Cancel逻辑中直接调用了Cancel接口,而没有校验是否处于"冻结"状态,导致部分正常付款订单也触发了Cancel,库存被错误释放。
4. 不要在Confirm阶段抛出异常
Confirm代表最终确认,一旦失败会导致整个流程无法继续推进。所以Confirm操作建议无副作用的设计,并做好异常捕获和日志记录。
效果总结:带来的收益和改变
方案上线后,我们的系统在事务一致性方面有了显著改善:
- 数据不一致率从原来的0.05%降低到近乎于零;
- 平均事务耗时控制在200ms以内,性能可接受;
- 客户投诉明显减少,特别是因“未付款却没库存”引起的纠纷;
- 运维层面可以通过事务日志系统快速定位问题点;
- TCC模式也被复用到了其他类似的业务场景,如积分兑换、礼包发放等。
更重要的是,我们建立了一个统一的分布式事务模型,后续新服务接入可以低成本集成进来。
经验分享:送给正在踩坑的你
如果你也在做分布式事务相关的开发,这里是我的一些建议和思考:
✅ 架构设计角度:
- 不要盲目追求“强一致性”,根据业务场景选择合适的一致性级别;
- 设计服务接口时就考虑好事务边界,避免后期强行嵌套;
- 服务之间尽量保持松耦合,通过事件/消息传递状态,而不是同步阻塞调用。
✅ 开发角度:
- 接口调用要加超时和降级机制,防止雪崩效应;
- 使用唯一的事务ID贯穿整个流程,便于日志追踪和问题排查;
- 每一步操作都要有详细的日志记录,方便运维分析;
- 尽量让Confirm和Cancel操作不可逆,避免反复变更数据状态。
✅ 运维角度:
- 定期跑补偿任务检查异常事务;
- 监控MQ消费积压情况,及时告警;
- 保留历史事务记录供后续审计和统计分析;
- 对Cancel失败的事务要能人工介入干预。
结语:路虽远,行则将至
回头看看这几年走过的路,分布式事务确实是个让人又爱又恨的话题。它既考验技术深度,也挑战工程能力。但我始终相信一句话:“没有银弹,但有办法。”只要我们足够重视数据一致性、理解业务本质、愿意投入精力去设计和维护,就能找到适合自己的平衡点。
希望这篇从实际项目出发的小总结,能够对你有所启发。如果你也有类似经验或问题,欢迎留言交流。咱们一起把坑填平,把路铺宽。
—— 一名还在路上的开发者

评论 0