秒杀系统如何设计

们直接看一个全局架构图,心里先有个底 👇

1.静态化/答题

2.刷票/验证

3.资格校验

4.原子扣减

库存充足

库存不足

异步下单

5.乐观锁扣减

👤 用户请求

🌩️ CDN + 前端限流

🚪 网关层-令牌桶限流

💻 秒杀业务层

⚡ Redis Cluster

📬 消息队列 MQ

💔 直接返回’售罄’

🔄 订单消费者

🗄️ MySQL数据库

✅ 下单成功

👤 用户

🌐 CDN/静态页

⚖️ 负载均衡 Nginx

🚪 API 网关 限流/鉴权/黑名单

🖥️ Web 服务层

💎 Redis 库存预减+请求排队

📬 消息队列 削峰填谷

🗄️ 数据库 异步下单

📖 从库 订单查询

这个链路,就是我们今天要手撕的全部。

秒杀系统的三大核心挑战 ⚠️
挑战 本质问题 后果
瞬时高并发 短时间内流量暴增 100-1000 倍 服务器雪崩、数据库宕机
超卖问题 并发读写导致库存不一致 订单量 > 库存量,资损
恶意请求 脚本刷单、黄牛抢购 普通用户抢不到,体验差
整体架构设计 🏗️
秒杀系统的核心思想是:分层限流 + 异步削峰 + 最终一致性,把流量层层拦截,最终只有极少部分请求能到达数据库。

用户请求

CDN静态资源

前端限流

Nginx负载均衡+限流

网关层限流+黑名单

秒杀服务集群

Redis缓存层
(库存+限流+用户标记)

MQ消息队列
(异步削峰)

订单服务

数据库
(最终库存扣减)

支付服务

核心流程拆解(按时间线,告诉你每一步在做什么)
我用一个时序图,把一次正确的秒杀请求走通:

数据库
消息队列
Redis
网关
CDN/静态页
用户
数据库
消息队列
Redis
网关
CDN/静态页
用户
前端防作弊:按钮防抖、验证码、问答
alt
[库存 > 0]
[库存已空]

  1. 打开秒杀页面(静态HTML+JS)
    返回页面(秒杀开始前按钮置灰)

  2. 点击秒杀(携带token+商品ID+验证码)
    限流校验(令牌桶/漏桶),黑名单过滤

  3. 资格预扣:DECR 库存
    扣减成功,返回成功
    提示“排队中,请稍候”

  4. 发送秒杀请求消息(用户ID+商品ID)
    库存不足
    直接返回“已抢光”

  5. 消费者拉取消息,异步创建订单
    创建成功

  6. 标记该用户已下单成功

  7. 轮询/推送 获取秒杀结果
    查结果缓存
    返回状态
    成功页 / 失败页
    设计原则:
    能在前端拦截的,绝不放到后端
    能在缓存拦截的,绝不放到数据库
    能异步处理的,绝不同步处理
    高并发解决方案 🚄

  8. 前端层限流 ✋
    按钮置灰 + 倒计时,防止重复点击
    点击抢购前,先弹出个滑块验证码或者简单的数学题。
    别小看这一步,它能直接把脚本党和手速慢的人分流出去,还顺带把瞬时峰值的尖峰削平了。
    验证码 / 滑块验证,拦截机器请求
    随机延迟请求,分散流量峰值
    动静分离 & CDN 🗂️
    把秒杀页面做成纯静态 HTML,直接甩到 CDN 上。
    页面里秒杀按钮开始时是灰的,千万不要提前暴露真正的下单 URL。
    到点了,才通过一个额外请求动态获取秒杀地址(/getSeckillPath),这个 URL 还可以做成一次性的、加盐动态生成的。
    秒杀最怕的就是海量请求直接把服务器打崩。所以,要将 99% 的不必要流量,拦截在上游。

  9. 网关层限流 🚦
    Nginx 配置limit_req_zone,按 IP 限流
    黑名单机制,封禁恶意 IP 和用户
    网关层令牌桶算法,限制总 QPS
    在网关层,给秒杀接口加上令牌桶或漏桶限流器。
    比如:这个接口每秒只能放行 5000 个请求。多出来的直接快速失败,返回“挤不进去啦,请稍后再试~ 😅”。宁可把流量扼杀在此,也别让它冲垮下游。

  10. 服务层限流 🛡️
    分布式限流:Redis+Lua 脚本实现令牌桶
    熔断降级:秒杀服务过载时,直接返回 “活动火爆”
    业务隔离:秒杀服务独立部署,不影响核心业务

  11. 缓存层优化 💾
    商品详情页静态化,CDN 加速
    活动开始前,将库存预热到 Redis
    使用 Redis Cluster 分片,提高并发能力

  12. 异步削峰 📥
    库存扣减成功后,发送消息到 MQ
    订单服务异步消费消息,创建订单
    前端轮询 /websocket 通知用户抢购结果
    超卖问题的终极解决方案 ✅
    超卖的根本原因是:多个线程同时读取到相同的库存值,然后各自扣减,导致最终库存为负。

