分布式事务实战:从踩坑到掌控的五年心路
引言:为什么我决定写这篇文章?

五年前刚入行的时候,我在一个电商项目里负责订单服务。有一天上线后突然收到报警:用户下单后积分没扣、商品库存也少了,但订单却显示成功。更糟的是,支付系统那边已经收到了交易成功的通知。
当时的我一头雾水,查日志、看代码、翻文档……最后才发现,这其实是一个典型的分布式事务问题——服务间状态不一致。我们当时使用的是多个微服务+不同的数据库,整个流程横跨了支付、订单、商品、积分四个系统,每个系统的数据一致性都只能通过程序去保障。
那一刻起,我就意识到,在微服务架构下,“事务”这件事比以前复杂太多了。
今天,我想结合自己这几年在多个项目中遇到的真实场景,分享一下我对分布式事务的实践经验和思考。
问题描述:真实业务中的典型挑战


场景背景
最近一个项目是一个 SaaS 平台,面向中小企业提供供应链管理功能。这个平台中有:
- 商品中心(MySQL)
- 库存中心(MongoDB)
- 订单中心(PostgreSQL)
- 支付中心(外部接口)
- 用户中心(Redis 缓存用户余额)
当用户完成一次采购,会经历如下关键步骤:
- 下单
- 扣用户余额
- 减库存
- 写订单记录
- 调用第三方支付回调确认
如果其中任何一个环节失败,都要保证所有操作回滚,否则就会出现类似“钱扣了货没发”、“货没了钱没收到”的严重问题。
起初我们想当然地认为:“用本地事务包裹一下这些调用不就好了?”然而事实是,每个服务都有自己的数据库,事务无法跨服务提交或回滚。
更复杂的还有:
- 有些服务是外部系统,比如支付系统是第三方的
- 存在异步处理的需求,比如生成报表、同步日志
- 需要支持高并发和低延迟
解决方案选型:不是只有 Seata!

为了解决这个问题,我和团队调研了很多方案,最终选择了 TCC + Saga 混合模式。下面是我总结的一些主流方案及其适用场景:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 两阶段提交 | 小规模、强一致性要求 | 简单易实现 | 性能差、存在单点故障 |
| TCC | 核心业务链路,可拆分为 Confirm/Cancel | 性能好、适合高并发 | 逻辑复杂、需要补偿机制设计 |
| Saga | 异常较少、补偿代价较低 | 易扩展、适合长流程 | 最终一致性,可能带来数据波动 |
| 消息队列事务 | 数据异步同步、事件驱动 | 松耦合、性能好 | 实现成本高、需考虑幂等 |
| Seata | 单一技术栈、Spring Cloud生态 | 集成简单、社区活跃 | 对 DB 类型和框架兼容有限 |
我们最终采用 TCC(Try-Confirm-Cancel) + Saga 组合的方式实现了核心下单流程的一致性保障。
关键代码与实现思路:以订单创建为例

我们把一个订单创建流程划分为以下几个 TCC 步骤:
Try 阶段:
- 冻结用户余额(在 Redis 中预扣)
- 预减库存(MongoDB 设置冻结字段)
Confirm 阶段:
- 扣除用户余额
- 真正减少库存
- 创建订单记录
Cancel 阶段:
- 回退用户余额
- 回滚库存
以下是简化的核心流程伪代码(实际项目使用 Spring Boot + Dubbo + RocketMQ 消息队列):
// 下单入口
public Order createOrder(OrderRequest request) {
// Step 1: Try 阶段
tryService.freezeBalance(request.getUserId(), request.getTotalPrice());
tryService.reduceInventory(request.getProductId(), request.getCount());
// Step 2: 创建订单
Order order = orderService.create(request);
// Step 3: 调用 Confirm 或触发 Cancel
if (paymentService.charge(order)) {
confirmService.confirmBalance(request.getUserId(), request.getTotalPrice());
confirmService.confirmInventory(request.getProductId(), request.getCount());
} else {
cancelService.rollbackBalance(request.getUserId(), request.getTotalPrice());
cancelService.rollbackInventory(request.getProductId(), request.getCount());
}
return order;
}
看起来很理想?但真正的难点在于如何设计这些接口,以及在失败时自动重试/补偿。
为此我们引入了一个 Saga 工作流引擎,用于编排整个流程,并记录每一步的状态。
🧠 小插曲:最开始我们没有记录每一步状态,结果线上有个任务卡住了,根本不知道是在哪个环节出了问题。后来才加上了完整的 traceID 和 step 日志追踪机制。
踩过的坑:那些年我掉过的陷阱
1. 幂等性缺失引发重复扣款
某次促销活动中,用户余额被重复扣除。排查发现是因为消息队列中的 Confirm 操作被多次消费。
教训:所有的 Confirm/Cancel 操作必须携带唯一标识符,并在服务端做幂等校验。
// 示例:基于 Redis 的幂等校验
public void confirmBalance(String userId, String requestId) {
String key = "confirm_balance:" + userId + ":" + requestId;
Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1", 30, TimeUnit.MINUTES);
if (result == null || !result) {
log.warn("该余额确认已执行过");
return;
}
// 执行真正逻辑
balanceService.deduct(userId, amount);
}

