秒杀系统设计
秒杀系统设计
10 万 QPS 打进来,库存只有 100 件——如何做到不超卖、不崩溃、用户体验不崩
秒杀系统是高并发场景的极端代表:流量极度集中、时间窗口极短、数据一致性要求极高。设计秒杀系统的核心挑战可以用一句话概括:用最少的请求真正打到数据库,同时绝对不能超卖。
一、问题建模:秒杀的三个核心挑战
挑战 1:高并发冲击 小米手机秒杀、双十一零点,10 万甚至 100 万用户同时点击「立即购买」。普通数据库根本扛不住。
挑战 2:防超卖 100 件商品,绝对不能卖出 101 件。并发减库存时,必须保证原子性。
挑战 3:防黄牛 / 恶意刷单 机器人程序可以以每秒数千次的频率发请求,要在技术层面过滤掉非真实用户的流量。
二、整体架构:三层防护漏斗
秒杀系统的设计思路是漏斗模型:在每一层过滤掉尽量多的请求,真正到达数据库的只有极少量。
用户请求(100 万次)
↓
第一层:前端限流(过滤 90%)
↓ 剩余约 10 万次
第二层:网关 / Nginx 限流(过滤 98%)
↓ 剩余约 2000 次
第三层:Redis 原子扣减(过滤 99%+)
↓ 库存剩余多少,通过多少
第四层:MQ 异步下单(最终写入数据库)下图展示了完整秒杀请求链路的时序流转,包括异步下单和库存不足两条路径:
三、第一层:前端限流
前端是最廉价的限流手段,目标是过滤掉重复点击和机器人流量:
按钮置灰 + 冷却时间:用户点击「立即购买」后,按钮立即置灰 5 秒,防止同一用户连续发送大量请求。简单有效,过滤大量手速党。
图形验证码 / 滑块验证:在秒杀开始前要求用户完成验证,机器人通过率极低。注意验证码服务本身需要独立限流,不能成为新的瓶颈。
随机延迟:对真实用户请求加入 0-500ms 的随机延迟,将同一时刻的峰值流量「打散」成一段时间内的均匀流量,效果出乎意料地好。
秒杀资格预检:秒杀开始前,用户先「预约/摇号」,只有拿到资格的用户才能进入购买页面。双十一很多品类采用这种方式。
四、第二层:网关层限流
到达网关的请求已经是真实 HTTP 请求,需要更精确的限流:
接口 QPS 限制:通过 Sentinel 或 Nginx limit_req 对秒杀接口设置整体 QPS 上限。超出阈值直接返回 429,告知用户「当前访问人数过多,请稍后再试」。
黑名单 IP 过滤:对短时间内发送大量请求的 IP 直接封禁,可以借助 Redis 记录各 IP 的请求频率,超过阈值加黑名单(TTL 设为 1-5 分钟)。
用户维度限流:每个 userId 在秒杀期间最多只能成功发起 N 次请求(如 3 次),防止单账号刷单。
五、第三层:Redis 原子扣减库存(核心)
这是防超卖的关键。错误做法是「先查库存,再减库存」——这在并发下一定会超卖(查-减之间有时间窗口)。
正确做法:Lua 脚本保证原子性
Redis 执行 Lua 脚本是原子操作,不会被其他命令打断:
-- 秒杀扣减库存 Lua 脚本
local stock_key = KEYS[1] -- 库存 key: seckill:stock:{itemId}
local bought_key = KEYS[2] -- 已购用户集合: seckill:bought:{itemId}
local user_id = ARGV[1] -- 当前用户 ID
-- 防重复购买:检查用户是否已经抢购成功
if redis.call('SISMEMBER', bought_key, user_id) == 1 then
return -1 -- 已购买,返回 -1
end
-- 检查并扣减库存
local stock = tonumber(redis.call('GET', stock_key))
if not stock or stock <= 0 then
return 0 -- 库存不足,返回 0
end
redis.call('DECR', stock_key)
redis.call('SADD', bought_key, user_id)
return 1 -- 成功,返回 1脚本执行结果:
- 返回 1:扣减成功,进入下单流程
- 返回 0:库存不足,直接返回「已售罄」
- 返回 -1:重复购买,直接返回「您已参与过本次秒杀」
库存预热:秒杀开始前(通常提前 5 分钟),定时任务将 MySQL 中的库存同步到 Redis。预热脚本需要幂等(可重复执行,不会重置已被消费的库存)。
六、第四层:MQ 异步下单
Redis 扣减库存成功后,不要同步创建订单。数据库写入远比 Redis 操作慢,同步写会成为新的瓶颈。
正确流程:
Redis 扣减成功 → 发送「创建订单」消息到 RocketMQ/Kafka → 立即返回「秒杀成功,订单处理中」
↓
订单服务消费消息 → 写入 MySQL → 发送支付链接给用户消息可靠性保证:
- 生产端:发送消息失败时本地重试,或将「待发送消息」先写入 DB(本地消息表),再异步发送
- 消费端:消费成功才 ACK,失败则重试(注意消费逻辑要幂等)
订单超时取消:用户拿到秒杀资格后,15 分钟内未支付则自动取消,将库存归还 Redis。可用 RocketMQ 延迟消息或 Redis 过期回调实现。
七、数据库层:最后一道防线
虽然数据库已经是最后一层,仍然需要防护:
乐观锁防超卖(兜底):
UPDATE item_stock
SET stock = stock - 1, version = version + 1
WHERE item_id = #{itemId}
AND stock > 0
AND version = #{version}version 字段做乐观锁,即使并发写入,也能保证不超卖(UPDATE 影响行数为 0 时重试)。
读写分离:查询库存详情走从库,减库存走主库。
分库分表:大促场景下,将热门商品分散到不同分片,避免单表热点。
八、面试题精选
Q:如何保证秒杀不超卖?
核心在于「扣减库存」的原子性。Redis DECR 命令本身是原子的,但「检查库存 + 扣减 + 记录用户」三步必须用 Lua 脚本包成一个原子操作。Lua 脚本在 Redis 中以原子方式执行,不会有并发问题。数据库层用
stock > 0的 WHERE 条件作为最后兜底。
Q:秒杀结束后如何将 Redis 库存同步回 MySQL?
两种方案:① 秒杀结束时,直接用 Redis 中的剩余库存覆盖 MySQL(简单,但有 Redis 宕机风险)。② 全程以 MySQL 为准,Redis 只是「预减」缓冲,每次 MQ 消费(订单创建成功)才真正扣减 MySQL,Redis 和 MySQL 的差值就是「在途订单数」。方案二更可靠,推荐。
Q:如果 Redis 在秒杀过程中宕机怎么办?
Redis 宕机时,秒杀请求会打到数据库,用乐观锁或数据库行锁兜底(性能下降但不超卖)。同时触发服务降级,在 Redis 恢复前暂停秒杀活动,给用户展示「系统繁忙」提示,待 Redis 重新预热库存后恢复。这就是为什么 Redis 需要持久化配置(RDB + AOF)。
Q:如何防止黄牛用多个账号刷秒杀?
技术手段:① 同一设备指纹只能绑定一个账号参与秒杀;② 实名认证 + 限制同一手机号/身份证只能参与一次;③ 秒杀资格通过行为分析打分(历史下单、登录频率),低分账号不发资格。完全杜绝黄牛很难,核心是提高黄牛成本,让批量刷单不再有利可图。
知识星球深度内容
完整秒杀系统 Spring Boot 源码(含 Lua 脚本、MQ 异步下单、超时取消全流程)、大厂秒杀真题解析,加入「AI 工程师加速社区」知识星球获取 👉 立即加入
