占着库存不支付的人
预扣模式让抢到的用户拿到 15 分钟支付窗口——窗口是体验,也是风险:不支付的人占着库存,回补机制缺席的话,每 10% 的弃付率就是 10% 的库存少卖,恶意占坑更是全部打了水漂。关单回补是预扣模式的另一半,没有它整个模型不成立。
延迟消息的三种实现
"15 分钟后触发关单"有三种主流做法:MQ 延迟消息——下单时发一条 15 分钟后投递的消息,精度高、实现省,首选;死信队列——消息过期后转入死信消费,档位固定但够用;定时任务扫表——按支付截止时间扫描超时订单,精度分钟级,量大时扫描压力大,适合兜底而非主力。生产组合:延迟消息为主力,扫表任务做兜底——延迟消息万一丢失,扫表最终会收网。
| 方案 | 精度 | 依赖 | 定位 |
|---|---|---|---|
| MQ 延迟消息 | 秒级 | 支持延迟的 MQ | 主力方案 |
| 死信队列 | 固定档位 | RabbitMQ 等 | 可用替代 |
| 定时扫表 | 分钟级 | 仅数据库 | 兜底收网 |
竞态:关单时用户正在支付
关单最凶险的场景:回补已执行,用户的钱也扣了——订单关了、支付成了,钱货两空。解法是把关单做成原子条件更新:UPDATE order SET status=CLOSED WHERE id=? AND status=UNPAID——状态机 CAS,只有"未支付"能被关;支付回调与关单谁先抢到,谁决定终态:关单先成功,支付回调发现订单已关则走退款;支付先成功,关单更新影响行数为零自动放弃。两个动作共享一个状态闸门,竞态从机制上解掉。
// 原子关单:只有未支付状态可关
int rows = orderMapper.closeIfUnpaid(orderId);
if (rows == 1) {
stockCompensator.refund(orderId, skuId); // 关单成功才回补库存
notifyUser(orderId, CLOSED); // 通知用户订单已关闭
} else {
log.info("order {} not closable, paid or closed", orderId);
}
// 支付回调侧:订单已关则自动发起退款,绝不发货回补的幂等:只能补一次
回补消息重复消费会把库存越补越多——比少卖更隐蔽的超额放行。回补幂等的关键是把"是否已回补"记录在订单侧而非凭空判断:回补前 CAS 更新回补标记(未回补→已回补),更新成功才真正加库存;落库失败则标记回滚、消息重投。同一个订单无论消息来几次,库存只加一次。凡是"加库存"的动作,都必须先过幂等闸门。
回补后的库存去哪
回补的库存不是立刻重开抢——瞬时放出来又是一轮小洪峰。更稳的做法:回补库存进入候补池,按候补名单顺序通知下一批用户,或者等到下一个整点随新一批资格一起放行。库存的每一次流动都要有明确的去向与节奏,这是把秒杀从"能跑"做到"优雅"的分水岭。下一篇回到请求入口的另一道防线:幂等与一人一单。
评论 (0)