方案对比 📊
方案 实现方式 优点 缺点 适用场景
数据库行锁 UPDATE goods SET stock=stock-1 WHERE id=? AND stock>0 简单可靠 数据库压力大 低并发秒杀
悲观锁 SELECT … FOR UPDATE 绝对不会超卖 性能极差,死锁风险 几乎不用
乐观锁 版本号机制 性能较好 高并发下成功率低 中低并发
Redis+Lua 原子脚本扣减库存 性能极高,原子性 依赖 Redis 可靠性 高并发秒杀 ✅
分布式锁 Redisson 分布式锁 通用性强 性能一般,复杂度高 复杂业务场景
面试官插话:你说了那么多,如果流量真的冲进来了,超卖问题到底怎么解决?

我: 这正是秒杀的灵魂所在。我们绝对不能在数据库层面直接排队扣减,那样 MySQL 会直接死给你看。

核心方案:Redis 预减库存 + 数据库最终一致性扣减

推荐方案:Redis+Lua 原子脚本 🌟
这是目前互联网大厂最主流的解决方案,性能最高且绝对不会超卖。

我们把秒杀库存提前预热到 Redis 里。关键是,扣减操作必须原子化。我们用的不是多条 Redis 命令,而是一段 Lua 脚本:

– 原子扣减库存脚本
local stockKey = KEYS[1]
local userKey = KEYS[2]
local userId = ARGV[1]

– 1. 判断用户是否已经抢购过
if redis.call(‘sismember’, userKey, userId) == 1 then
return -1 – 已抢购
end

– 2. 判断库存是否充足
local stock = tonumber(redis.call(‘get’, stockKey))
if stock <= 0 then
return 0 – 库存不足
end

– 3. 扣减库存并标记用户已抢购
redis.call(‘decr’, stockKey)
redis.call(‘sadd’, userKey, userId)
return 1 – 抢购成功
为什么这样能防止超卖? 因为 Redis 是单线程执行命令的,Lua 脚本在执行时会把整个脚本当成一个原子操作。绝对不会出现“读到还有库存,但去扣的时候被别人扣光了”的读后写竞态条件。🛡️

快返回、异步下单:MQ 削峰 📬

Redis 扣减成功后,我们不立刻操作数据库。
而是马上给用户返回“抢购成功,正在创建订单…”。
同时,把“用户ID、商品ID”等必要信息,闪电般地扔进 RocketMQ 或 Kafka 里。服务端就直接结束了。
这一步,把瞬时的百万级并发,变成了后端消费服务可以慢慢处理的流式消息,削峰填谷。
为什么 Lua 脚本能保证原子性?
Redis 是单线程执行的
整个 Lua 脚本会作为一个整体执行
执行过程中不会被其他命令打断
最终一致性保证 🤝
Redis 扣减成功后,通过 MQ 异步创建订单。如果订单创建失败,需要进行库存回补:

订单服务消费消息失败,发送死信消息
库存回补服务消费死信消息,将库存加回 Redis
定时任务扫描超时未支付订单,自动取消并回补库存
🛡️ 数据库——最后的兜底防线
面试官追问:那如果消息队列出错了,或者 Redis 有什么问题,数据库层面怎么兜底?

我: 问得好。MySQL 是最后一道闸门,我们必须做最坏的打算。

方案:乐观锁防超卖
消费服务慢慢从 MQ 拉消息,真正去创建订单、扣减数据库库存时,必须用乐观锁。

