秒杀系统如何设计

如何设计一个高并发的秒杀系统?超卖问题如何解决?

设计秒杀系统,核心就三个字:快、稳、准

  • :响应要快,流量要在上游就被挡住。
  • :系统不能崩,要层层削峰。
  • :库存不能多扣,绝对不能超卖。

咱们直接看一个全局架构图,心里先有个底 👇
graph TD A👤 用户请求 -->|1.静态化/答题| B🌩️ CDN + 前端限流 B -->|2.刷票/验证| C🚪 网关层-令牌桶限流 C -->|3.资格校验| D💻 秒杀业务层 D -->|4.原子扣减| E⚡ Redis Cluster E -->|库存充足| F📬 消息队列 MQ E -->|库存不足| G💔 直接返回'售罄' F -->|异步下单| H🔄 订单消费者 H -->|5.乐观锁扣减| I(🗄️ MySQL数据库) I --> J✅ 下单成功
graph TB User👤 用户 --> CDN🌐 CDN/静态页 CDN --> LB⚖️ 负载均衡 Nginx LB --> Gateway🚪 API 网关 限流/鉴权/黑名单 Gateway --> Web🖥️ Web 服务层 Web --> Redis💎 Redis 库存预减+请求排队 Web --> MQ📬 消息队列 削峰填谷 MQ --> DB🗄️ 数据库 异步下单 Web --> DB_Slave📖 从库 订单查询 style Redis fill:#f9f,stroke:#333,stroke-width:2px style MQ fill:#fc6,stroke:#333,stroke-width:2px style DB fill:#6cf,stroke:#333,stroke-width:2px

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

秒杀系统的三大核心挑战 ⚠️

挑战 本质问题 后果
瞬时高并发 短时间内流量暴增 100-1000 倍 服务器雪崩、数据库宕机
超卖问题 并发读写导致库存不一致 订单量 > 库存量,资损
恶意请求 脚本刷单、黄牛抢购 普通用户抢不到,体验差

整体架构设计 🏗️

秒杀系统的核心思想是:分层限流 + 异步削峰 + 最终一致性,把流量层层拦截,最终只有极少部分请求能到达数据库。
graph TD A用户请求 --> BCDN静态资源 A --> C前端限流 C --> DNginx负载均衡+限流 D --> E网关层限流+黑名单 E --> F秒杀服务集群 F --> G"Redis缓存层\
(库存+限流+用户标记)"
G --> H"MQ消息队列\
(异步削峰)"
H --> I订单服务 I --> J"数据库\
(最终库存扣减)"
J --> K支付服务

核心流程拆解(按时间线,告诉你每一步在做什么)

我用一个时序图,把一次正确的秒杀请求走通:
sequenceDiagram participant U as 用户 participant CDN as CDN/静态页 participant GW as 网关 participant Redis as Redis participant MQ as 消息队列 participant DB as 数据库 U->>CDN: 1. 打开秒杀页面(静态HTML+JS) CDN-->>U: 返回页面(秒杀开始前按钮置灰) Note over U,CDN: 前端防作弊:按钮防抖、验证码、问答 U->>GW: 2. 点击秒杀(携带token+商品ID+验证码) GW->>GW: 限流校验(令牌桶/漏桶),黑名单过滤 GW->>Redis: 3. 资格预扣:DECR 库存 alt 库存 > 0 Redis-->>GW: 扣减成功,返回成功 GW-->>U: 提示"排队中,请稍候" GW->>MQ: 4. 发送秒杀请求消息(用户ID+商品ID) else 库存已空 Redis-->>GW: 库存不足 GW-->>U: 直接返回"已抢光" end MQ->>DB: 5. 消费者拉取消息,异步创建订单 DB-->>MQ: 创建成功 MQ->>Redis: 6. 标记该用户已下单成功 U->>GW: 7. 轮询/推送 获取秒杀结果 GW->>Redis: 查结果缓存 Redis-->>GW: 返回状态 GW-->>U: 成功页 / 失败页

设计原则:

  • 能在前端拦截的,绝不放到后端
  • 能在缓存拦截的,绝不放到数据库
  • 能异步处理的,绝不同步处理

高并发解决方案 🚄

1. 前端层限流 ✋

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

秒杀最怕的就是海量请求直接把服务器打崩。所以,要将 99% 的不必要流量,拦截在上游。

