秒杀系统如何防止超买超卖

前言

防止超买超卖是抢购、秒杀等业务场景的核心技术挑战。下面我将从设计原则、技术方案、架构细节等多个层面,提供一个全面且可落地的设计方案。

核心设计原则

  1. 读多写少,读写分离:绝大部分请求是查询库存(读),实际下单是减库存(写)。要将两者分离,读请求尽量不走数据库。
  2. 尽量上游拦截:尽可能在系统链路的最前端(如客户端、网关、缓存层)拦截无效请求,让尽可能少的请求到达数据库。
  3. 数据缓存化:库存等核心数据放在缓存中(如Redis),利用缓存的高并发能力应对读请求和原子写操作。
  4. 原子操作:所有扣减库存的操作必须是原子的,防止并发导致的数据不一致。数据库行锁、Redis的DECR/Lua脚本都是常用手段。
  5. 最终一致性:扣减缓存库存和持久化数据库库存之间可以采用异步方式,保证最终一致,以提高性能。
  6. 柔性设计:系统要有降级、限流和熔断能力,在极端压力下保护自己,避免雪崩。

分层防御体系:如何防止超卖 (Prevent Overselling)

超卖是核心要解决的问题,即卖出的数量超过库存总量。关键在于保证扣减库存的原子性和预占

缓存层 (Redis) - 主战场

库存数据预热到Redis中,所有扣减操作在Redis中完成。

  • 方案A:利用原子指令 (推荐)

    • 使用 SET key value NX 初始化库存。
    • 使用 DECRBYINCRBY 进行原子扣减或回滚。
    • 步骤
      1. 用户下单时,调用 DECRBY stock_key quantity
      2. 返回值 new_stock
        • new_stock >= 0:扣减成功,预占库存成功。
        • new_stock < 0:扣减失败,库存不足。必须紧接着调用 INCRBY stock_key quantity 回滚刚才的扣减! 否则会出现负数,后续无法处理。
  • 方案B:使用Lua脚本 (最强保证)

    • 将“判断库存”和“扣减库存”多个操作写在一个Lua脚本中,脚本执行是原子性的,无需担心并发问题。
    • 示例脚本:
      local key = KEYS[1] -- 库存key
      local change = tonumber(ARGV[1]) -- 要扣减的数量
      local stock = tonumber(redis.call('get', key))
      -- 检查库存是否充足
      if stock < change then
          return -1 -- 库存不足
      end
      -- 库存充足,进行扣减
      redis.call('decrby', key, change)
      return stock - change -- 返回剩余库存
      
    • 在应用中调用 eval 执行该脚本,根据返回值判断成功与否。

数据库层 (MySQL) - 最终保障

缓存层防止了大部分超卖,但为防止缓存宕机等极端情况,数据库层面也需要最后一道锁。

  • 悲观锁 (for update):不推荐在抢购时使用,性能太差,容易导致数据库连接打满。
  • 乐观锁 (Version或Quantity本身)
    • 表设计增加 version 字段或使用 quantity 本身作为条件。
    • 更新SQL:
      UPDATE item_stock
      SET quantity = quantity - #{buyQuantity},
          version = version + 1
      WHERE item_id = #{itemId}
      AND quantity >= #{buyQuantity}
      -- AND version = #{version}  -- 如果用version
      
    • 判断该SQL执行后影响的函数 (affected_rows)。如果为1,表示扣减成功;为0,表示库存不足失败。这利用了数据库的原子更新能力。

缓存与数据库的协同(重要)

通常采用 “缓存扣减,数据库异步更新” 的策略。

  1. 用户在Redis中成功预占库存。
  2. 订单服务创建订单,状态为“待支付”。
  3. 通过消息队列(如RocketMQ/Kafka)发送一个扣减数据库库存的消息。
  4. 库存消费端异步消费该消息,执行上面的数据库乐观锁更新SQL。
  5. 如果数据库更新失败怎么办?(例如缓存有库存但数据库实际已不足,极少发生)
    • 这意味着出现了不一致。解决方案是:回滚
    • 消费端更新失败后,需要执行补偿操作:a) 将Redis库存加回去 (INCRBY)。b) 将订单状态置为“无效”或“取消”。

分层防御体系:如何防止超买 (Prevent Overbuying)

