管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:延迟关单与跨服务一致性实战

3560 字
18 分钟
多来买:延迟关单与跨服务一致性实战

多来买:延迟关单与跨服务一致性实战#

本篇范围:延迟消息、订单与库存状态机、超时关单、事务边界、失败场景和演进方案
事实来源:当前 OrderControllerDelayOrderConsumerOrderServiceImpl、支付与仓储协作代码
验证口径:代码已确认;延迟消费者当前未自动启用,未运行 RocketMQ 或故障注入

本篇不重复订单创建和仓储实现细节,而是把它们放入同一张状态与失败地图中:一张订单从 UNPAID 走到支付、拆单、待发货或关闭时,哪些步骤有本地事务,哪些步骤只能依赖幂等、重试和补偿。

延迟关单计划链路#

sequenceDiagram participant Order as "order-service" participant MQ as "RocketMQ" participant Consumer as "DelayOrderConsumer" participant Pay as "pay-service / 支付宝" participant DB as "订单数据库" Order->>MQ: 发送 orderId 延迟消息 Note over Order,MQ: 当前发送结果未检查 MQ-->>Consumer: 到期投递 Note over Consumer: 当前 @PostConstruct 已注释 Consumer->>DB: 查询订单状态 Consumer->>Pay: 必要时查询支付状态 alt 确认未支付 Consumer->>DB: 尝试关闭订单 else 已支付或状态未知 Consumer-->>MQ: 保留或等待后续处理 end

阅读目标#

  • 说明订单过期时间、RocketMQ 延迟级别和代码注释为何不是同一个时间。
  • 说明消费者代码存在不等于消费者已经随应用启动。
  • 用状态机描述支付成功、拆单、锁库存和关闭订单之间的竞争。
  • 列出订单保存、购物车、消息、支付和仓储各自的事务边界。
  • 区分代码已确认的缺口与尚未运行验证的候选风险。

第七步:发送延迟订单消息#

Producer 调用#

代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.java

baseProducer.sendDelayMessage(
MqTopicConst.DELAY_ORDER_TOPIC,
orderId,
7);

通用 Producer 会:

代码路径:duolaimall-common/common-mq/src/main/java/com/cskaoyan/mall/mq/producer/BaseProducer.java

String jsonMessage = JSON.toJSONString(messageBody);
Message message = new Message(
topicName,
jsonMessage.getBytes(Charset.forName("utf-8")));
message.setDelayTimeLevel(delayLevel);
SendResult sendResult = mqProducer.send(message);
if (sendResult == null || sendResult.getSendStatus() == null) return false;
if (sendResult != null) {
SendStatus sendStatus = sendResult.getSendStatus();
if (sendStatus.equals(SendStatus.SEND_OK)) {
log.info("延迟消息发送成功,topic:{}, message:{}", topicName, jsonMessage);
return true;
} else {
return false;
}
}

当前 Controller 没有检查 Boolean 返回值。消息发送失败时,订单仍会成功返回给客户端。

三套时间并不一致#

代码已确认:

  • 订单 expireTime:当前时间加 1 分钟;
  • 实际发送 delayLevel = 7:RocketMQ 内置级别约 3 分钟;
  • 代码中另有“30 分钟”注释或常量设计痕迹,但当前调用未使用。

所以不能说“订单会在 expireTime 到达时自动准确关闭”。

Consumer 当前没有自动启动#

代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/mq/DelayOrderConsumer.java

@Component
public class DelayOrderConsumer {
@Autowired
OrderService orderService;
// @PostConstruct
public void init() throws MQClientException {
DefaultMQPushConsumer consumer =
new DefaultMQPushConsumer(
"delay-order-group");
// 省略注册中心配置和订阅
consumer.setMessageListener(
new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(
List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
Long orderId = JSON.parseObject(
new String(msgs.get(0).getBody()),
Long.class);
orderService.execExpiredOrder(orderId);
return ConsumeConcurrentlyStatus
.CONSUME_SUCCESS;
}
});
consumer.start();
}
}

