多来买:秒杀订单消费、轮询与补偿边界实战
多来买:秒杀订单消费、轮询与补偿边界实战
本篇范围:订单消息消费、msgId 防重、秒杀订单落库、结果轮询、活动清理、失败窗口和补偿演进
事实来源:当前PromoOrderConsumer、OrderServiceImpl.saveSeckillOrder、PromoServiceImpl
验证口径:代码已确认;未运行 Broker 重投、进程崩溃、订单对账或库存补偿测试
本篇从事务消息已经提交开始,解释订单服务怎样消费消息并创建 PROMO_ORDER,前端怎样通过 Redis 轮询结果,以及为什么“先写消费标记、再保存订单”会在进程突然退出时留下库存已扣但订单缺失的窗口。
消费与轮询主链路
阅读目标
- 说明消费者如何初始化 Topic、Consumer Group 与消息监听器。
- 说明 msgId Redis 标记的常规防重逻辑和崩溃窗口。
- 说明秒杀订单主从表怎样在本地事务内保存。
- 区分用户防重 Set、消费标记 Bucket 与订单结果 Map。
- 说明活动清理、未支付订单和库存回补为何仍需额外闭环。
第十二步:订单消费者启动与订阅
代码路径:duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/mq/PromoOrderConsumer.java
关键方法:init
@PostConstructpublic 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 | 只用于轮询,无 TTL |
| 订单数据库 | 本地事务保存主从表 | 未看到业务唯一约束 |
失败场景静态推演
以下是根据当前代码的静态推演,不是运行或故障注入结果。
正常成功
- 本地状态为有库存;
- 用户 Set 首次添加成功;
- Producer 发送 Half Message;
- Lua 扣减后返回非负;
- 写事务状态
success,提交消息; - Controller 返回“正在排队”;
- Consumer 占用 msgId 标记;
- 订单主从表事务保存成功;
- 写
promo:orders[userId:skuId] = orderId; - 下一次轮询返回“下单成功”。
库存不足
- 本地状态可能仍是有库存;
- 用户 Set 添加成功;
- Lua 返回
-1; - 本地状态改为无库存;
- 事务消息回滚;
- 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 + 活动”绑定的唯一键。
当前实现的亮点与真实取舍
亮点
- 活动数据提前进入 Redis,降低活动开始时数据库压力;
- 本地状态位能在售罄后快速失败;
- 用户 Set 使用原子添加限制重复提交;
- 库存 Hash 使用 StringCodec,便于 Lua 操作;
- Lua 原子完成库存检查与扣减;
- RocketMQ 事务消息让库存失败时订单消息不提交;
- Broker 回查通过 Redis transactionId 标记恢复决策;
- Consumer 设计了消息防重;
- 异步下单使用“排队中 + 轮询”,不提前谎报订单已落库。
真实取舍
- 本地状态速度快,但多实例不共享,只适合做旁路过滤;
- 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 回查、重试、死信和订单缺口。
关键源码导航
| 阅读顺序 | 文件 | 关键方法 |
|---|---|---|
| 1 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/mq/PromoOrderConsumer.java | init、消息监听 |
| 2 | duolaimall-order/order-service/src/main/java/com/cskaoyan/mall/order/service/impl/OrderServiceImpl.java | saveSeckillOrder |
| 3 | duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/controller/PromoController.java | checkOrder |
| 4 | duolaimall-promo/promo-service/src/main/java/com/cskaoyan/mall/promo/service/impl/PromoServiceImpl.java | clearRedisCache |
| 5 | duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java | 消费标记与结果 key |
4 条短复习点
- msgId 标记能挡住常规重复消息,但前置写入会产生进程崩溃窗口。
saveSeckillOrder只保证订单库主表和明细的本地原子性。- 轮询 Map 表示订单已生成,与用户提交防重 Set 不是同一个事实。
- 未支付关单、库存回补、在途消息清理和数据库唯一约束仍未形成完整闭环。
本章总结
订单消费者完成消息到秒杀订单的转换,并用 Redis 提供轮询结果;现有 msgId 防重仍有前置标记窗口,订单超时、库存回补和对账补偿尚未形成闭环。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!