管理主页分类

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

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

多来买:秒杀订单消费、轮询与补偿边界实战

3657 字
18 分钟
多来买:秒杀订单消费、轮询与补偿边界实战

多来买:秒杀订单消费、轮询与补偿边界实战#

本篇范围:订单消息消费、msgId 防重、秒杀订单落库、结果轮询、活动清理、失败窗口和补偿演进
事实来源:当前 PromoOrderConsumerOrderServiceImpl.saveSeckillOrderPromoServiceImpl
验证口径:代码已确认;未运行 Broker 重投、进程崩溃、订单对账或库存补偿测试

本篇从事务消息已经提交开始,解释订单服务怎样消费消息并创建 PROMO_ORDER,前端怎样通过 Redis 轮询结果,以及为什么“先写消费标记、再保存订单”会在进程突然退出时留下库存已扣但订单缺失的窗口。

消费与轮询主链路#

sequenceDiagram participant Broker as "RocketMQ" participant Consumer as "PromoOrderConsumer" participant Redis as "Redis" participant Order as "OrderServiceImpl" participant DB as "订单数据库" participant Client as "客户端" Broker->>Consumer: 投递秒杀订单消息 Consumer->>Redis: msgId Bucket.trySet alt 首次消费 Consumer->>Order: saveSeckillOrder Order->>DB: 本地事务保存主表和明细 Consumer->>Redis: userId:skuId -> orderId Consumer-->>Broker: CONSUME_SUCCESS else 重复消息 Consumer-->>Broker: CONSUME_SUCCESS end loop 订单尚未出现 Client->>Redis: checkOrder(skuId) Redis-->>Client: 排队中或 orderId end

阅读目标#

  • 说明消费者如何初始化 Topic、Consumer Group 与消息监听器。
  • 说明 msgId Redis 标记的常规防重逻辑和崩溃窗口。
  • 说明秒杀订单主从表怎样在本地事务内保存。
  • 区分用户防重 Set、消费标记 Bucket 与订单结果 Map。
  • 说明活动清理、未支付订单和库存回补为何仍需额外闭环。

第十二步:订单消费者启动与订阅#

代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/mq/PromoOrderConsumer.java
关键方法:init

@PostConstruct
public void init()
throws MQClientException {
DefaultMQPushConsumer consumer =
new DefaultMQPushConsumer(
"promo-order-group");
// 注册中心地址省略;当前与 Producer 硬编码值不一致
consumer.subscribe(
MqTopicConst.PROMO_ORDER_TOPIC,
"*");
consumer.setMessageListener(
new MessageListenerConcurrently() {
// consumeMessage
});
consumer.start();
}

与普通订单延迟消费者不同,秒杀消费者的 @PostConstruct 没有注释,且方法末尾调用 start()。但是两端地址配置不一致,仍不能据此断言它们当前能连接同一个 Broker。

第十三步:消费者先创建防重标记#

当前实现#

String msgId =
msgs.get(0).getMsgId();
String key =
RedisConst.PROMO_RESUME_MESSAGE
+ msgId;
RBucket<Object> bucket =
redissonClient.getBucket(key);
boolean firstConsume =
bucket.trySet("consume");
if (!firstConsume) {
return ConsumeConcurrentlyStatus
.CONSUME_SUCCESS;
}

同一 msgId 再投递时,trySet 失败并直接返回消费成功。它能阻止正常重试重复创建订单。

标记前置的崩溃窗口#

标记在订单落库之前写入:

写 consume 标记
解析消息
保存订单
写 userId:skuId -> orderId

代码捕获普通异常时会删除标记并返回稍后重试,但如果进程在写标记后突然退出、机器断电或 JVM 被强制终止,catch 无法执行。消息重投后看到标记存在,会直接返回成功,订单可能永远没有创建。

因此当前消费幂等属于“先占位”方案,但缺少“处理中/成功”状态、TTL 和数据库唯一键兜底。

第十四步:解析消息并保存秒杀订单#

解析消息#

MessageExt message =
msgs.get(0);
String messageContent =
new String(message.getBody());
OrderInfoParam orderInfoParam =
JSON.parseObject(
messageContent,
OrderInfoParam.class);
Long userId =
orderInfoParam.getUserId();
Long skuId =
orderInfoParam
.getOrderDetailList()
.get(0)
.getSkuId();

Producer 使用 UTF-8 编码,Consumer 使用平台默认字符集解码。若部署默认字符集不是 UTF-8,包含中文的商品名或地址可能出现编码问题;当前 Windows/服务器实际字符集未验证。

