16-订单业务MQ架构:创建订单、支付回调、出货完成、订单归档
作者:黒漂技术佬 系列:RocketMQ 核心原理与无人售货柜项目实战 面向:新手小白也能看懂的 MQ 架构设计指南
一、为什么要用 MQ 驱动订单流程?
先想象一个场景:你在一台无人售货柜前扫码下单,系统需要同时完成"创建订单 → 扣减库存 → 请求支付",如果这三个操作写在一个同步方法里,任何一个环节卡住,用户就只能盯着屏幕转圈圈。更要命的是,支付成功后还要通知设备出货、更新订单状态、发积分------这一串操作如果全部串行执行,接口耗时轻松破秒。
这就是引入 消息队列(Message Queue) 的核心原因:解耦。订单服务只管把"订单创建好了"这件事告诉 MQ,之后库存服务、支付服务各取所需,互不干扰。支付回来也一样,发一条"钱到账了"的消息,订单服务、出货服务、积分服务各干各的。
消息队列本质上是一个"异步通信的中间人":生产者把消息扔进去就不管了,消费者按自己的节奏取消息处理。RocketMQ 是这个领域的国产优秀选手,阿里出品,经双十一验证,特别适合我们这种订单驱动的业务场景。
二、订单全生命周期消息流转设计
无人售货柜的订单从创建到归档,一共经历四个关键节点,每个节点由一条 MQ 消息驱动:
| 阶段 | 生产者 | 消息 | 消费者 | 做什么 |
|---|---|---|---|---|
| 1. 创建订单 | 订单服务 | 订单创建消息 | 库存服务 | 锁定库存(预占) |
| 2. 支付回调 | 支付服务 | 支付成功消息 | 订单服务 + 出货服务 | 更新订单状态 + 触发出货 |
| 3. 出货完成 | 设备服务 | 出货完成消息 | 订单服务 + 库存服务 | 完成订单 + 扣减库存 |
| 4. 订单归档 | 定时任务 | 归档消息 | 归档服务 | 迁移历史订单到归档库 |
下面逐一拆解。
三、Topic / Tag 设计
一个设计良好的 Topic 体系是 MQ 架构的基石。RocketMQ 中,Topic 是一类消息的逻辑分类,Tag 是 Topic 下面的二级标签,用于进一步区分消息类型。消费者可以用 Topic + Tag 精确订阅自己关心的消息。
我们的设计如下:
yaml
Topic: OrderTopic
├── Tag: ORDER_CREATED # 订单创建
├── Tag: ORDER_PAID # 支付成功
├── Tag: ORDER_DISPATCHED # 出货完成
└── Tag: ORDER_ARCHIVE # 订单归档
为什么所有订单相关消息放在同一个 Topic? 因为同一个 Topic 下的消息可以保证顺序消费(配合 MessageQueue 选择器),这对于同一笔订单的状态流转至关重要------你必须先"创建"再"支付"再"出货",不能乱。
消费者订阅关系:
- 库存服务:订阅
OrderTopic,Tag 为ORDER_CREATED || ORDER_DISPATCHED - 支付服务:订阅
OrderTopic,Tag 为ORDER_CREATED(但一般支付由外部回调触发) - 出货服务:订阅
OrderTopic,Tag 为ORDER_PAID - 订单服务:订阅
OrderTopic,Tag 为ORDER_PAID || ORDER_DISPATCHED - 归档服务:订阅
OrderTopic,Tag 为ORDER_ARCHIVE
四、消息体设计(Message DTO)
消息体就是消息的"内容"。我们定义一个统一的订单消息结构:
java
public class OrderMessageDTO {
/**
* 消息版本号,用于后续消息格式升级兼容
*/
private int version = 1;
/**
* 全链路追踪ID,贯穿订单创建→支付→出货→归档
*/
private String traceId;
/**
* 消息类型,对应 Tag 值的枚举
*/
private String messageType; // ORDER_CREATED / ORDER_PAID / ...
/**
* 订单ID(幂等去重的唯一业务键)
*/
private Long orderId;
/**
* 设备ID(哪台售货柜)
*/
private String deviceId;
/**
* 用户ID
*/
private Long userId;
/**
* 商品列表
*/
private List<OrderItemDTO> items;
/**
* 订单金额(分)
*/
private Long amount;
/**
* 实付金额(分),支付回调时才赋值
*/
private Long paidAmount;
/**
* 支付渠道:WECHAT / ALIPAY
*/
private String payChannel;
/**
* 第三方支付流水号
*/
private String transactionId;
/**
* 消息产生时间戳(毫秒)
*/
private Long timestamp;
}
几个关键设计决策:
- traceId:从订单创建时就生成,后续所有消息都带上它,查问题时有这条线就能串起整个链路。
- version:为未来消息格式变更预留,消费者可以根据 version 选择不同的反序列化逻辑。
- 金额用 Long 存分:浮点数有精度问题,用"分"做单位,100 分 = 1 元,安全又简单。
五、每个环节的详细设计
5.1 环节一:创建订单 → 锁定库存
java
// ========== 生产者:订单服务 ==========
@Service
public class OrderService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Transactional
public CreateOrderResult createOrder(CreateOrderRequest request) {
// 1. 保存订单到数据库,状态为 PENDING_PAY
Order order = saveOrder(request);
// 2. 构建消息
OrderMessageDTO msg = OrderMessageDTO.builder()
.traceId(TraceContext.getTraceId())
.messageType("ORDER_CREATED")
.orderId(order.getId())
.deviceId(request.getDeviceId())
.userId(request.getUserId())
.items(request.getItems())
.amount(order.getTotalAmount())
.timestamp(System.currentTimeMillis())
.build();
// 3. 发送消息:订单ID作为key,保证同一订单的消息进同一个队列
// 同步发送确保消息不丢失
rocketMQTemplate.syncSendOrderly(
"OrderTopic:ORDER_CREATED",
MessageBuilder.withPayload(msg)
.setHeader(RocketMQHeaders.KEYS, String.valueOf(order.getId()))
.build(),
String.valueOf(order.getId()) // hashKey → 选择队列
);
return new CreateOrderResult(order.getId());
}
}
// ========== 消费者:库存服务 ==========
@Service
@RocketMQMessageListener(
topic = "OrderTopic",
selectorExpression = "ORDER_CREATED",
consumerGroup = "inventory-lock-group",
consumeMode = ConsumeMode.ORDERLY // 顺序消费
)
public class InventoryLockConsumer implements RocketMQListener<OrderMessageDTO> {
@Override
public void onMessage(OrderMessageDTO msg) {
// 幂等检查:是否已经锁定过
if (inventoryLocked(msg.getOrderId())) {
return;
}
// Lua脚本原子锁定:遍历商品列表,每个商品预占库存
for (OrderItemDTO item : msg.getItems()) {
boolean locked = inventoryService.lockStock(
item.getSkuId(), item.getQuantity(), msg.getOrderId()
);
if (!locked) {
// 库存不足,发送库存不足消息,触发订单关闭
sendStockInsufficientEvent(msg.getOrderId());
return;
}
}
// 记录锁定日志
saveLockRecord(msg.getOrderId());
}
}
幂等 是指同一个操作执行多次的结果和执行一次相同。这里用 inventoryLocked(orderId) 检查,防止消息重复消费导致重复锁定。
5.2 环节二:支付回调 → 更新订单 + 触发出货
当微信/支付宝回调通知我们"用户付钱了",支付服务发送支付成功消息:
java
// ========== 生产者:支付服务 ==========
@RestController
public class PayCallbackController {
@PostMapping("/callback/wechat")
public String wechatCallback(@RequestBody WechatPayNotify notify) {
// 1. 验签(防止伪造回调)
if (!wechatPayService.verifySignature(notify)) {
return "FAIL";
}
// 2. 解析订单ID
Long orderId = Long.valueOf(notify.getOutTradeNo());
// 3. 发送支付成功消息
OrderMessageDTO msg = buildPaidMessage(orderId, notify);
rocketMQTemplate.syncSendOrderly(
"OrderTopic:ORDER_PAID",
MessageBuilder.withPayload(msg)
.setHeader(RocketMQHeaders.KEYS, String.valueOf(orderId))
.build(),
String.valueOf(orderId)
);
return "SUCCESS";
}
}
// ========== 消费者:出货服务 ==========
@Service
@RocketMQMessageListener(
topic = "OrderTopic",
selectorExpression = "ORDER_PAID",
consumerGroup = "dispatch-group",
consumeMode = ConsumeMode.ORDERLY
)
public class OrderDispatchConsumer implements RocketMQListener<OrderMessageDTO> {
@Override
public void onMessage(OrderMessageDTO msg) {
// 1. 向设备下发出货指令(通过MQTT或WebSocket)
DispatchCommand cmd = DispatchCommand.builder()
.deviceId(msg.getDeviceId())
.orderId(msg.getOrderId())
.items(msg.getItems())
.build();
deviceService.sendDispatchCommand(cmd);
// 2. 记录出货任务
dispatchTaskService.createTask(msg.getOrderId(), msg.getDeviceId());
}
}
5.3 环节三:出货完成 → 完成订单 + 扣减库存
设备出货成功后,设备服务(或 IoT 网关)发送出货完成消息:
java
// ========== 消费者:订单服务 ==========
@Service
@RocketMQMessageListener(
topic = "OrderTopic",
selectorExpression = "ORDER_DISPATCHED",
consumerGroup = "order-finish-group",
consumeMode = ConsumeMode.ORDERLY
)
public class OrderFinishConsumer implements RocketMQListener<OrderMessageDTO> {
@Override
public void onMessage(OrderMessageDTO msg) {
// 状态机校验:只有 PAID 状态的订单才能流转到 FINISHED
Order order = orderService.getById(msg.getOrderId());
if (order.getStatus() != OrderStatus.PAID) {
// 状态不对,记录告警
log.warn("订单状态异常,当前:{},期望:PAID", order.getStatus());
return;
}
// 更新订单状态
orderService.updateStatus(msg.getOrderId(), OrderStatus.FINISHED);
}
}
// ========== 消费者:库存服务(扣减库存,区别于之前的锁定) ==========
@Service
@RocketMQMessageListener(
topic = "OrderTopic",
selectorExpression = "ORDER_DISPATCHED",
consumerGroup = "inventory-deduct-group",
consumeMode = ConsumeMode.ORDERLY
)
public class InventoryDeductConsumer implements RocketMQListener<OrderMessageDTO> {
@Override
public void onMessage(OrderMessageDTO msg) {
// 从预占转为实际扣减
for (OrderItemDTO item : msg.getItems()) {
inventoryService.deductStock(item.getSkuId(), item.getQuantity(), msg.getOrderId());
}
}
}
注意这里库存经历了两个阶段:锁定(预占)→ 实际扣减。锁定发生在下单时,防止超卖;实际扣减发生在出货后,因为只有货真的出去了才算消耗。
5.4 环节四:定时归档 → 迁移历史订单
java
// ========== 生产者:定时任务 ==========
@Component
public class OrderArchiveJob {
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点执行
public void archiveOrders() {
// 查询3天前完成的订单
List<Long> orderIds = orderService.findFinishedBefore(3);
for (Long orderId : orderIds) {
OrderMessageDTO msg = OrderMessageDTO.builder()
.traceId(UUID.randomUUID().toString())
.messageType("ORDER_ARCHIVE")
.orderId(orderId)
.timestamp(System.currentTimeMillis())
.build();
rocketMQTemplate.syncSend("OrderTopic:ORDER_ARCHIVE", msg);
}
}
}
// ========== 消费者:归档服务 ==========
@Service
@RocketMQMessageListener(
topic = "OrderTopic",
selectorExpression = "ORDER_ARCHIVE",
consumerGroup = "archive-group"
)
public class OrderArchiveConsumer implements RocketMQListener<OrderMessageDTO> {
@Override
public void onMessage(OrderMessageDTO msg) {
// 1. 把订单数据写入归档库(MongoDB 或独立的归档 MySQL)
Order order = orderService.getById(msg.getOrderId());
archiveRepository.save(order);
// 2. 从主库删除(可选,取决于业务需要保留多久)
orderService.softDelete(msg.getOrderId());
}
}
六、异常场景处理
真实世界不是理想世界,四条消息都可能出问题。
场景1:支付成功但出货失败怎么办?
出货指令下发后,设备可能因为"卡货"、"断网"等原因无法完成出货。这时候需要补偿退款:
java
// 出货服务定时扫描:超过5分钟还未收到出货完成的订单
@Scheduled(fixedDelay = 60000)
public void checkTimeoutDispatch() {
List<DispatchTask> timeoutTasks = dispatchTaskService.findTimeout(5);
for (DispatchTask task : timeoutTasks) {
// 发送退款消息
RefundMessageDTO refundMsg = RefundMessageDTO.builder()
.orderId(task.getOrderId())
.reason("出货超时未完成")
.amount(task.getAmount())
.build();
rocketMQTemplate.syncSend("RefundTopic:REFUND_REQUEST", refundMsg);
}
}
场景2:出货成功但订单更新失败
这种情况通常由数据库瞬时故障导致。RocketMQ 的消费失败默认会重试 16 次 (递增间隔),加上消息消费者的幂等设计(状态机校验),最终一定能成功。
场景3:库存锁定后订单超时未支付
用户下单后 15 分钟不付款,需要释放库存:
java
// 定时任务:扫描超时订单
@Scheduled(fixedDelay = 30000)
public void releaseTimeoutLocks() {
List<Order> timeoutOrders = orderService.findTimeoutPending(15);
for (Order order : timeoutOrders) {
orderService.updateStatus(order.getId(), OrderStatus.CLOSED);
// 发送库存回滚消息
rocketMQTemplate.syncSend("OrderTopic:ORDER_CLOSED",
buildCloseMessage(order.getId()));
}
}
库存服务收到 ORDER_CLOSED 消息后,回滚预占的库存。
七、消息时序保障
同一订单的创建 → 支付 → 出货 → 归档四条消息,必须保证严格有序。否则可能出现"还没支付就出货"的恐怖场景。
保障手段:
- syncSendOrderly :发送时用订单 ID 作为
hashKey,RocketMQ 会把相同 hashKey 的消息路由到同一个 MessageQueue。 - ConsumeMode.ORDERLY:消费者设置为顺序消费模式,同一队列的消息单线程处理。
- 状态机校验:消费者处理前检查订单当前状态,不符合预期就拒绝处理。
八、完整架构图
scss
┌──────────┐ 创建订单 ┌──────────────┐ ORDER_CREATED ┌──────────┐
│ 订单服务 │ ─────────────→ │ RocketMQ │ ──────────────────→ │ 库存服务 │
│ │ │ │ │ (锁定库存)│
└──────────┘ │ OrderTopic │ └──────────┘
│ │
┌──────────┐ 支付回调 │ │ ORDER_PAID ┌──────────┐
│ 支付服务 │ ─────────────→ │ ├─Q0─────── │ ──────────────────→ │ 订单服务 │
│(微信支付) │ │ ├─Q1─────── │ │(更新状态)│
└──────────┘ │ ├─Q2─────── │ └──────────┘
│ ├─Q3─────── │
┌──────────┐ 出货完成 │ │ ORDER_PAID ┌──────────┐
│ 设备服务 │ ─────────────→ │ │ ──────────────────→ │ 出货服务 │
│(IoT网关) │ │ │ │(下发指令)│
└──────────┘ └──────────────┘ └──────────┘
│
ORDER_DISPATCHED
│
┌───────────────┼───────────────┐
↓ ↓
┌──────────┐ ┌──────────┐
│ 订单服务 │ │ 库存服务 │
│(完成订单)│ │(扣减库存)│
└──────────┘ └──────────┘
┌──────────┐ 定时归档 ┌──────────────┐ ORDER_ARCHIVE ┌──────────┐
│ 定时任务 │ ─────────────→ │ RocketMQ │ ──────────────────→ │ 归档服务 │
│(每天3点) │ │ OrderTopic │ │(MongoDB) │
└──────────┘ └──────────────┘ └──────────┘
九、订单状态机
java
public enum OrderStatus {
PENDING_PAY, // 待支付
PAID, // 已支付(等待出货)
DISPATCHING, // 出货中
FINISHED, // 已完成
CLOSED, // 已关闭(超时/退款)
REFUNDING, // 退款中
REFUNDED // 已退款
}
// 合法的状态流转
// PENDING_PAY → PAID(支付成功)
// PENDING_PAY → CLOSED(超时关闭)
// PAID → DISPATCHING → FINISHED(正常出货)
// PAID → REFUNDING → REFUNDED(出货失败退款)
订单 MQ 架构的核心心法就八个字:异步解耦、消息驱动。每个服务只关心自己订阅的消息,出问题各自重试,互不阻塞。下一篇我们聊库存异步更新,看看早高峰集中购买时如何扛住并发压力。