因为 @PostConstruct 已注释,Spring 创建组件时不会自动调用 init()。虽然监听代码和 start() 存在,当前进程不会自动注册这个消费者。

订单与库存状态机#

订单状态#

stateDiagram-v2 [*] --> UNPAID: 创建普通订单 UNPAID --> PAID: 支付成功通知 UNPAID --> CLOSED: 超时关单方法存在,消费者未自动启用 PAID --> SPLIT: 多仓时仅父订单进入 PAID --> WAIT_DELEVER: 单仓订单或拆分后的子订单锁库存成功 PAID --> STOCK_EXCEPTION: 单仓订单或拆分后的子订单库存异常 note right of SPLIT 父订单保持 SPLIT; 子订单从 PAID 继续流转。 end note

代码枚举还包含发货、评价和支付失败等状态,但本文链路没有展开它们。

库存任务状态#

TaskStatus当前语义
PAID支付后创建,等待库存处理
SPLIT原工作单已按仓拆分
DEDUCTEDlockStock 成功时写入;多仓拆分时子工作单也会在实际锁库前暂写此值,随后由 lockStock 覆盖结果
OUT_OF_STOCK指定仓库可用库存不足
DELEVERED枚举存在,本文主链未见完整出库实现

DEDUCTEDlockStock 成功分支只表示增加了 stock_locked,并非实际出库扣减;多仓拆分代码还会在锁库前把子工作单暂置为该状态,因此判断最终结果必须结合调用阶段。

延迟关单业务逻辑#

为什么不能只看本地 UNPAID#

支付发生在超时边界时可能出现:

  1. 用户已经在支付宝支付成功;
  2. 支付宝回调因网络延迟尚未到达;
  3. 订单库仍是 UNPAID
  4. 延迟消费者此时执行。

若直接关单,就会出现“钱已付、订单却关闭”。当前 execExpiredOrder 因此会查询本地支付流水和支付宝状态。

当前静态流程#

OrderInfo orderInfo =
orderInfoMapper.selectById(orderId);
if (!OrderStatus.UNPAID.name()
.equals(orderInfo.getOrderStatus())) {
return;
}
String outTradeNo =
orderInfo.getOutTradeNo();
PaymentInfoDTO paymentInfo =
payApiClient
.getPaymentInfoDTOByOutTradeNo(
outTradeNo);
if (paymentInfo == null) {
orderInfo.setOrderStatus(
OrderStatus.CLOSED.name());
orderInfoMapper.updateById(orderInfo);
return;
}

如果有本地支付流水,再主动查询支付宝:

Result alipayInfo =
payApiClient.getAlipayInfo(outTradeNo);
Map<String, String> result =
(Map<String, String>) alipayInfo.getData();
String subCode = result.get("subCode");
String tradeStatus =
result.get("tradeStatus");
if ("TRADE_SUCCESS".equals(tradeStatus)) {
return;
}

其他状态会尝试关闭订单、关闭本地支付流水或关闭支付宝交易。

当前超时闭环仍未生效#

即使业务方法存在,也有以下代码事实:

  1. 延迟 Consumer 的 @PostConstruct 被注释;
  2. 订单过期时间与延迟级别不一致;
  3. 发送结果没有检查;
  4. 支付服务本地 updatePaymentStatus 为空;
  5. WAIT_BUYER_PAY 分支判断 "null".equals(subCode),真实空值是否匹配存在疑问;
  6. 订单关闭和支付成功都没有条件状态迁移,存在竞态。

因此只能说“代码设计了延迟关单判断”,不能说“未支付订单会被可靠自动关闭”。

事务、并发、幂等与一致性边界#

事务地图#

提交前 Feign 库存预查 不在订单事务
提交前 Feign 实时核价 不在订单事务
saveOrderInfo 主表 + 明细 普通订单入口:本地事务
删除购物车 订单事务提交后
发送延迟消息 订单事务提交后
支付服务更新支付流水 支付服务独立提交
订单状态改 PAID 订单服务独立提交
仓储任务与库存锁定 仓储本地事务意图,存在自调用代理风险
多仓子订单保存 跨多次本地写,整体非原子

