商城小程序高并发下单怎么设计?库存超卖、重复支付、优惠券超发的工程方案(Redis+MQ+幂等)
做商城小程序,平时一天几十单看不出问题,一到大促秒杀就集中暴露三个经典故障:库存超卖(100 件商品卖了 130 单)、重复支付(用户狂点或回调重放,一笔订单扣了两次钱)、优惠券超发(限量 1000 张被领走 1200 张)。这三个问题的根因都一样:并发下"先查再改"不是原子操作,且缺少幂等设计。
本文不堆概念,按真实下单链路给出一套可落地的工程方案:Redis 预扣库存 + 消息队列异步落库 + 数据库乐观锁兜底 + 支付回调幂等 + 优惠券原子领取,并附关键代码和压测兜底策略。
一、先看清下单链路:并发压力在哪
一次典型的"秒杀下单"链路是:
用户点立即购买
→ 校验活动/价格
→ 校验优惠券
→ 扣减库存
→ 锁定优惠券
→ 创建订单(待支付)
→ 调起微信支付
→ 支付回调改状态、真正扣库存
→ 发消息通知、触发营销
瓶颈集中在"扣库存、锁券、建单、改状态"这几个需要写库的步骤。如果所有请求都直接打 MySQL 做 select 库存 → 判断 → update,高并发下必然超卖——因为两个线程同时查到库存为 1,都认为能下单。
核心思路是:把高并发的"抢"动作前置到 Redis(内存、单线程、原子),把慢的"写库"动作削峰到 MQ 异步处理,MySQL 用乐观锁做最后一道防线,支付和回调全部幂等。
二、库存防超卖:三道防线
第 1 道:数据库乐观锁(能用,但扛不住大促)
最朴素的写法是在扣减时加条件,让数据库保证原子性:
UPDATE product_stock
SET stock = stock - #{num}
WHERE sku_id = #{skuId} AND stock >= #{num};
返回影响行数为 1 才说明扣减成功,为 0 就是库存不足。这已经能避免超卖,但问题是:大促时几千个请求同时打同一行,行锁竞争激烈,MySQL 连接和 CPU 会被打满。所以它适合做兜底,不适合扛流量。
第 2 道:Redis 预扣(Lua 脚本保证原子)
把可售库存提前预热到 Redis,秒杀请求先在 Redis 抢,抢不到直接返回"已售罄",不碰数据库:
-- seckill_deduct.lua KEYS[1]=库存key ARGV[1]=购买数量
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local num = tonumber(ARGV[1])
if stock < num then
return -1 -- 库存不足
end
redis.call('DECRBY', KEYS[1], num)
return 1 -- 预扣成功
Lua 脚本在 Redis 里是单线程顺序执行的,"判断 + 扣减"天然原子,不会出现两个请求都看到同一个库存值的情况。
同时要处理一人一单,防止刷单:
String lua =
"local stockKey=KEYS[1], userKey=KEYS[2]; " +
"if redis.call('EXISTS', userKey)==1 then return 0 end; " + // 已下过单
"if tonumber(redis.call('GET',stockKey) or '0') < tonumber(ARGV[1]) then return -1 end; " +
"redis.call('DECRBY', stockKey, ARGV[1]); " +
"redis.call('SADD', userKey, ARGV[2]); " +
"return 1;";
第 3 道:MQ 异步落库 + 最终一致
Redis 预扣成功后,不立刻写订单库,而是发一条"下单消息"到 MQ,消费者异步建单、落库。这样既削峰,又把请求线程快速释放:
@Transactional
public void consume(OrderMsg msg) {
// 幂等:同一下单流水只处理一次
if (orderMapper.existsByReqNo(msg.getReqNo())) return;
// 落库时再用乐观锁兜底,防止 Redis 与 DB 对账前的极端不一致
int rows = stockMapper.deduct(msg.getSkuId(), msg.getNum());
if (rows == 0) {
mq.sendDelay("stock-rollback", msg); // 库存不足,回滚 Redis 预扣
return;
}
orderMapper.create(msg);
}
注意区分:Redis 预扣只是"占位",真正的库存所有权在支付成功后才转移。未支付的订单要设置超时(如 15 分钟),超时后通过延迟消息回滚 Redis 库存,避免有人占着不付钱。
三、支付防重复:订单状态机 + 回调幂等
重复支付/重复回调有两个来源:前端重复点击、支付平台异步通知重复投递(微信支付可能重发多次回调)。
前端:幂等令牌防重复提交
进入下单页时后端发一个一次性 token,下单时必须携带,用过即作废:
public String createOrder(CreateOrderDTO dto) {
// SET NX 保证 token 只能用一次
Boolean ok = redis.opsForValue().setIfAbsent("order:token:" + dto.getToken(),
"1", 5, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(ok)) {
throw new BizException("请勿重复提交");
}
// 继续下单...
}
回调:用状态机保证"只处理一次"
微信支付回调可能重复推送,处理时必须先判断当前订单状态,只有"待支付"才允许流转,并用乐观锁更新:
UPDATE orders
SET pay_status = 'PAID', pay_time = NOW(), version = version + 1
WHERE order_no = #{orderNo} AND pay_status = 'WAITING';
返回影响行数为 0,说明订单已经被处理过(可能是重复回调),直接返回成功、不再做任何业务(不重复发货、不重复扣库存、不重复加积分)。
@Transactional
public void onPayNotify(PayNotify notify) {
if (!signVerify(notify)) throw new BizException("签名错误");
int rows = orderMapper.markPaidIfWaiting(notify.getOrderNo());
if (rows == 0) {
log.info("订单{}已处理,忽略重复回调", notify.getOrderNo());
return; // 幂等:重复消息直接吞掉
}
// 首次支付成功:真正扣库存、核销券、发 MQ 通知
mq.send("order-paid", notify.getOrderNo());
}
关键原则:所有外部回调(支付、退款、物流)都要按"可能重复到达任意多次"来设计,靠唯一单号 + 状态机 + 乐观锁保证恰好处理一次。
四、优惠券防超发:原子领取 + 一人一张
优惠券超发通常有两种:总量超发、同一用户领多张。
总量控制:用 Redis 原子扣减
-- 券剩余数量 key = coupon:stock:{id}
local remain = tonumber(redis.call('GET', KEYS[1]) or '0')
if remain <= 0 then return -1 end -- 已抢光
redis.call('DECR', KEYS[1])
return 1
一人一张:用 Set 或 Bitmap 去重
local stockKey = KEYS[1]
local userSet = KEYS[2]
local userId = ARGV[1]
if redis.call('SISMEMBER', userSet, userId) == 1 then
return 0 -- 已领取
end
if tonumber(redis.call('GET', stockKey) or '0') <= 0 then
return -1
end
redis.call('DECR', stockKey)
redis.call('SADD', userSet, userId)
return 1
高并发、用户量极大时,SISMEMBER 可以换成 Bitmap(SETBIT/GETBIT),一亿用户的去重位图也只占十几 MB,更省内存。
优惠券同样要做落库兜底:用户领取后异步写"用户券表",并在数据库层面对 (coupon_id, user_id) 建唯一索引,即使 Redis 与 MQ 出现异常,重复数据也插不进去。
五、完整链路:把组件串起来
把上面的方案串成一条可靠的下单链路:
- 活动开始前:定时任务把库存、券数量预热进 Redis。
- 用户下单:Lua 脚本原子完成"判断库存 + 一人一单 + 预扣",失败直接返回售罄/已下单。
- 预扣成功:发送下单消息到 MQ,前端拿到"排队中/待支付",立刻调起支付。
- 消费者建单:幂等校验(reqNo 去重)→ 乐观锁扣 DB 库存 → 建待支付订单,失败发延迟消息回滚预扣。
- 支付成功回调:验签 → 状态机乐观更新(只在待支付时生效)→ 发 MQ 触发真正扣库存、核券、加积分、通知。
- 超时未支付:延迟队列 15 分钟后检查,未支付则关闭订单、回滚 Redis 库存和券。
- 兜底对账:每天凌晨用 MySQL 实际库存与 Redis 对账,修复因进程崩溃、消息丢失导致的偏差。
六、压测与兜底:限流、降级、对账
- 入口限流:在网关层对秒杀接口按用户/IP 做限流(令牌桶),超出的请求直接快速失败,保护后端。
- 库存分桶:单个热点 SKU 库存可以拆成多个 Redis key(分桶),扣减时随机选桶,进一步分散热点。
- 降级兜底:Redis 故障时降级为数据库乐观锁(限流后);MQ 积压时暂停接单、提示"活动太火爆",比超卖后再退款好得多。
- 静态资源隔离:商品详情、活动页静态化并上 CDN,别让查详情的流量打穿交易库。
- 对账补偿:任何依赖缓存和消息的最终一致方案都必须有对账任务,以数据库为最终事实源,定期校准 Redis、清理悬挂订单。
常见问题
Q1:商城小程序秒杀库存超卖怎么解决?
核心是"Redis 预扣 + MQ 异步落库 + 数据库乐观锁兜底"。用 Lua 脚本把"判断库存+扣减"做成原子操作,未支付订单延迟回滚,落库时再用 UPDATE ... WHERE stock >= num 兜底,三层配合既扛并发又不超卖。
Q2:微信支付回调重复推送,怎么避免重复发货?
对订单状态做状态机管理,用乐观锁只在"待支付"状态下更新为"已支付"(WHERE pay_status='WAITING');更新影响行数为 0 就视为重复回调直接返回,不重复扣库存、发货、加积分。
Q3:小程序优惠券怎么防止超发和一人多领?
总量用 Redis 的 DECR(Lua 原子)控制,一人一张用 Set 的 SISMEMBER(用户量大用 Bitmap),落库时对 (coupon_id,user_id) 建唯一索引兜底,即使消息异常也不会产生重复记录。
Q4:高并发下单为什么要上消息队列,直接写库不行吗?
大促时所有请求直接写库会造成行锁竞争和连接打满。MQ 把突发流量削峰、异步建单,请求线程快速释放;配合幂等消费和失败重试,既能扛住瞬时高峰,又能保证数据最终一致。
Q5:Redis 预扣了但用户没付款,库存怎么回收?
下单时发送延迟消息(如 15 分钟),到期后检查订单状态,仍为待支付则关闭订单并回滚 Redis 库存、释放优惠券;若用户在临界点支付成功,以支付状态机为准,避免误关已支付订单。
- 点赞
- 收藏
- 关注作者
评论(0)