分布式事务解决方案:从踩坑到落地,一次真实项目中的实践总结
在如今的后端开发中,系统架构趋向于微服务化,而这也带来了越来越多的挑战。其中最让人头疼的问题之一,就是如何处理跨服务、跨数据库的数据一致性问题——也就是我们常说的“分布式事务”。
我目前在一家电商公司做后端开发,负责订单系统的研发工作。今天想和大家分享一次我们在新版本订单履约模块中遇到的真实问题,以及我们是如何一步步设计出一个既能保证数据一致性、又能兼顾性能和可用性的分布式事务方案的。
一、项目背景:为什么我们要引入分布式事务?

我们的订单系统是独立拆分出去的一个微服务,原来只对接商品中心和库存中心,结构相对简单。但随着业务的发展,履约流程变得越来越复杂:
- 订单创建成功后需要调用多个服务:
- 扣减用户优惠券
- 更新库存数量
- 调用物流系统进行预占仓位
- 触发风控系统的风险评估
- 每个操作都是异步调用、远程写入不同数据库,还可能失败重试
这时候我们面临一个非常现实的问题:如果某个操作失败,比如库存扣减成功了,但优惠券扣除失败,怎么回滚?
这时候,简单的本地事务已经无能为力,我们必须引入分布式事务机制来协调多个服务之间的数据一致性。
二、挑战与困惑:分布式事务不是万能药

一开始我们也尝试了几种方式,结果都出现了各种问题。
尝试1:纯TCC补偿机制(Try-Confirm-Cancel)
我们在核心链路里采用了TCC模式:
- Try阶段:资源预检并冻结
- Confirm:执行业务动作
- Cancel:回滚所有操作
虽然TCC的思想很好,但我们很快发现了几个痛点:
- 实现复杂:每个操作都要有Cancel逻辑,代码量翻倍
- 数据一致性难保障:Cancel失败怎么办?
- 性能瓶颈明显:高并发下锁竞争严重
更糟的是,有一段时间我们因为Cancel方法没有幂等处理,导致退款的时候多次扣款……这个问题直接上了生产事故台账。
尝试2:基于RocketMQ的消息事务
后来我们又尝试使用MQ实现“柔性事务”:
- 在订单写入数据库的同时,先发一条“待确认”的消息
- 如果本地事务提交成功,再发送Commit消息
- 消费方监听消息完成后续操作
这个方案确实简化了很多逻辑,但在测试环境中经常出现消费重复或者延迟的情况,特别是在网络波动或MQ重启后容易乱序。
我记得有一次测试环境的消息堆积了上万条,运维同事半夜被叫起来重启MQ,真是让人头大。
三、最终方案:Saga模式 + 状态机 + 异常自动对账

