分布式事务的实战思考:我是如何解决一次订单超卖问题的
开篇:为什么我们要关心分布式事务?

如果你在一家互联网公司做后端开发,尤其是在电商、金融等对数据一致性要求极高的业务场景下,那么「分布式事务」这四个字一定是你绕不开的话题。
我也一样,在我们公司去年的一次大促活动中,就遇到了一个典型的“超卖”问题。当时,我们的库存服务和订单服务是独立部署的两个微服务系统,由于在创建订单和扣减库存之间出现了并发冲突,导致部分商品被超额售出。这个问题一出现,直接影响到了用户的下单体验,也暴露出我们在分布式环境下对事务控制的不足。
今天,我想结合这段实际经历,来聊聊我在分布式事务上踩过的坑、学过的教训,以及我们最终是如何落地一套可接受的解决方案的。
问题描述:一次失败的大促引发的危机

项目背景
我们公司的电商平台采用的是微服务架构,核心服务包括:
- 订单服务(OrderService)
- 库存服务(InventoryService)
- 支付服务(PaymentService)
其中,订单服务负责接收用户下单请求,调用库存服务扣减库存,再调用支付服务发起支付流程。
遇到的问题
在某个节日大促期间,系统突然出现了一个严重问题:同一SKU的商品在并发下单时出现超卖现象。也就是说,当多个用户同时尝试购买一件仅剩1件的商品时,系统允许了两个甚至更多的订单生成,并且都成功扣减了库存,造成了负库存的情况。
我们第一时间查看日志,发现根本原因在于订单与库存之间的操作没有保证事务一致性。虽然订单服务和库存服务各自内部使用了数据库本地事务,但由于这两个服务之间是通过 RPC 调用进行交互的,因此无法做到跨系统的事务控制。
这种问题是典型的分布式环境下事务不一致问题,也促使我们开始认真地审视整个系统的事务设计。
解决方案选型:从尝试各种方式到落地Saga模式

面对这个挑战,我们一开始尝试了多种常见的分布式事务解决方案,包括:
1. TCC(Try - Confirm - Cancel)
TCC 是一种比较成熟的分布式事务模式,适用于对性能要求较高但业务逻辑可控的场景。它需要为每个参与服务实现三个接口:
- Try:资源预占
- Confirm:真正执行
- Cancel:补偿回滚
我们尝试过在库存服务中增加 Try 接口来做库存预占,订单确认后再执行 Confirm 或者 Cancel。但在实际应用中,我们发现这种方式在实现成本上太高,尤其是对于一些复杂的业务链路来说,维护 Try/Confirm/Cancel 的状态转换非常繁琐,容易出错。
更关键的是,TCC 对开发者的要求非常高,一旦逻辑处理不当,很容易留下脏数据,反而影响系统的健壮性。
2. 消息队列 + 事务消息
我们还考虑过使用消息队列来进行异步解耦和事务消息的方式处理订单与库存的一致性。比如 RocketMQ 支持事务消息机制,可以确保本地事务与消息发送的原子性。
但在实际测试过程中发现,这种方案虽然能提升可用性,但对于强一致性要求较高的核心下单流程来说显得有些“脱节”。而且在异常处理和重试机制的设计上也需要大量的兜底代码。
3. 最终选择 Saga 模式
最终,我们决定采用 Saga 分布式事务模式,这是一个相对轻量级但又不失灵活性的解决方案。
Saga 模式的基本思想是将一个长周期的分布式事务拆分为多个本地事务,每个步骤都带有对应的补偿操作。如果某一步骤失败,则依次触发前面步骤的逆操作来回滚整个流程。
这种方式的优势在于:
- 不需要全局锁,性能较好
- 实现复杂度较低
- 可以灵活配合业务逻辑定制补偿行为
当然,Saga 的最大缺点是不能保证强一致性,而是最终一致性,但这对我们当时的业务场景是可以接受的。
代码实践:用Spring Boot + Redis构建一个轻量版Saga事务流程

