一个下单流程涉及订单、库存、优惠券多个服务,如何设计事务保证最终一致性?
在一个涉及订单、库存、优惠券的分布式场景中,追求最终一致性(Eventual Consistency)通常意味着放弃强一致性(如 2PC/XA),转而采用 BASE 理论。
针对你的场景,最常用且成熟的方案主要有三种:基于消息队列的事务消息(如 RocketMQ)、TCC(Try-Confirm-Cancel)模式 和 Seata 分布式事务框架。
以下是具体的方案设计与对比:
方案一:基于 RocketMQ 的事务消息(推荐:高并发、解耦)
这是互联网大厂最常用的方案,核心思想是将“本地事务执行”与“消息发送”绑定,确保订单创建成功,消息一定发出。
1. 核心流程
假设动作是:用户下单 -> 创建订单 -> 扣减库存 -> 使用优惠券。
- 发送半消息(Half Message):
- 订单服务(Producer)向 MQ 发送一条“预备下单”的消息。
- 此时消息对消费者(库存、优惠券服务)不可见。
- 执行本地事务:
- MQ 确认收到半消息后,订单服务执行本地数据库操作(
INSERT Order)。
- MQ 确认收到半消息后,订单服务执行本地数据库操作(
- 提交或回滚消息:
- 如果本地事务成功: 订单服务向 MQ 发送 Commit 指令。MQ 将消息标记为“可消费”,投递给库存和优惠券服务。
- 如果本地事务失败: 订单服务向 MQ 发送 Rollback 指令。MQ 删除该消息,后续流程终止。
- 消费消息(下游服务):
- 库存服务收到消息 -> 扣减库存。
- 优惠券服务收到消息 -> 核销优惠券。
- MQ 回查机制(兜底):
- 如果订单服务在第 3 步挂了(没发 Commit/Rollback),MQ 会定期回调订单服务接口,查询该订单是否真的创建成功,根据结果决定是投递还是删除消息。
2. 异常处理与最终一致性
- 下游失败怎么办? MQ 会自动重试。如果重试多次仍失败(如库存不足),进入死信队列(DLQ),此时需要人工介入或触发补偿机制(如自动发起退单流程,给用户退款并关闭订单)。
- 关键点: 下游服务必须保证幂等性(防止 MQ 重复投递导致重复扣库存)。
方案二:TCC (Try-Confirm-Cancel) 模式(推荐:强业务约束)
如果业务要求库存必须严格预占(防止超卖),且需要即时反馈失败,TCC 是更好的选择。它属于应用层的两阶段提交。
1. 三个阶段设计
- Try(预留资源):
- 订单服务: 创建状态为“处理中”的订单。
- 库存服务: 冻结库存(
UPDATE stock SET frozen = frozen + 1 WHERE id = x)。 - 优惠券服务: 冻结优惠券(标记为“预使用”)。
- Confirm(确认执行):
- 如果 Try 阶段所有服务都成功,协调者调用 Confirm。
- 订单服务: 更新订单状态为“已下单”。
- 库存服务: 正式扣减(
frozen - 1,total - 1)。 - 优惠券服务: 正式核销。
- Cancel(取消/补偿):
- 如果 Try 阶段任一服务失败(如库存不足),协调者调用 Cancel。
- 订单服务: 删除或取消订单。
- 库存服务: 释放冻结库存(
frozen - 1)。 - 优惠券服务: 解冻优惠券。
2. 优缺点
- 优点: 锁粒度小(仅锁行记录),并发性能优于 XA;控制灵活。
- 缺点: 代码侵入性极大,每个服务都要写 Try/Confirm/Cancel 三个接口;需要处理空回滚(未 Try 就 Cancel)和悬挂(Cancel 比 Try 先到)问题。
方案三:本地消息表(Local Message Table)
如果不使用支持事务消息的 MQ(如 RabbitMQ, Kafka),可以使用此方案。
1. 核心流程
- 事务绑定: 在订单数据库中,新增一张
message表。 - 动作: 在同一个本地数据库事务中,写入
Order表 和message表(状态为“待发送”)。 - 异步发送:
- 方式 A: 启动一个定时任务(Poller),轮询
message表,将“待发送”的消息投递到 MQ,发送成功后更新为“已发送”。 - 方式 B: 利用 CDC(如 Canal)监听数据库 Binlog,自动投递到 MQ。
- 方式 A: 启动一个定时任务(Poller),轮询
- 下游消费: 库存和优惠券服务消费 MQ,成功后通过 ACK 机制确认。
2. 优缺点
- 优点: 不依赖特定 MQ 中间件,实现简单可靠。
- 缺点: 消息表与业务库耦合,高并发下数据库 I/O 压力大。
方案四:Seata (AT 模式)
如果你使用的是 Spring Cloud Alibaba 技术栈,Seata 是最简单的方案。
1. 核心流程
- 使用
@GlobalTransactional注解。 - Seata 会拦截 SQL,自动生成 UNDO LOG(回滚日志)。
- 如果后续流程报错,Seata 协调器(TC)会通知各分支事务利用 UNDO LOG 进行反向 SQL 回滚。
2. 适用性
- 适合业务流程不长、并发量中等的内部系统。
- 高并发场景下,全局锁可能会影响性能。
总结与建议
针对“下单”这个核心高并发场景,推荐组合方案:
- 核心链路(防超卖): 使用 TCC 或 Redis Lua 脚本 预扣库存。因为库存是热点资源,必须在下单前置校验并锁定。
- 异步解耦(最终一致): 使用 RocketMQ 事务消息。
- 用户点击下单 -> TCC 预扣库存/Redis 扣减 -> 发送 MQ 事务消息 -> 创建订单 -> 提交 MQ。
- 下游(积分、优惠券、通知服务)异步消费 MQ。
- 兜底补偿:
- 如果下游消费失败(如优惠券系统挂了),MQ 重试。
- 如果彻底失败,记录异常日志,人工客服介入或触发自动退款流程。
必须注意的技术细节:
- 幂等性设计: 所有的下游接口(扣库存、用券)必须支持幂等(利用订单号作为唯一 Key,在 Redis 或数据库建立唯一索引)。
- 顺序性: 确保状态流转顺序(如:必须先创建订单,再扣款)。