经历了几次“摸爬滚打”,我们最终采用了Saga模式结合状态机管理的方式,并加上定时对账机制,作为当前系统的最终解决方案。
Saga简介
Saga是一种经典的长周期分布式事务模式,它的核心思想是:
- 每一步都有一个补偿动作
- 一旦某一步失败,就逐级反向执行补偿动作
- 不依赖全局锁,适合高并发场景
我们定义了一个订单履约的状态机:
INITIAL -> PLACE_ORDER (try)
-> DEDUCT_COUPON (try)
-> DEDUCT_STOCK (try)
-> ALLOCATE_LOGISTICS (try)
任何一个步骤失败 → 执行对应的Undo动作
最后状态要么是FULFILLED,要么是ROLLBACK
核心代码片段
这里贴一段简化的Java伪代码逻辑:
public class OrderFulfillmentService {
private final CouponService couponService;
private final StockService stockService;
private final LogisticsService logisticsService;
public void fulfillOrder(Long orderId) throws Exception {
try {
// Step 1: Place order
placeOrder(orderId);
// Step 2: Deduct coupon
boolean couponDeducted = couponService.tryDeductCoupon(orderId);
if (!couponDeducted) throw new RuntimeException("Coupon deduct failed");
// Step 3: Deduct stock
boolean stockDeducted = stockService.tryDeductStock(orderId);
if (!stockDeducted) throw new RuntimeException("Stock deduct failed");
// Step 4: Allocate logistics
boolean logisticsAllocated = logisticsService.allocate(orderId);
if (!logisticsAllocated) throw new RuntimeException("Logistics allocate failed");
// All succeeded, mark as fulfilled
markOrderAsFulfilled(orderId);
} catch (Exception e) {
// Start rollback saga
rollbackFulfillment(orderId);
throw e;
}
}
private void rollbackFulfillment(Long orderId) {
logisticsService.releaseAllocation(orderId); // Undo step 4
stockService.rollbackDeduction(orderId); // Undo step 3
couponService.rollbackDeduction(orderId); // Undo step 2
markOrderAsRolledBack(orderId);
}
// ... other utility methods
}
当然这只是一个最简示例,在实际项目中我们引入了Saga编排引擎(比如Apache ServiceComb Pack或自研轻量引擎),并通过数据库记录每一步的状态、补偿日志等信息。
状态表设计示意
| 字段名 | 类型 | 描述 |
|---|---|---|
| order_id | BIGINT | 订单ID |
| current_step | VARCHAR | 当前执行到哪一步 |
| status | ENUM | RUNNING / SUCCESS / FAILED / ROLLED_BACK |
| last_error_code | INT | 最后错误码 |
| retry_count | INT | 已重试次数 |
| created_at | DATETIME | 创建时间 |
| updated_at | DATETIME | 上次更新时间 |
这样不仅方便监控,也便于后续对账和异常恢复。
四、踩过的坑与经验分享

坑1:Cancel操作不幂等,导致数据错乱
早期Cancel没有做幂等处理,例如用户点击多次重试,结果优惠券扣了两次……后来我们加了幂等键(一般是order_id + action_type)+ Redis缓存去重标志。
坑2:补偿失败无人知晓
Saga最大的问题是补偿操作可能会失败,这时候就需要额外的“兜底”能力。于是我们又加了个对账Job,每天凌晨比对各个子系统的数据,自动修复偏差。
坑3:日志追踪缺失,排查困难
一开始日志是分散在各个服务里,排查一个问题要跳转三四台服务器,效率极低。后来我们接入了ELK + Zipkin做全链路追踪,日志统一收集之后,定位效率提升了好几倍。
五、上线效果与收益
这套Saga+状态机的方案上线后,整体表现还是相当不错的:
- 数据一致性保障增强:相比之前MQ事务模式的弱一致,Saga提供了显式的回滚路径
- 系统吞吐提升:去掉了全局锁机制,不再担心死锁或阻塞
- 可维护性提高:通过状态跟踪和日志归集,故障排查更方便
- 自动化程度更高:定时对账任务有效拦截了90%以上的边缘异常场景
尤其是在双十一高峰期,整套系统扛住了流量冲击,没有出现重大数据不一致问题,得到了产品和运维团队的高度认可。
六、一些实用建议与注意事项
如果你也在考虑引入分布式事务机制,这里是一些来自实战的小建议:
- 不要盲目追求ACID:分布式环境下,强一致性代价太高,建议优先考虑最终一致性方案。
- 选择合适的技术方案而非流行技术:TCC、SAGA、XA各有适用场景,要根据业务特点选型。
- 尽早埋点日志和状态跟踪:日志和监控是后期运维的救命稻草。
- 幂等性必须做:无论是Cancel还是Confirm操作,都要具备幂等特性。
- 定期对账不可少:人工不可能实时发现所有问题,定时对账是最后一道防线。
- 合理设计降级策略:比如部分环节允许短暂不一致,不影响用户体验。
结语
分布式事务是每个后端开发者绕不开的一道坎。在我参与订单系统重构的过程中,从最初的迷茫、到不断踩坑、再到找到适合自身业务的方案,这段经历让我成长了不少。
现在的我已经不像以前那样“谈分布式事务色变”了。反而觉得,只要我们清楚地了解业务需求,选择合适的策略,设计良好的状态模型,再加上完善的监控和对账机制,它其实也没有想象中那么可怕。
希望这篇带着一线开发温度的文章,能给你带来一点启发。如果你也在处理类似的问题,欢迎留言交流,我们一起进步!

评论 0