我们采用 Java 技术栈,用 Spring Boot 构建服务,Redis 来做 Saga 流程的状态管理。以下是一个简化版本的核心流程示意:
核心服务结构
public class OrderService {
@Autowired
private InventoryClient inventoryClient;
@Autowired
private PaymentClient paymentClient;
@Autowired
private SagaStateManager sagaStateManager;
public void placeOrder(Order order) {
String sagaId = UUID.randomUUID().toString();
try {
// 步骤一:扣减库存
boolean inventoryResult = inventoryClient.deductStock(order.getSku(), 1);
if (!inventoryResult) {
throw new RuntimeException("库存不足");
}
sagaStateManager.recordStep(sagaId, "deduct_stock", order.getSku(), 1);
// 步骤二:创建订单
order.setStatus("created");
orderRepository.save(order);
sagaStateManager.recordStep(sagaId, "create_order", order.getId());
// 步骤三:支付
boolean payResult = paymentClient.processPayment(order.getUserId(), order.getTotalPrice());
if (!payResult) {
throw new RuntimeException("支付失败");
}
sagaStateManager.recordStep(sagaId, "process_payment", order.getId());
} catch (Exception e) {
// 触发补偿回滚
sagaStateManager.rollback(sagaId);
throw e;
}
}
}
补偿器设计示例
public class SagaStateManager {
@Autowired
private InventoryClient inventoryClient;
public void rollback(String sagaId) {
List<SagaStep> steps = getStepsBySagaId(sagaId); // 假设已记录各步骤
for (int i = steps.size() - 1; i >= 0; i--) {
SagaStep step = steps.get(i);
switch (step.getType()) {
case "deduct_stock":
inventoryClient.restoreStock(step.getSku(), step.getAmount());
break;
case "create_order":
orderRepository.deleteById(step.getOrderId());
break;
case "process_payment":
refund(step.getOrderId());
break;
default:
break;
}
}
}
public void recordStep(String sagaId, String type, String sku, int amount) {
// 使用 Redis 记录当前 Saga 状态
redisTemplate.opsForList().rightPush("saga:" + sagaId, new SagaStep(type, sku, amount));
}
// 省略其他辅助方法...
}
Redis 存储结构设计
为了记录 Saga 的执行步骤和状态,我们使用 Redis 的 list 类型来临时存储每一步的操作信息。例如:
saga:abc123 -> [ {type: 'deduct_stock', sku: 'sku_001', amount: 1}, ... ]
并且我们设置一个合理的 TTL(如1小时),防止 Saga 状态长期滞留。
踩坑经验分享:这些细节千万别忽视!
在实施这套 Saga 模式的分布式事务方案过程中,我们也遇到了不少“坑”,以下是一些我亲身经历的教训:
1. Saga状态丢失,导致补偿失效
最开始我们把 Saga 的执行状态存放在内存里,结果遇到服务重启或者网络异常,直接导致状态丢失,无法触发回滚。后来改为 Redis 存储,并引入事务日志表进行双重保障,才解决了这个问题。
建议做法:
- Saga 状态必须持久化
- 使用 Redis + 数据库双写兜底,避免单点故障
- 给每一步加上时间戳,方便后续排查
2. 幂等性问题
Saga 在失败后的补偿操作可能会多次被执行,比如由于重试机制导致同一个 cancel 操作被执行两次。如果没有幂等性保障,就会造成库存多恢复、退款重复等问题。
解决办法:
- 每个补偿操作带上唯一标识(如 sagaId + stepId)
- 使用数据库乐观锁或 Redis setNx 来保障操作只执行一次
3. 超时机制缺失,导致系统雪崩
Saga 执行过程没有设定合理的超时机制,会导致某些长时间未完成的 Saga 占满线程池,甚至影响其他正常业务流程。
优化措施:
- 为 Saga 设置最大生命周期(TTL)
- 引入监控告警,对超长运行的 Saga 提前预警
4. 业务补偿逻辑过于简单,导致状态混乱
最初我们写的补偿逻辑非常粗糙,例如只是简单的恢复库存而没有检查是否已发生过退款。后来我们逐步完善补偿逻辑,加入了前置校验、状态判断等逻辑。
经验总结:
- 补偿操作要尽量完整,不能“拍脑袋”
- 补偿要有边界条件判断,不能盲目回退
效果总结:性能+稳定性明显提升
在上线 Saga 模式并进行一系列优化后,我们的分布式事务流程变得更加稳定可靠,主要体现在以下几个方面:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 超卖事件 | 每次大促都有发生 | 完全杜绝 |
| 下单成功率 | ~98% | ~99.95% |
| 系统响应延迟 | 平均 300ms | 平均 180ms |
| 运维成本 | 高频报警,需人工介入 | 报警显著减少,自动恢复能力增强 |
特别是在双十一这样的大规模促销活动期间,系统保持了良好的稳定性,没有再出现类似的历史问题,大大提升了用户体验和运营信心。
经验分享:分布式事务不是银弹,但必须有预案
作为经历过这场“分布式事务之战”的一线后端工程师,我想给正在面临类似问题的同行们几点建议:
✅ 明确你的业务一致性需求
并不是所有场景都需要强一致性,很多情况下最终一致性已经足够。Saga 模式更适合那种可以容忍一定延迟、但对失败恢复有明确路径的业务场景。
✅ 尽量简化事务链路
能用本地事务搞定的事情,不要轻易跨服务。我们后来重构了一些核心业务模块,把部分高频交互的服务合并成一个单元,大幅减少了跨事务的需求。
✅ 兼容性和扩展性要考虑周全
在设计 Saga 方案的时候,我们就预留了“自定义补偿处理器”的接口,这样未来如果有新的服务加入 Saga 流程,也能快速对接。
✅ 监控 & 告警必须做起来
Saga 本质上是一种“柔性事务”,它的失败不会立刻暴露出来。所以我们一定要建立完整的日志追踪体系和监控报警机制,及时发现问题才能防患于未然。
✅ 多方案对比选择,没有最优只有最合适
TCC、Seata、消息事务、Saga……这些都不是银弹。你需要根据自己的团队能力、技术栈和业务需求来综合权衡。我们当时评估了几种方案,选择了最容易落地、风险最小的那一套。
写在最后:技术之外的思考
回顾这段经历,最大的收获不仅仅是掌握了一套分布式事务的解决方法,更重要的是学会了从业务的角度去理解技术的本质。
有时候,我们太容易陷进“高并发、高性能”的技术追求里,却忽略了真正的用户需求和业务目标。分布式事务之所以重要,是因为它背后承载的是一个个真实的交易、一笔笔用户的信任。
作为一名后端开发者,我们不仅要写出能跑的代码,更要写出能扛住压力、经得住时间检验的系统。希望这篇文章能给你带来一些灵感和启发,让你在今后的开发道路上少走弯路,多一份从容。
如果你有任何想法或疑问,也欢迎留言交流。毕竟,技术的进步永远来自于不断的学习与碰撞。
— End —

评论 0