保存订单#

代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java
关键方法:saveSeckillOrder

@Transactional(rollbackFor = Exception.class)
public Long saveSeckillOrder(
OrderInfoParam orderInfoParam) {
OrderInfo orderInfo =
orderInfoConverter
.convertOrderInfoParam(
orderInfoParam);
orderInfo.setOrderStatus(
OrderStatus.UNPAID.name());
orderInfo.sumTotalAmount();
orderInfo.setOutTradeNo(
UUID.randomUUID()
.toString()
.replaceAll("-", ""));
orderInfo.setTradeBody(
orderInfo.getOrderDetailList()
.get(0)
.getSkuName());
orderInfo.setOrderType(
OrderType.PROMO_ORDER.name());
orderInfo.setExpireTime(
DateUtil.datePlusMinutes(
new Date(), 3));
return saveOrderInfo(orderInfo);
}

saveSeckillOrder 自身从消费者通过 Spring Bean 调用,外层事务可以覆盖内部保存主表和明细。秒杀订单类型标为 PROMO_ORDER,过期时间设置 3 分钟。

但方法末尾“发送超时取消消息、回补秒杀库存”仍是 TODO。设置 expireTime 不会自动产生定时任务。

第十五步:写入轮询结果#

消费者在订单保存成功后:

Long orderId =
orderService.saveSeckillOrder(
orderInfoParam);
if (orderId == null) {
bucket.delete();
return ConsumeConcurrentlyStatus
.RECONSUME_LATER;
}
RMap<String, Long> orderMap =
redissonClient.getMap(
RedisConst
.PROMO_SECKILL_ORDERS);
orderMap.put(
userId + ":" + skuId,
orderId);
return ConsumeConcurrentlyStatus
.CONSUME_SUCCESS;

Promo 服务轮询:

代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/service/impl/PromoServiceImpl.java
关键方法:checkOrder

RMap<String, Long> orderMap =
redissonClient.getMap(
RedisConst
.PROMO_SECKILL_ORDERS);
String key =
userId + ":" + skuId;
return orderMap.containsKey(key);

Controller 根据 Boolean 返回:

  • 存在:业务码 218,“下单成功”;
  • 不存在:业务码 211,“正在排队中”。

当前接口没有返回订单 ID,只返回状态;客户端需要再通过订单列表等接口获取具体订单。

活动结束清理#

代码路径:duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/service/impl/PromoServiceImpl.java
关键方法:clearRedisCache

回写状态与剩余库存#

List<SeckillGoods> goodsList =
getCurrentDayGoodsList();
for (SeckillGoods goods : goodsList) {
goods.setStatus(
SeckillGoodsStatus.FINISHED.name());
seckillGoodsMapper.updateById(goods);
}
RMap<String, String> stockMap =
redissonClient.getMap(
RedisConst
.PROMO_SECKILL_GOODS_STOCK,
new StringCodec());
stockMap.keySet().forEach(skuId -> {
String remainStock =
stockMap.get(skuId);
UpdateWrapper<SeckillGoods> wrapper =
new UpdateWrapper<>();
wrapper.eq("sku_id", skuId);
wrapper.eq(
"DATE_FORMAT(start_time,'%Y-%m-%d')",
DateUtil.formatDate(new Date()));
wrapper.set(
"stock_count", remainStock);
seckillGoodsMapper.update(
null, wrapper);
});

删除缓存#

redissonClient.getKeys()
.deleteByPattern("promo:*");
LocalCacheHelper.removeAll();

这会同时删除活动商品、库存、用户防重、事务回查、消费标记和订单轮询结果。若清理与未完成事务回查或消息消费重叠,可能提前删除仍在使用的协调状态。

入口是 GET /clear/cache,仓库内没有看到本次已验证的定时任务;访问控制取决于外部 Gateway 规则,当前未知。

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

Lua 解决了什么#

同一个 Redis 实例、同一个库存 Hash:
检查库存 + 扣减库存
作为一个 Lua 原子操作执行

它可以阻止并发把 Redis 库存扣成负数。

Lua 没解决什么#

  • 用户是否重复提交;
  • 消息是否发送到正确 Broker;
  • 订单是否最终保存;
  • 消费者是否崩溃;
  • 未支付订单是否回补库存;
  • Redis 数据与 MySQL 活动库存是否持续一致。

RocketMQ 事务消息解决了什么#