超买是指请求量超过系统处理能力,导致系统崩溃。关键在于限流和削峰

  1. 前端(客户端)优化

    • 按钮禁用:点击“立即购买”后,立即禁用按钮,防止用户重复提交。
    • 倒计时与伪装:提前几分钟让用户进入活动页面,倒计时结束后按钮才变为可点击。将抢购开始时间略微随机化(如±100ms),避免所有用户绝对同时请求。
    • 验证码:在提交订单前弹出图形验证码或滑块验证,可以有效拦截黄牛脚本,并起到削峰作用(将一次洪峰拉长为一段时间的请求)。
  2. 网关/API层限流

    • 全局限流:使用Nginx、Spring Cloud Gateway等网关,设置整个服务的QPS/TPS阈值,超出部分直接返回“活动太火爆”等提示。
    • API分级限流:对“查询库存”和“提交订单”接口设置不同的限流策略。“提交订单”接口的限流阈值要更严格。
  3. 服务层限流 & 熔断

    • 熔断器:使用Hystrix或Sentinel,当下游服务(如库存服务、订单服务)响应慢或失败率高时,自动熔断,防止级联故障。
    • 信号量隔离:限制服务调用的并发线程数,而不是线程池队列,反应更迅速。
  4. 消息队列削峰填谷

    • 将核心的创建订单流程异步化。
    • 用户预占库存成功后,立即返回“抢购中,请等待结果”,然后将订单请求发送到消息队列。
    • 订单服务以自己能承受的速度从队列中消费消息,逐步创建订单和更新数据库。
    • 这样前端请求的峰值被消息队列“拉平”了,系统按照最大处理能力稳定运行。

整体架构与流程

一个典型的防超买超卖抢购系统流程如下:

系统边界:抢购平台
外部用户
1.查询库存请求
2.路由请求
3.读取缓存
4.返回库存数据
5.返回结果
6.提交订单请求
7.路由请求
8.原子扣减库存
9.发送下单消息
10.异步消费
11.写订单数据
12.扣减真实库存
0.库存预热
13.支付成功通知
14.最终状态更新
网关/接入层
业务服务层
Redis: 缓存库存
消息队列
订单消费服务
MySQL: 真实库存
MySQL: 订单数据
用户

步骤详解:

  1. 准备阶段
    • 将商品库存从数据库加载到Redis中 (SET stock_1001 100)。
  2. 抢购阶段
    • 查询库存:用户请求直接查询Redis缓存,返回剩余库存量。
    • 提交订单
      a. 网关层进行限流,放过一定数量的请求。
      b. 服务层调用Redis Lua脚本原子扣减库存。扣减失败则直接返回失败。
      c. 扣减成功,则生成一个临时订单Token,并将订单信息(用户ID、商品ID、数量)发送到消息队列,立即返回用户“抢购排队中”。
      d. 订单消费者处理消息,执行数据库库存扣减(乐观锁)和订单创建。
      e. 通过推送或用户轮询告知用户最终结果(成功/失败)。
  3. 支付阶段
    • 支付成功后,系统调用库存服务,将“预占库存”状态改为“已售出”。(如果Redis中只存了总库存,此步可省略)
    • 超时未支付:用户一定时间未支付,订单取消。需要执行“回滚库存”操作:a) Redis INCRBY。b) 数据库库存 INCRBY(同样要用乐观锁,防止超卖)。

总结与注意事项

方案优点缺点适用场景
Redis原子操作性能极高,实现简单需要保证Redis高可用,需处理缓存与DB的一致性绝大部分高并发抢购场景
数据库乐观锁绝对保证不超卖,简单性能较差,数据库压力大并发量不高,或作为缓存方案的最终保障
消息队列异步完美削峰,保护下游系统用户体验有延迟,系统复杂性增加流量特别巨大,对响应时间不极度敏感的场景

其他注意事项:

  • 库存回滚:订单取消、支付失败、业务逻辑失败时,必须要有可靠的库存回滚机制。
  • 防黄牛:除了验证码,还可以加入用户等级、IP限制、设备指纹等策略。
  • 数据预热与缓存穿透:抢购开始前预热缓存。对于不存在的商品ID,缓存空值防止缓存穿透。
  • 压测:上线前必须进行全链路压测,准确评估各个环节的承载能力,合理设置限流阈值。

通过以上分层、分级的综合设计方案,可以有效地防止超买和超卖,构建一个稳定、高性能的抢购系统。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值