– 不用版本号,直接用库存数当条件,更简洁
UPDATE seckill_product
SET stock = stock - 1
WHERE id = #{productId}
AND stock >= 1; – 核心兜底条件
执行完后,看返回值。如果这个 SQL 影响行数为 0,说明库存其实已经没了(可能 Redis 那边因为主从延迟或异常,多扣了一点点)。
此时,进行补偿操作:把 Redis 里多扣的库存加回去,然后标记该订单失败或回滚。整个链路最终一致。
🔥 极限挑战:热点数据问题怎么解?
如果是不限量的茅台,谁也救不了,只能用更大的集群硬扛。但我们可以:

业务隔离:把秒杀系统单独部署,不和日常业务抢资源。
JVM 本地缓存:如果某个商品真的是绝对热点,可以把它的部分库存前置到服务本地的内存里,用 AtomicInteger 自旋扣减,再定时异步同步给 Redis。这样连 Redis 的网络开销都省了。💨
其他关键问题处理 🛠️
防黄牛:一人一单限制、实名认证、收货地址校验
数据一致性:最终一致性模型,定时对账
容灾备份:Redis 主从 + 哨兵,数据库主从复制
监控告警:实时监控 QPS、库存、订单量,异常告警
总结 📝
同学,我们回顾下这场秒杀设计的整个逻辑闭环,一张图记牢:

层次 核心组件/策略 解决的核心问题
👆 前端/网关 CDN、验证码、动态URL、网关令牌桶 挡住垃圾流量,削减尖峰
⚡ 核心业务 Redis + Lua 原子扣减 彻底杜绝超卖
🔄 异步处理 消息队列 RocketMQ/Kafka 削峰填谷,将并发变流式
🗄️ 最终兜底 数据库乐观锁(库存 ≥ 1) 防止极端情况下数据不一致
📈 可观测性 监控、告警、限流动态调整 随时知道系统还能撑多久
高并发秒杀系统的设计精髓就是:“挡”、“削”、“异” 三个字

挡:在前端、网关、缓存层挡住 99.9% 的流量
削:用消息队列削平流量峰值
异:将非核心流程异步化
超卖问题的最优解是Redis+Lua 原子脚本,它在保证原子性的同时,提供了极高的性能,完全能扛住百万级 QPS 的秒杀场景。

核心代码
核心代码实现(带技术亮点)

  1. Redis 原子性库存预扣(Lua 脚本)💡
    技术亮点:用 Lua 脚本将 “库存判断 + 扣减 + 用户限购” 打包成原子操作,彻底解决 Redis 并发下的竞态问题,比多次 Redis 调用性能高 3 倍以上。

/**

  • 原子性预扣减库存(Lua脚本实现)

  • @param goodsId 商品ID

  • @param userId 用户ID

  • @param limitCount 每人限购数量

  • @return 0-成功 1-库存不足 2-已达限购上限
    */
    public int deductStock(Long goodsId, Long userId, int limitCount) {
    String luaScript = “”"
    local stockKey = KEYS[1]
    local userKey = KEYS[2]
    local limit = tonumber(ARGV[1])

    -- 1. 判断用户是否已达限购上限 local userBuyCount = redis.call('HGET', userKey, ARGV[2]) if userBuyCount and tonumber(userBuyCount) >= limit then return 2 end -- 2. 判断库存是否充足 local stock = redis.call('GET', stockKey) if not stock or tonumber(stock) <= 0 then return 1 end -- 3. 扣减库存+记录用户购买数量 redis.call('DECR', stockKey) redis.call('HINCRBY', userKey, ARGV[2], 1) return 0

    “”";

    List keys = Arrays.asList(
    “seckill:stock:” + goodsId,
    “seckill:user:” + goodsId
    );

    return (Integer) redisTemplate.execute(
    new DefaultRedisScript<>(luaScript, Integer.class),
    keys,
    limitCount,
    userId.toString()
    );
    }

  1. 分布式防重锁(Redisson 可重入锁 + 看门狗)🔒
    技术亮点:解决原生 SETNX 锁的 “锁过期但业务未执行完” 问题,Redisson 看门狗自动续期,支持可重入,是生产环境唯一推荐的分布式锁实现。