2. 网关层限流 🚦

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

3. 服务层限流 🛡️

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

4. 缓存层优化 💾

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

5. 异步削峰 📥

  • 库存扣减成功后,发送消息到 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 脚本

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"等必要信息,闪电般地扔进 RocketMQKafka 里。服务端就直接结束了。
  • 这一步,把瞬时的百万级并发,变成了后端消费服务可以慢慢处理的流式消息,削峰填谷。

为什么 Lua 脚本能保证原子性?

  • Redis 是单线程执行的
  • 整个 Lua 脚本会作为一个整体执行
  • 执行过程中不会被其他命令打断

最终一致性保证 🤝

Redis 扣减成功后,通过 MQ 异步创建订单。如果订单创建失败,需要进行库存回补:

  1. 订单服务消费消息失败,发送死信消息
  2. 库存回补服务消费死信消息,将库存加回 Redis
  3. 定时任务扫描超时未支付订单,自动取消并回补库存

🛡️ 数据库------最后的兜底防线

面试官追问:那如果消息队列出错了,或者 Redis 有什么问题,数据库层面怎么兜底?

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

方案:乐观锁防超卖

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

sql 复制代码
-- 不用版本号,直接用库存数当条件,更简洁
UPDATE seckill_product 
SET stock = stock - 1 
WHERE id = #{productId} 
AND stock >= 1;  -- 核心兜底条件
  • 执行完后,看返回值。如果这个 SQL 影响行数为 0,说明库存其实已经没了(可能 Redis 那边因为主从延迟或异常,多扣了一点点)。
  • 此时,进行补偿操作:把 Redis 里多扣的库存加回去,然后标记该订单失败或回滚。整个链路最终一致。

🔥 极限挑战:热点数据问题怎么解?

如果是不限量的茅台,谁也救不了,只能用更大的集群硬扛。但我们可以:

  • 业务隔离:把秒杀系统单独部署,不和日常业务抢资源。
  • JVM 本地缓存 :如果某个商品真的是绝对热点,可以把它的部分库存前置到服务本地的内存里,用 AtomicInteger 自旋扣减,再定时异步同步给 Redis。这样连 Redis 的网络开销都省了。💨

其他关键问题处理 🛠️

  1. 防黄牛:一人一单限制、实名认证、收货地址校验
  2. 数据一致性:最终一致性模型,定时对账
  3. 容灾备份:Redis 主从 + 哨兵,数据库主从复制
  4. 监控告警:实时监控 QPS、库存、订单量,异常告警

总结 📝

同学,我们回顾下这场秒杀设计的整个逻辑闭环,一张图记牢:

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

高并发秒杀系统的设计精髓就是:"挡"、"削"、"异" 三个字

  • :在前端、网关、缓存层挡住 99.9% 的流量
  • :用消息队列削平流量峰值
  • :将非核心流程异步化

超卖问题的最优解是Redis+Lua 原子脚本,它在保证原子性的同时,提供了极高的性能,完全能扛住百万级 QPS 的秒杀场景。

核心代码

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

1. Redis 原子性库存预扣(Lua 脚本)💡

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

java 复制代码
/**
 * 原子性预扣减库存(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<String> keys = Arrays.asList(
        "seckill:stock:" + goodsId,
        "seckill:user:" + goodsId
    );

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

2. 分布式防重锁(Redisson 可重入锁 + 看门狗)🔒

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

java 复制代码
/**
 * 防止同一用户重复下单
 */
public Result seckill(Long goodsId, Long userId) {
    String lockKey = "seckill:lock:" + 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();
        }
    }
}

3. 消息队列幂等性消费者 ✅

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