当 Producer 能正常与 Broker 和 Redis 协作时:

  • Lua 返回成功,消息提交并可消费;
  • Lua 返回库存不足,消息回滚;
  • Broker 不确定时,通过 Redis 事务标记回查。

RocketMQ 事务消息没解决什么#

  • Producer 与 Consumer 地址配置是否一致;
  • Consumer 业务一定只执行一次;
  • Redis 扣库存后订单永远不会丢;
  • 订单超时回补;
  • 客户端请求参数可信;
  • Redis 与订单数据库是一个原子事务。

当前幂等层次#

层次当前做法边界
用户提交每用户 Set 保存 SKU无 TTL,未知结果不一定删除
库存扣减Lua 原子检查与扣减只保护 Redis 库存
事务回查transactionId Bucket无 TTL,清理可能提前删除
消息消费msgId Bucket trySet标记先于订单落库
订单结果userId Map只用于轮询,无 TTL
订单数据库本地事务保存主从表未看到业务唯一约束

失败场景静态推演#

以下是根据当前代码的静态推演,不是运行或故障注入结果。

正常成功#

  1. 本地状态为有库存;
  2. 用户 Set 首次添加成功;
  3. Producer 发送 Half Message;
  4. Lua 扣减后返回非负;
  5. 写事务状态 success,提交消息;
  6. Controller 返回“正在排队”;
  7. Consumer 占用 msgId 标记;
  8. 订单主从表事务保存成功;
  9. promo:orders[userId:skuId] = orderId
  10. 下一次轮询返回“下单成功”。

库存不足#

  1. 本地状态可能仍是有库存;
  2. 用户 Set 添加成功;
  3. Lua 返回 -1
  4. 本地状态改为无库存;
  5. 事务消息回滚;
  6. Controller 返回“已售罄”。

当前分支不删除用户 Set 标记,但商品已售罄时通常没有再次购买机会。

消息发送明确失败#

submitOrderInTransaction 删除用户 Set 中的 SKU,并返回“请稍后重试”。用户可以重新提交。

本地事务结果未知#

方法返回“请稍后重试”,但没有删除用户 Set。用户直接重试会得到“重复抢购”;真正结果依赖 Broker 后续回查和 Redis transaction Bucket。

Producer 在 Lua 与结果标记之间退出#

Lua 扣库存和 stateBucket.set("success") 是两条独立 Redis 操作。若进程在 Lua 成功后、写入 transactionId 结果前退出,Redis 库存已经减少,Broker 回查却只能得到 UNKNOW。当前代码没有按 transactionId 回补这次扣减或从其他业务事实恢复提交结论,因此存在“库存已扣、消息长期未知或最终未提交”的候选窗口。

Consumer 普通异常#

catch 会删除 msgId 消费标记并返回 RECONSUME_LATER,RocketMQ 可以重投。

Consumer 进程突然退出#

若退出发生在 msgId 标记写入后、订单提交前,catch 不会运行。消息重投时标记已存在,Consumer 直接返回成功,形成库存已扣但订单缺失窗口。

订单成功但结果 Map 写失败#

订单数据库已经提交,异常会删除消费标记并让消息重试。下一次消费可能再次创建订单,因为订单表没有看到与 msgId 或“用户 + SKU + 活动”绑定的唯一键。

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

亮点#

  1. 活动数据提前进入 Redis,降低活动开始时数据库压力;
  2. 本地状态位能在售罄后快速失败;
  3. 用户 Set 使用原子添加限制重复提交;
  4. 库存 Hash 使用 StringCodec,便于 Lua 操作;
  5. Lua 原子完成库存检查与扣减;
  6. RocketMQ 事务消息让库存失败时订单消息不提交;
  7. Broker 回查通过 Redis transactionId 标记恢复决策;
  8. Consumer 设计了消息防重;
  9. 异步下单使用“排队中 + 轮询”,不提前谎报订单已落库。

真实取舍#

  • 本地状态速度快,但多实例不共享,只适合做旁路过滤;
  • Redis 库存吞吐高,但需要活动结束回写和故障恢复;
  • 事务消息缩小了库存与消息的不一致窗口,但引入回查状态管理;
  • 前置消费标记阻止常规重复,却产生进程崩溃窗口;
  • 轮询实现简单,但会增加请求量并依赖结果 Map 生命周期。

已确认限制与候选风险#

