如何避免用户重复下单(多次下单未支付,占用库存)
- 标签:后端、场景题
🏛 业务架构师
重复下单占用库存这个问题,我先把"重复"拆成两种业务成因——用户误操作和恶意刷单/黄牛,两类成因对应不同的防护深度和成本投入。
用户误操作层(基础防护,成本极低)
- 前端防抖:提交按钮点击后置灰,拦截连点
- 防重 Token:进入下单页时服务端下发一次性 token,提交时校验并原子消费(Redis
DEL成功才放行)——网络重试、刷新重复提交都被挡住 - 接口幂等:以"用户 + 活动/ SKU + 时间窗"生成幂等键,唯一索引兜底,重复请求返回首次的结果而不是报错(静默幂等体验更好)
库存占用层(核心)
重复下单最大的危害不是多生成订单,而是恶意占库存:黄牛批量下单不付款,把爆款库存锁死,真实用户买不到,大促直接翻车。对策:
- 未支付订单 TTL:15~30 分钟未付款自动取消并释放库存(延迟消息实现)
- 未支付单数量上限:同一用户未支付订单超过 N 笔禁止新下单
- 下单与付款的库存策略选择:预售/秒杀类用"付款减库存 + 限购",普通电商用"下单预占 + 超时释放"——这是业务决策不是纯技术决策,取决于超卖和少卖哪个代价更高
恶意层(风控)
- 限购(按用户/设备/收货地址维度)、人机校验(滑块)、黄牛账号识别
投入产出的收尾:防护深度和业务损失挂钩——日常商品做到 Token 幂等 + TTL 就够;大促秒杀必须上全套(限购、风控、付款减库存)。按活动资损风险分级布防,不为日常场景付秒杀级的复杂度。
🏗 系统架构师
"重复下单"要拆成两个问题:防重复提交和未支付订单占用库存。
一、防重复下单(幂等设计)
- 前端:按钮防抖置灰,只是体验优化,不能当防线。
- Token 机制(我的主防線):进入下单页先向服务端领一次性 token 存 Redis,提交时用
DEL key的原子性校验——删除成功才放行。DEL 是原子的,两个并发请求只有一个能删掉,天然防重。 - 唯一约束兜底:数据库对"用户 + 幂等键(客户端生成的 requestId)"建唯一索引,即使 Redis 被绕过,DB 也会拒掉第二条。幂等的最后一道防线永远落在存储层的唯一性上。
二、未支付占库存的治理
- 预占与实扣分离:下单只"预占"库存(Redis Lua 原子扣减 + DB 记录占用流水),支付成功才实扣,超时未支付自动回补。直接扣真实库存会让"下单不买"的人吃掉库存。
- 超时释放:下单发一条 30 分钟延迟消息(RocketMQ 延迟消息),到期检查订单状态,未支付则取消订单 + 回补预占库存;再配一个低频扫表任务兜底防消息丢失。
- 限制未支付单数量:同一用户未支付订单超过 N 笔(比如 3 笔)禁止再下单,从源头减少占坑。
架构总结:防重靠"前端防抖 → Redis 原子 token → DB 唯一约束"三层递减漏斗,库存靠"预占 + 延迟释放 + 扫表兜底"的闭环。每一层都有明确的失败语义,而不是指望某一层永远不出错。
💻 全栈开发专家
防重复下单我做三层:前端防抖(体验)+ token 幂等(主力)+ 数据库唯一索引(兜底)。
方案一:防重 token(推荐)
进入下单页时服务端发一个一次性 token 存 Redis:
java
// 进页面
String token = UUID.randomUUID().toString();
redis.set("order:token:" + userId, token, 10, TimeUnit.MINUTES);
// 提交订单:Lua 保证"校验+删除"原子性,防并发双双通过
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
if (redisTemplate.execute(script, keys, token) == 0) throw new BizException("请勿重复提交");两次快速点击/两个标签页同时下单,只有一个能删掉 token。
方案二:数据库兜底(必须加)
sql
-- 同一用户同一活动只有一个未支付占库存的单
ALTER TABLE orders ADD UNIQUE KEY uk_user_sku_pending(user_id, sku_id, status);更常用的做法是单独建"占用表",未支付单删除后释放唯一键位。
配套措施
- 未支付超时自动释放(延迟消息关单),别让占库存的单挂 30 分钟。
- 前端按钮点击后置灰 loading,仅是体验优化,不能当防线。
踩过的坑
- 只靠前端防重会被脚本直接打穿;只靠 Redis token 在 Redis 抖动时全量失败,所以 DB 唯一索引永远是最后一道,捕获
DuplicateKeyException转成友好提示。