/**

  • 防止同一用户重复下单
    */
    public Result seckill(Long goodsId, Long userId) {
    String lockKey = “seckill🔒” + goodsId + “:” + userId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
    // 尝试获取锁,最多等待0秒,锁默认30秒过期(看门狗自动续期)
    if (!lock.tryLock(0, TimeUnit.SECONDS)) {
    return Result.error(“请勿重复提交请求”);
    }

    // 执行库存预扣减 int result = deductStock(goodsId, userId, 1); if (result == 1) { return Result.error("商品已售罄"); } if (result == 2) { return Result.error("您已参与过本次秒杀"); } // 生成订单ID,发送消息到Kafka String orderId = UUID.randomUUID().toString(); kafkaTemplate.send("seckill-order-topic", new SeckillMessage(orderId, goodsId, userId)); return Result.success("排队成功,请等待订单生成");

    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    return Result.error(“系统繁忙,请稍后再试”);
    } finally {
    // 释放锁
    if (lock.isHeldByCurrentThread()) {
    lock.unlock();
    }
    }
    }

  1. 消息队列幂等性消费者 ✅
    技术亮点:用订单 ID 作为唯一标识实现幂等性,解决 Kafka 消息重复消费导致的重复下单问题,同时配合事务保证消息处理的原子性。

/**

  • Kafka秒杀订单消费者(幂等性实现)
    */
    @KafkaListener(topics = “seckill-order-topic”, groupId = “seckill-group”)
    public void onMessage(ConsumerRecord<String, String> record) {
    SeckillMessage message = JSON.parseObject(record.value(), SeckillMessage.class);
    String orderId = message.getOrderId();

    // 1. 幂等性判断:如果订单已存在,直接返回
    if (orderService.existOrder(orderId)) {
    log.info(“订单已存在,重复消费,orderId: {}”, orderId);
    return;
    }

    // 2. 事务处理:创建订单+扣减数据库库存
    try {
    orderService.createOrderWithStockDeduct(message);
    } catch (Exception e) {
    log.error(“订单创建失败,orderId: {}”, orderId, e);
    // 发送到死信队列,人工处理
    kafkaTemplate.send(“seckill-dlq-topic”, record.value());
    }
    }

  1. 数据库最终库存扣减(行锁 + 乐观锁)📊
    技术亮点:用 MySQL 行锁防止超卖,同时用乐观锁版本号控制并发更新,在保证数据一致性的前提下最大化性能。

– 最终库存扣减(行锁实现,绝对不会超卖)
UPDATE seckill_goods
SET stock_count = stock_count - 1,
version = version + 1
WHERE goods_id = #{goodsId}
AND stock_count > 0
AND version = #{version};
5. 接口层的防刷与排队反馈(限流 + 秒杀结果异步通知)
@RestController
public class SeckillController {
@Autowired
private SeckillStockService stockService;
@Autowired
private RocketMQTemplate mqTemplate;
@Autowired
private StringRedisTemplate redis;

// 秒杀接口 @PostMapping("/seckill/exec") public Result exec(@RequestParam String itemId, @RequestParam String captcha, HttpServletRequest request) { String uid = getLoginUserId(request); // 1. 验证码校验(防脚本) if (!checkCaptcha(uid, captcha)) { return Result.fail("验证码错误"); } // 2. 用户级限流:每人只允许一次请求 String limitKey = "seckill:limit:" + itemId + ":" + uid; Boolean limited = redis.opsForValue() .setIfAbsent(limitKey, "1", Duration.ofMinutes(1)); if (Boolean.FALSE.equals(limited)) { return Result.fail("请勿重复提交"); } // 3. 库存预扣 boolean deducted = stockService.deductStock(itemId, 10); // 10个分片 if (!deducted) { return Result.fail("已抢光"); } // 4. 发送 MQ 消息,异步下单 SeckillMessage msg = new SeckillMessage(uid, itemId); mqTemplate.asyncSend("seckill_order", msg, new SendCallback() { @Override public void onSuccess(SendResult result) {} @Override public void onException(Throwable e) { // 发送失败,补偿恢复库存 + 删除 limitKey // ... 异常处理逻辑,可发一条补偿消息 } }); // 5. 立即返回排队结果,前端轮询查结果 return Result.ok("排队中,请稍候查看结果"); } // 查询秒杀结果 @GetMapping("/seckill/result") public Result queryResult(@RequestParam String itemId, HttpServletRequest req) { String uid = getLoginUserId(req); String resultKey = "seckill:result:" + itemId + ":" + uid; String result = redis.opsForValue().get(resultKey); if (result == null) { return Result.ok("排队中"); } return Result.ok(result); // "success" 或 "fail" }

}
技术亮点:

Redis 用户限流 挡掉绝大多重复请求。
异步返回:秒杀接口耗时控制在 10ms 内,不阻塞用户。
失败补偿逻辑(生产需进一步细化)。