前言
防止超买超卖是抢购、秒杀等业务场景的核心技术挑战。下面我将从设计原则、技术方案、架构细节等多个层面,提供一个全面且可落地的设计方案。
核心设计原则
- 读多写少,读写分离:绝大部分请求是查询库存(读),实际下单是减库存(写)。要将两者分离,读请求尽量不走数据库。
- 尽量上游拦截:尽可能在系统链路的最前端(如客户端、网关、缓存层)拦截无效请求,让尽可能少的请求到达数据库。
- 数据缓存化:库存等核心数据放在缓存中(如Redis),利用缓存的高并发能力应对读请求和原子写操作。
- 原子操作:所有扣减库存的操作必须是原子的,防止并发导致的数据不一致。数据库行锁、Redis的
DECR/Lua脚本都是常用手段。 - 最终一致性:扣减缓存库存和持久化数据库库存之间可以采用异步方式,保证最终一致,以提高性能。
- 柔性设计:系统要有降级、限流和熔断能力,在极端压力下保护自己,避免雪崩。
分层防御体系:如何防止超卖 (Prevent Overselling)
超卖是核心要解决的问题,即卖出的数量超过库存总量。关键在于保证扣减库存的原子性和预占。
缓存层 (Redis) - 主战场
库存数据预热到Redis中,所有扣减操作在Redis中完成。
-
方案A:利用原子指令 (推荐)
- 使用
SET key value NX初始化库存。 - 使用
DECRBY或INCRBY进行原子扣减或回滚。 - 步骤:
- 用户下单时,调用
DECRBY stock_key quantity。 - 返回值
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,表示库存不足失败。这利用了数据库的原子更新能力。
- 表设计增加
缓存与数据库的协同(重要)
通常采用 “缓存扣减,数据库异步更新” 的策略。
- 用户在Redis中成功预占库存。
- 订单服务创建订单,状态为“待支付”。
- 通过消息队列(如RocketMQ/Kafka)发送一个扣减数据库库存的消息。
- 库存消费端异步消费该消息,执行上面的数据库乐观锁更新SQL。
- 如果数据库更新失败怎么办?(例如缓存有库存但数据库实际已不足,极少发生)
- 这意味着出现了不一致。解决方案是:回滚。
- 消费端更新失败后,需要执行补偿操作:a) 将Redis库存加回去 (
INCRBY)。b) 将订单状态置为“无效”或“取消”。
分层防御体系:如何防止超买 (Prevent Overbuying)
超买是指请求量超过系统处理能力,导致系统崩溃。关键在于限流和削峰。
-
前端(客户端)优化
- 按钮禁用:点击“立即购买”后,立即禁用按钮,防止用户重复提交。
- 倒计时与伪装:提前几分钟让用户进入活动页面,倒计时结束后按钮才变为可点击。将抢购开始时间略微随机化(如±100ms),避免所有用户绝对同时请求。
- 验证码:在提交订单前弹出图形验证码或滑块验证,可以有效拦截黄牛脚本,并起到削峰作用(将一次洪峰拉长为一段时间的请求)。
-
网关/API层限流
- 全局限流:使用Nginx、Spring Cloud Gateway等网关,设置整个服务的QPS/TPS阈值,超出部分直接返回“活动太火爆”等提示。
- API分级限流:对“查询库存”和“提交订单”接口设置不同的限流策略。“提交订单”接口的限流阈值要更严格。
-
服务层限流 & 熔断
- 熔断器:使用Hystrix或Sentinel,当下游服务(如库存服务、订单服务)响应慢或失败率高时,自动熔断,防止级联故障。
- 信号量隔离:限制服务调用的并发线程数,而不是线程池队列,反应更迅速。
-
消息队列削峰填谷
- 将核心的创建订单流程异步化。
- 用户预占库存成功后,立即返回“抢购中,请等待结果”,然后将订单请求发送到消息队列。
- 订单服务以自己能承受的速度从队列中消费消息,逐步创建订单和更新数据库。
- 这样前端请求的峰值被消息队列“拉平”了,系统按照最大处理能力稳定运行。
整体架构与流程
一个典型的防超买超卖抢购系统流程如下:
步骤详解:
- 准备阶段:
- 将商品库存从数据库加载到Redis中 (
SET stock_1001 100)。
- 将商品库存从数据库加载到Redis中 (
- 抢购阶段:
- 查询库存:用户请求直接查询Redis缓存,返回剩余库存量。
- 提交订单:
a. 网关层进行限流,放过一定数量的请求。
b. 服务层调用Redis Lua脚本原子扣减库存。扣减失败则直接返回失败。
c. 扣减成功,则生成一个临时订单Token,并将订单信息(用户ID、商品ID、数量)发送到消息队列,立即返回用户“抢购排队中”。
d. 订单消费者处理消息,执行数据库库存扣减(乐观锁)和订单创建。
e. 通过推送或用户轮询告知用户最终结果(成功/失败)。
- 支付阶段:
- 支付成功后,系统调用库存服务,将“预占库存”状态改为“已售出”。(如果Redis中只存了总库存,此步可省略)
- 超时未支付:用户一定时间未支付,订单取消。需要执行“回滚库存”操作:a) Redis
INCRBY。b) 数据库库存INCRBY(同样要用乐观锁,防止超卖)。
总结与注意事项
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Redis原子操作 | 性能极高,实现简单 | 需要保证Redis高可用,需处理缓存与DB的一致性 | 绝大部分高并发抢购场景 |
| 数据库乐观锁 | 绝对保证不超卖,简单 | 性能较差,数据库压力大 | 并发量不高,或作为缓存方案的最终保障 |
| 消息队列异步 | 完美削峰,保护下游系统 | 用户体验有延迟,系统复杂性增加 | 流量特别巨大,对响应时间不极度敏感的场景 |
其他注意事项:
- 库存回滚:订单取消、支付失败、业务逻辑失败时,必须要有可靠的库存回滚机制。
- 防黄牛:除了验证码,还可以加入用户等级、IP限制、设备指纹等策略。
- 数据预热与缓存穿透:抢购开始前预热缓存。对于不存在的商品ID,缓存空值防止缓存穿透。
- 压测:上线前必须进行全链路压测,准确评估各个环节的承载能力,合理设置限流阈值。
通过以上分层、分级的综合设计方案,可以有效地防止超买和超卖,构建一个稳定、高性能的抢购系统。

2449

被折叠的 条评论
为什么被折叠?