java 复制代码
/**
 * 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());
    }
}

4. 数据库最终库存扣减(行锁 + 乐观锁)📊

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

sql 复制代码
-- 最终库存扣减(行锁实现,绝对不会超卖)
UPDATE seckill_goods 
SET stock_count = stock_count - 1,
    version = version + 1
WHERE goods_id = #{goodsId} 
  AND stock_count > 0 
  AND version = #{version};

5. 接口层的防刷与排队反馈(限流 + 秒杀结果异步通知)

java 复制代码
@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 内,不阻塞用户。
  • 失败补偿逻辑(生产需进一步细化)。

核心技术难点与解决方案

技术难点 问题本质 解决方案 技术亮点
库存超卖问题 ⚠️ 并发读写导致数据不一致,多个线程同时读到相同库存 1. Redis Lua 脚本原子预扣2. MySQL 行锁最终扣减3. 库存回补机制 双层防护,Redis 挡 99% 流量,DB 做最终兜底,绝对不会超卖
分布式锁可靠性问题 🔓 原生 SETNX 锁过期但业务未执行完,导致多个线程同时拿到锁 1. Redisson 可重入锁2. 看门狗自动续期3. 锁释放 finally 块 解决了锁过期、死锁、不可重入三大问题,生产级可靠
消息丢失与重复消费 📨 Kafka 消息在生产、传输、消费过程中可能丢失或重复 1. 生产者 ack=all + 重试2. 消费者手动提交 offset3. 订单 ID 幂等性 保证消息至少被消费一次,且不会重复处理
库存与订单一致性问题 🧩 库存扣减成功但订单创建失败,导致库存丢失 1. 最终一致性模型2. 定时任务补偿3. 死信队列人工处理 用异步 + 补偿代替强事务,性能提升 10 倍以上
热点商品性能瓶颈 🔥 单个商品百万级并发请求,导致 Redis 单节点 CPU 打满 1. 热点商品单独隔离2. Redis 库存分片3. 本地缓存预热 将热点流量分散到多个节点,单节点 QPS 从 10 万提升到 100 万
恶意请求与刷单问题 🤖 脚本机器人批量抢购,普通用户抢不到 1. 多层验证码(滑块 / 点选)2. 设备指纹识别3. 风控系统实时拦截 拦截 99% 以上的恶意请求,保证活动公平性
系统高可用问题 🚨 秒杀流量突增导致服务雪崩 1. Sentinel 限流降级2. 服务集群多活部署3. 全链路压测提前验证 系统过载时自动保护核心功能,不会整体宕机
订单超时取消问题 ⏰ 用户下单后未支付,需要自动取消并回补库存 1. Redisson 延迟队列2. 定时任务兜底扫描 延迟队列精确到秒级,比定时任务性能高 100 倍

🔁 难点解决亮点图解(以"超卖"为例)

flowchart LR A用户请求 --> B{Redis Lua<br/>DECR 分片 key} B -->|剩余 >=0| C允许进入排队 B -->|小于 0| D直接拒绝 C --> EMQ 异步下单 E --> F{DB 乐观锁<br/>UPDATE stock>0} F -->|影响行数>0| G下单成功 F -->|影响行数=0| H失败+补偿 G --> I更新 Redis 结果 H --> J回滚 Redis 库存

核心思路

  • Redis 做快速准入(高效,允许少量超卖"假象")
  • DB 做最终兜底(利用行锁/乐观锁严格不准超卖)
  • 失败补偿:DB 发现库存真没了,Redis 加的库存要吐回去。

这样一套组合拳,既保证了高并发下的性能,又用最终一致性保证不超卖,大厂里就是这么玩的 😎。

面试加分项总结 ✨

  1. 不要只说 "用 Redis 扣库存" ,一定要提到Lua 脚本原子性,这是区分初级和中级工程师的关键
  2. 不要只说 "用分布式锁" ,一定要提到Redisson 看门狗机制,说明你了解分布式锁的坑
  3. 不要只说 "用消息队列削峰" ,一定要提到幂等性和消息可靠性,说明你考虑了生产环境的异常情况
  4. 不要只说 "不超卖" ,一定要提到Redis+DB 双层防护 ,以及库存回补机制,说明你考虑了全流程的一致性

别走,交个朋友

我是Rain ,一个喜欢把复杂技术讲透、让代码落地的实践者。

如果你厌倦了四处收集碎片化的八股文,想看看一线项目里真实的架构决策和踩坑记录,欢迎来我的公众号「Rain的Java大神之路 」坐坐。

在这里,我会把每一次技术复盘、每一个项目的设计源码,毫无保留地分享给你。

关注后回复「源码」,我为你准备了几份我亲手注释的高并发项目核心源码,希望能帮你少走一些弯路。

🔥技术这条路很酷,我们结伴同行。

🚀Keep Coding, Keep Loving ------ 与所有 Java 同路人共勉。