2. Cancel 失败导致脏数据残留
有一次 Cancel 失败了,但上层流程已经结束,导致库存未释放。客户投诉商品明明“买到了”,但库存却还是原来的数。
解决方案是:
- 增加 Cancel 失败的重试策略(比如三次重试)
- 引入定时扫描 job,兜底处理超时未取消的数据
3. TCC 接口设计不当
最开始 Try 接口没有返回值,导致 Confirm/Cannel 不知道到底有没有成功执行。后来改成带返回码和预留 ID 的结构,才方便下游判断。
实施后的效果与收益
项目上线之后,系统稳定性显著提升:
- 事务一致性达到 99.999%
- 平均响应时间控制在 300ms 内
- 高峰期每秒处理能力超过 1w TPS
我们还在后台搭建了一个可视化监控看板,展示不同事务阶段的成功率、耗时分布、错误类型统计等,极大提升了排查效率。
更重要的是,我们的开发流程也更加规范了:
- 所有涉及资金变动的操作必须走事务流程
- 提交前必须跑事务测试套件
- 所有事务操作要有完整日志追踪(traceId + spanId)
我的经验总结与建议
经过几年在多个项目上的尝试与打磨,我总结出几点实用经验:
✅ 1. 不要过度追求“强一致性”
很多时候我们陷入误区,总觉得所有操作都必须严格原子化。其实,很多业务可以接受“最终一致性”。例如退款、物流状态更新就可以用 Saga 或者消息队列来解耦。
✅ 2. 做好事务的隔离与限界上下文划分
不要把一个事务做得太大,要根据业务边界切分职责。比如订单、库存、账户这三个系统之间最好互不影响。
✅ 3. 引入可观测性是必须的
- 日志一定要带上 traceId/spanId
- 每个事务的生命周期要全程监控
- 异常情况要能快速定位并修复
✅ 4. 补偿机制不能少
即使再完善的系统,也可能因为网络抖动、服务不可用等问题导致失败。所以你得准备好补偿方案,比如定时 Job、人工干预接口。
✅ 5. 技术选型要结合团队成熟度
如果你的团队对 RocketMQ 不熟,就别强行搞消息事务模型;如果你的技术栈主要在 Spring Cloud,那不妨试试 Seata。
写在结尾:工程师的成长就是不断面对不确定
说实话,分布式事务不是一个“有标准答案”的话题。它更像是一个艺术,需要你在架构设计、容错机制、性能优化等多个维度进行权衡。
这几年我经历过凌晨三点紧急回滚版本,也经历过因为一个 Cancel 方法没写好导致整个库存系统瘫痪。但也正是这些“踩坑”的过程,让我慢慢成长为一个能独立承担责任的后端工程师。
我希望这篇分享,不只是告诉你“怎么解决分布式事务”,而是让你看到我在面对复杂问题时的思考方式和取舍思路。希望它对你也有启发。
如果你也在做微服务相关的项目,欢迎留言交流,我们一起成长 😊
作者:一位在一线敲代码的后端开发者,热爱技术也热爱生活。欢迎关注我的 GitHub 和公众号【TechGrower】。

评论 0