结论证据等级直接依据影响
事务消息两端硬编码地址不同代码已确认Producer 与 Consumer 初始化可能连接不同 Broker,订单消息不可达
最终提交未复核下单码代码已确认submitSeckillOrder 无该参数可绕过交易页校验
最终提交未复核活动时间代码已确认提交入口仅查本地状态活动外请求边界不完整
请求价格没有用 Redis 活动数据覆盖代码已确认Service 只取 SKU 判断存在订单金额可能依赖客户端参数
用户防重和协调标记无 TTL代码已确认Set/Bucket/Map 调用状态长期积累、恢复困难
库存为 0 时未立即标售罄代码已确认remainStock == 0 分支注释多放过一次本地过滤
未知事务结果不删除用户标记代码已确认结果分支用户重试可能被误判重复
Lua 扣库存与事务结果标记分两步候选风险executeLocalTransaction 调用顺序两步之间退出时 Broker 无法确认已扣库存
消费标记先于订单落库代码已确认Consumer 调用顺序进程退出可能丢订单
Producer UTF-8、Consumer 默认字符集候选风险编解码代码非 UTF-8 环境可能乱码
秒杀超时关单与库存回补未实现代码已确认saveSeckillOrder TODO未支付订单导致少卖
清理会删除全部 promo:*代码已确认deleteByPattern可能与在途事务/消费冲突
预热与清理调度未知未验证只有手工 GET 入口活动生命周期不能确认
MQ 重试和回查结果未知未验证未运行 Broker不能宣称可靠最终一致
订单表业务唯一约束未知未验证仓库无建表脚本重试时重复订单风险未知

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

以下是改进建议,不是当前代码能力。

服务端重建提交参数#

提交只接收最小标识:

{
"skuId": 101,
"orderCode": "<一次性下单码>",
"addressId": 88,
"requestId": "<客户端幂等号>"
}

服务端从 Redis 活动商品重建商品名、价格、数量和时间,拒绝客户端提交的金额快照。

下单码#

  • 使用高熵随机值;
  • Redis 保存 code -> userId + skuId + activityId
  • 设置短 TTL;
  • 提交时原子消费一次;
  • 与活动时间、用户和 SKU 同时校验。

用户防重#

使用活动维度业务键:

unique = activityId + userId + skuId

把“提交中、库存成功、订单成功、失败可重试”设计成状态,而不是只有一个永久 Set member。

消费幂等#

一种演进:

订单数据库唯一键:
unique(message_transaction_id)
unique(activity_id, user_id, sku_id)
数据库事务:
插入幂等记录
保存订单主表
保存订单明细
提交后写/刷新轮询结果

这样消息重投以数据库最终事实为准,不依赖 Redis 前置标记。

库存补偿#

  • 秒杀订单发送更短的延迟关闭消息;
  • 关闭时条件更新订单状态;
  • 订单确实未支付才执行 Lua 回补;
  • 回补操作以订单 ID 幂等;
  • 对 Redis 扣减成功但长时间没有订单的事务做扫描和补偿;
  • 活动结束前等待在途消息或使用状态化清理,而不是直接删除全部协调 key。

基础设施配置#

  • Producer 与 Consumer 共用配置中心地址;
  • 不在源码中硬编码;
  • 设置消息 key,便于追踪一笔抢购;
  • 显式 UTF-8 解码;
  • 事务回查标记和消费结果设置合理 TTL;
  • 监控 Half Message 回查、重试、死信和订单缺口。

关键源码导航#

阅读顺序文件关键方法
1duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/mq/PromoOrderConsumer.javainit、消息监听
2duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.javasaveSeckillOrder
3duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/controller/PromoController.javacheckOrder
4duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/service/impl/PromoServiceImpl.javaclearRedisCache
5duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java消费标记与结果 key

4 条短复习点#

  1. msgId 标记能挡住常规重复消息,但前置写入会产生进程崩溃窗口。
  2. saveSeckillOrder 只保证订单库主表和明细的本地原子性。
  3. 轮询 Map 表示订单已生成,与用户提交防重 Set 不是同一个事实。
  4. 未支付关单、库存回补、在途消息清理和数据库唯一约束仍未形成完整闭环。

本章总结#

订单消费者完成消息到秒杀订单的转换,并用 Redis 提供轮询结果;现有 msgId 防重仍有前置标记窗口,订单超时、库存回补和对账补偿尚未形成闭环。

文章分享

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

多来买:秒杀订单消费、轮询与补偿边界实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-seckill-order-consume-poll-compensate/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录