重复提交#

普通订单接口没有交易码或请求幂等键。客户端超时重试可能:

  • 生成新的雪花 outTradeNo
  • 保存另一张内容相同的订单;
  • 再次删除购物车;
  • 再发送一条延迟消息。

重复支付通知#

订单 successPay 没有条件更新,仓储 saveWareOrderTask 的返回值又被忽略。若支付通知在“订单已处理但响应丢失”后重试,可能再次进入库存锁定。

库存并发#

正确的目标应是:

开始事务
-> 按稳定顺序 SELECT ... FOR UPDATE
-> 检查所有明细
-> 更新所有 stock_locked
提交事务

当前 SQL 具备行锁和更新能力,但事务注解是否通过代理生效需要修正或运行证据。多个订单按不同 SKU 顺序加锁时,也可能产生数据库死锁并需要重试策略。

跨服务失败#

OpenFeign 只告诉当前调用“成功或失败”,不能让之前已提交的服务自动回滚。跨服务需要幂等、可靠事件、补偿或状态扫描,不能依赖一个 @Transactional 注解。

关键场景静态推演#

以下是基于当前工作树的静态推演,不是接口实测。

正常单仓订单#

POST /order/auth/submitOrder
Content-Type: application/json
userId: <Gateway 写入>
{
"consignee": "<收货人>",
"deliveryAddress": "<地址>",
"orderDetailList": [
{
"skuId": 101,
"skuName": "<商品>",
"orderPrice": 99.00,
"skuNum": 2
}
]
}

推演:

  1. 仓储总可用库存通过;
  2. 商品实时价与请求价 equals
  3. 保存 UNPAID 主表和明细;
  4. 删除购物车 SKU;
  5. 发送延迟消息;
  6. 支付成功后创建单仓工作单;
  7. 指定仓库行锁检查通过;
  8. stock_locked += 2
  9. 订单变成 WAIT_DELEVER

价格变化#

库存预查可能已通过,但实时价不同:

  • 通知购物车刷新价格;
  • 不保存订单;
  • 返回“价格发生变动”。

预查通过、支付后库存不足#

两个用户都在预查时看到库存充足。支付后最终行锁阶段,其中一个先锁定库存,另一个再计算可用库存不足:

  • 库存任务变 OUT_OF_STOCK
  • 订单变 STOCK_EXCEPTION
  • 当前代码未展示退款或人工处理闭环。

订单保存成功、消息发送失败#

Controller 忽略 Producer 返回值,仍返回订单 ID。由于消费者本身也未自动启动,这张未支付订单不会因当前延迟链自动关闭。

空明细提交#

当前代码会遍历空集合,然后在构造 tradeBody 时访问第 0 条明细,可能抛异常。没有看到统一的空订单参数校验。

当前实现的亮点与真实取舍#

亮点#

  1. 确认页由订单服务聚合地址与已选商品;
  2. 提交前同时复核库存和实时价格;
  3. 订单主表和明细在普通下单入口使用本地事务;
  4. 库存预查和最终行锁检查分层;
  5. 多仓 SKU 可以转换为父子订单和分仓工作单;
  6. 最终库存 SQL 使用 stock - stock_locked
  7. 超时逻辑意识到支付回调延迟竞态,不是简单看到 UNPAID 就关单。

真实取舍#

  • 同步 Feign 易理解,但下单依赖多个服务同时可用;
  • 先落订单再删购物车避免误删,但会留下购物车残留;
  • 支付后再锁库存降低未支付占用,但可能出现付款后库存异常;
  • 行锁可以保护数据库行,但吞吐、锁顺序和事务代理必须正确;
  • 延迟消息适合超时任务,但需要消费者、时长和补偿一起闭环。

已确认限制与候选风险#

