秒杀系统如何设计

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

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

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

咱们直接看一个全局架构图,心里先有个底 👇
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 同路人共勉。

相关推荐
Rain的Java大神实战圈2 天前
数据库主从有延迟怎么处理
架构设计·场景设计题
Rain的Java大神实战圈6 天前
分库分表会带来哪些问题如何解决
架构设计·场景设计题
Rain的Java大神实战圈7 天前
介绍一下分库分表
架构设计·场景设计题
Rain的Java大神实战圈8 天前
线上慢SQL的排查思路
经验分享·架构设计·场景设计题
Rain的Java大神实战圈9 天前
MySQL单表有10亿数据如何做迁移
架构设计·面试经验·场景设计题
Rain的Java大神实战圈12 天前
如何统计亿级用户的在线状态
架构设计·面试经验·场景设计题
Rain的Java大神实战圈13 天前
扫码登录的实现原理
架构设计·面试经验·场景设计题
Rain的Java大神实战圈15 天前
每日排行榜功能如何设计
redis·架构设计·面试经验·场景设计题