多来买:延迟关单与跨服务一致性实战
多来买:延迟关单与跨服务一致性实战
本篇范围:延迟消息、订单与库存状态机、超时关单、事务边界、失败场景和演进方案
事实来源:当前OrderController、DelayOrderConsumer、OrderServiceImpl、支付与仓储协作代码
验证口径:代码已确认;延迟消费者当前未自动启用,未运行 RocketMQ 或故障注入
本篇不重复订单创建和仓储实现细节,而是把它们放入同一张状态与失败地图中:一张订单从 UNPAID 走到支付、拆单、待发货或关闭时,哪些步骤有本地事务,哪些步骤只能依赖幂等、重试和补偿。
延迟关单计划链路
阅读目标
- 说明订单过期时间、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
@Componentpublic 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() 存在,当前进程不会自动注册这个消费者。
订单与库存状态机
订单状态
代码枚举还包含发货、评价和支付失败等状态,但本文链路没有展开它们。
库存任务状态
TaskStatus | 当前语义 |
|---|---|
PAID | 支付后创建,等待库存处理 |
SPLIT | 原工作单已按仓拆分 |
DEDUCTED | lockStock 成功时写入;多仓拆分时子工作单也会在实际锁库前暂写此值,随后由 lockStock 覆盖结果 |
OUT_OF_STOCK | 指定仓库可用库存不足 |
DELEVERED | 枚举存在,本文主链未见完整出库实现 |
DEDUCTED 在 lockStock 成功分支只表示增加了 stock_locked,并非实际出库扣减;多仓拆分代码还会在锁库前把子工作单暂置为该状态,因此判断最终结果必须结合调用阶段。
延迟关单业务逻辑
为什么不能只看本地 UNPAID
支付发生在超时边界时可能出现:
- 用户已经在支付宝支付成功;
- 支付宝回调因网络延迟尚未到达;
- 订单库仍是
UNPAID; - 延迟消费者此时执行。
若直接关单,就会出现“钱已付、订单却关闭”。当前 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;}其他状态会尝试关闭订单、关闭本地支付流水或关闭支付宝交易。
当前超时闭环仍未生效
即使业务方法存在,也有以下代码事实:
- 延迟 Consumer 的
@PostConstruct被注释; - 订单过期时间与延迟级别不一致;
- 发送结果没有检查;
- 支付服务本地
updatePaymentStatus为空; WAIT_BUYER_PAY分支判断"null".equals(subCode),真实空值是否匹配存在疑问;- 订单关闭和支付成功都没有条件状态迁移,存在竞态。
因此只能说“代码设计了延迟关单判断”,不能说“未支付订单会被可靠自动关闭”。
事务、并发、幂等与一致性边界
事务地图
提交前 Feign 库存预查 不在订单事务提交前 Feign 实时核价 不在订单事务saveOrderInfo 主表 + 明细 普通订单入口:本地事务删除购物车 订单事务提交后发送延迟消息 订单事务提交后支付服务更新支付流水 支付服务独立提交订单状态改 PAID 订单服务独立提交仓储任务与库存锁定 仓储本地事务意图,存在自调用代理风险多仓子订单保存 跨多次本地写,整体非原子重复提交
普通订单接口没有交易码或请求幂等键。客户端超时重试可能:
- 生成新的雪花
outTradeNo; - 保存另一张内容相同的订单;
- 再次删除购物车;
- 再发送一条延迟消息。
重复支付通知
订单 successPay 没有条件更新,仓储 saveWareOrderTask 的返回值又被忽略。若支付通知在“订单已处理但响应丢失”后重试,可能再次进入库存锁定。
库存并发
正确的目标应是:
开始事务 -> 按稳定顺序 SELECT ... FOR UPDATE -> 检查所有明细 -> 更新所有 stock_locked提交事务当前 SQL 具备行锁和更新能力,但事务注解是否通过代理生效需要修正或运行证据。多个订单按不同 SKU 顺序加锁时,也可能产生数据库死锁并需要重试策略。
跨服务失败
OpenFeign 只告诉当前调用“成功或失败”,不能让之前已提交的服务自动回滚。跨服务需要幂等、可靠事件、补偿或状态扫描,不能依赖一个 @Transactional 注解。
关键场景静态推演
以下是基于当前工作树的静态推演,不是接口实测。
正常单仓订单
POST /order/auth/submitOrderContent-Type: application/jsonuserId: <Gateway 写入>
{ "consignee": "<收货人>", "deliveryAddress": "<地址>", "orderDetailList": [ { "skuId": 101, "skuName": "<商品>", "orderPrice": 99.00, "skuNum": 2 } ]}推演:
- 仓储总可用库存通过;
- 商品实时价与请求价
equals; - 保存
UNPAID主表和明细; - 删除购物车 SKU;
- 发送延迟消息;
- 支付成功后创建单仓工作单;
- 指定仓库行锁检查通过;
stock_locked += 2;- 订单变成
WAIT_DELEVER。
价格变化
库存预查可能已通过,但实时价不同:
- 通知购物车刷新价格;
- 不保存订单;
- 返回“价格发生变动”。
预查通过、支付后库存不足
两个用户都在预查时看到库存充足。支付后最终行锁阶段,其中一个先锁定库存,另一个再计算可用库存不足:
- 库存任务变
OUT_OF_STOCK; - 订单变
STOCK_EXCEPTION; - 当前代码未展示退款或人工处理闭环。
订单保存成功、消息发送失败
Controller 忽略 Producer 返回值,仍返回订单 ID。由于消费者本身也未自动启动,这张未支付订单不会因当前延迟链自动关闭。
空明细提交
当前代码会遍历空集合,然后在构造 tradeBody 时访问第 0 条明细,可能抛异常。没有看到统一的空订单参数校验。
当前实现的亮点与真实取舍
亮点
- 确认页由订单服务聚合地址与已选商品;
- 提交前同时复核库存和实时价格;
- 订单主表和明细在普通下单入口使用本地事务;
- 库存预查和最终行锁检查分层;
- 多仓 SKU 可以转换为父子订单和分仓工作单;
- 最终库存 SQL 使用
stock - stock_locked; - 超时逻辑意识到支付回调延迟竞态,不是简单看到
UNPAID就关单。
真实取舍
- 同步 Feign 易理解,但下单依赖多个服务同时可用;
- 先落订单再删购物车避免误删,但会留下购物车残留;
- 支付后再锁库存降低未支付占用,但可能出现付款后库存异常;
- 行锁可以保护数据库行,但吞吐、锁顺序和事务代理必须正确;
- 延迟消息适合超时任务,但需要消费者、时长和补偿一起闭环。
已确认限制与候选风险
| 结论 | 证据等级 | 直接依据 | 影响 |
|---|---|---|---|
| 普通提交没有交易码防重 | 代码已确认 | submitOrder 未读写交易码 | 重复请求可能生成重复订单 |
| 请求数量和商品信息校验不完整 | 代码已确认 | 主要只复核库存与价格 | 可能接受异常明细 |
价格使用 BigDecimal.equals | 代码已确认 | submitOrder | scale 不同也会拒绝 |
| 空明细会读取第 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; - 发货时把锁定量转为实际扣减量;
- 库存异常要有退款或人工补偿链。
状态机
订单状态更新使用条件:
-- 改进示例,不是当前 MapperUPDATE order_infoSET order_status = 'PAID'WHERE id = ? AND order_status = 'UNPAID';仓储任务也用“待处理 → 已锁定/库存不足”的条件迁移,重复请求读取已有结果,而不是再次执行库存写入。
超时关闭
- 统一订单
expireTime、支付宝过期时间和消息延迟; - 确保消费者随应用启动;
- 关单前查询支付平台;
- 修正空值判断;
- 支付成功与关单使用条件状态竞争;
- 建立延迟消息丢失扫描和库存释放补偿。
关键源码导航
| 阅读顺序 | 文件 | 关键方法 |
|---|---|---|
| 1 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/controller/OrderController.java | submitOrder 中发送延迟消息 |
| 2 | duolaimall-common/common-mq/src/main/java/com/cskaoyan/mall/mq/producer/BaseProducer.java | sendDelayMessage |
| 3 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/mq/DelayOrderConsumer.java | init、消息监听 |
| 4 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java | execExpiredOrder |
| 5 | duolaimall-order/order-api/src/main/java/com/cskaoyan/mall/order/constant/OrderStatus.java | 订单状态 |
| 6 | duolaimall-pay/pay-service/src/main/java/com/cskaoyan/mall/payment/service/impl/PayServiceImpl.java | 支付状态查询协作 |
4 条短复习点
- 延迟消息是触发检查,不应被表述为到点必然关闭。
- 当前消费者未自动启动,因此超时闭环只是代码存在,不是运行已验证能力。
- 本地事务不能覆盖购物车、MQ、支付和仓储,跨服务必须靠状态机与补偿。
- 支付通知、关单和库存任务都应采用条件状态迁移,不能只做无条件覆盖。
本章总结
延迟消息只是重新检查订单状态的触发器,不能代替可靠关单;当前消费者启动、时间配置和跨服务补偿仍有缺口,因此这条链路只能表述为代码已存在而非运行闭环。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!