商城小程序高并发下单怎么设计?库存超卖、重复支付、优惠券超发的工程方案(Redis+MQ+幂等)

举报
yd_264102447 发表于 2026/10/16 11:25:41 2026/10/16
【摘要】 商城小程序大促秒杀最容易出现库存超卖、重复支付、优惠券超发三大故障,根因是并发下先查再改非原子、缺少幂等设计。本文按真实下单链路给出Redis预扣库存、消息队列异步落库、数据库乐观锁兜底、支付回调幂等、优惠券原子领取的完整方案,附Lua/Java关键代码与压测兜底策略。

做商城小程序,平时一天几十单看不出问题,一到大促秒杀就集中暴露三个经典故障:库存超卖(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 出现异常,重复数据也插不进去。

五、完整链路:把组件串起来

把上面的方案串成一条可靠的下单链路:

  1. 活动开始前:定时任务把库存、券数量预热进 Redis。
  2. 用户下单:Lua 脚本原子完成"判断库存 + 一人一单 + 预扣",失败直接返回售罄/已下单。
  3. 预扣成功:发送下单消息到 MQ,前端拿到"排队中/待支付",立刻调起支付。
  4. 消费者建单:幂等校验(reqNo 去重)→ 乐观锁扣 DB 库存 → 建待支付订单,失败发延迟消息回滚预扣。
  5. 支付成功回调:验签 → 状态机乐观更新(只在待支付时生效)→ 发 MQ 触发真正扣库存、核券、加积分、通知。
  6. 超时未支付:延迟队列 15 分钟后检查,未支付则关闭订单、回滚 Redis 库存和券。
  7. 兜底对账:每天凌晨用 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 库存、释放优惠券;若用户在临界点支付成功,以支付状态机为准,避免误关已支付订单。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。