结论证据等级直接依据影响
普通提交没有交易码防重代码已确认submitOrder 未读写交易码重复请求可能生成重复订单
请求数量和商品信息校验不完整代码已确认主要只复核库存与价格可能接受异常明细
价格使用 BigDecimal.equals代码已确认submitOrderscale 不同也会拒绝
空明细会读取第 0 项代码已确认get(0)可能抛异常
购物车删除在事务外代码已确认Controller 调用顺序失败后订单与购物车不一致
延迟消息返回值未检查代码已确认sendDelayMessage 调用发送失败仍返回下单成功
Consumer 没有自动启用代码已确认@PostConstruct 已注释超时逻辑不会自动消费
1 分钟过期与 3 分钟消息不一致代码已确认expireTime 与 level 7状态时间语义冲突
支付成功先改订单再调仓储代码已确认successPay仓储失败留下 PAID
仓储去重结果被忽略代码已确认saveWareOrderTask 返回值未使用可能重复锁库存
库存事务方法通过 this 调用候选风险Spring 代理自调用行锁与更新可能不在同一事务
stock_locked 没有释放闭环代码已确认当前仓储主链关单后库存可能持续占用
最终实物扣减未确认未验证当前 SQL 只增加锁定量不能宣称完成出库扣减
数据库唯一索引未知未验证仓库无建表脚本订单号和任务幂等能力未知

如果重新设计会怎样演进#

以下是改进方向,不是当前代码已经具备的能力。

下单入口#

  • 由服务端按 SKU 重新构建商品快照;
  • 校验数量范围、上下架状态和地址归属;
  • 使用一次性交易码或客户端请求号建立唯一约束;
  • 价格使用 compareTo
  • 对空明细和重复 SKU 做明确校验。

订单与购物车#

一种方案:

本地事务保存订单
-> 同事务写 order_created_outbox
-> 提交后可靠发布
-> cart-service 幂等消费删除 SKU
-> 延迟关单消息由 outbox 保证投递

库存处理#

  • 把库存选择和锁定放入一个明确的仓储事务入口;
  • 避免同类自调用,或通过独立 Bean 进入事务代理;
  • order_id / 工作单业务号建立唯一约束;
  • 使用稳定 SKU 顺序加锁并对死锁重试;
  • 订单取消释放 stock_locked
  • 发货时把锁定量转为实际扣减量;
  • 库存异常要有退款或人工补偿链。

状态机#

订单状态更新使用条件:

-- 改进示例,不是当前 Mapper
UPDATE order_info
SET order_status = 'PAID'
WHERE id = ?
AND order_status = 'UNPAID';

仓储任务也用“待处理 → 已锁定/库存不足”的条件迁移,重复请求读取已有结果,而不是再次执行库存写入。

超时关闭#

  • 统一订单 expireTime、支付宝过期时间和消息延迟;
  • 确保消费者随应用启动;
  • 关单前查询支付平台;
  • 修正空值判断;
  • 支付成功与关单使用条件状态竞争;
  • 建立延迟消息丢失扫描和库存释放补偿。

关键源码导航#

阅读顺序文件关键方法
1duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.javasubmitOrder 中发送延迟消息
2duolaimall-common/common-mq/src/main/java/com/cskaoyan/mall/mq/producer/BaseProducer.javasendDelayMessage
3duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/mq/DelayOrderConsumer.javainit、消息监听
4duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.javaexecExpiredOrder
5duolaimall-order/order-api/src/main/java/com/cskaoyan/mall/order/constant/OrderStatus.java订单状态
6duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/service/impl/PayServiceImpl.java支付状态查询协作

4 条短复习点#

  1. 延迟消息是触发检查,不应被表述为到点必然关闭。
  2. 当前消费者未自动启动,因此超时闭环只是代码存在,不是运行已验证能力。
  3. 本地事务不能覆盖购物车、MQ、支付和仓储,跨服务必须靠状态机与补偿。
  4. 支付通知、关单和库存任务都应采用条件状态迁移,不能只做无条件覆盖。

本章总结#

延迟消息只是重新检查订单状态的触发器,不能代替可靠关单;当前消费者启动、时间配置和跨服务补偿仍有缺口,因此这条链路只能表述为代码已存在而非运行闭环。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:延迟关单与跨服务一致性实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-order-delay-close-consistency/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录