206|秒杀系统设计:库存预热、削峰、限流、防刷、扣减一致性和兜底

架构设计与系统设计约 12 分钟阅读6,815 字符
知识库文档 207 / 251

阅读时间:约 45 分钟 分类:13 架构设计与系统设计 前置知识:Redis、消息队列、分布式锁、MySQL 事务


一、引言:为什么秒杀是系统设计的"终极考试"

2019 年双十一,某电商平台的秒杀会场在 0 点涌入 1200 万并发请求,目标商品仅 100 件。如果把秒杀当成普通的商品下单流程,结果只有一个:数据库瞬间被打爆,整个系统雪崩。

秒杀场景之所以特殊,是它叠加了多个极端因素:

特征 数值表现 技术含义
瞬时并发极高 数万 QPS 在 1 秒内涌入 常规三层架构根本无法承受
热点商品极少 99% 的请求落在同一行记录 数据库行锁成为瓶颈
读写比例悬殊 读:写 ≈ 100:1 大量无效请求浪费计算资源
库存竞争激烈 1 件商品 100 万人抢 超卖即是事故
黄牛和脚本 机器请求占比极高 需要专门的防刷体系

本文将按照一个请求从发出到结束的完整链路,逐一拆解秒杀系统的六大核心设计:库存预热 → 削峰 → 限流 → 防刷 → 扣减一致性 → 兜底。


二、全局架构:秒杀系统的分层设计

一个成熟的秒杀架构,从上到下至少分为六层。每一层都承担着不同的职责,组合起来形成一条"漏斗式"的流量削减链路。

[图1:秒杀系统六层架构全景图]

文章配图
文章配图

各层职责简述:

  • 接入层/CDN:静态资源缓存、边缘限流、DDoS 防护
  • 网关层:身份认证、频控限流、风控拦截、请求校验
  • 业务层(秒杀服务):库存预热、资格校验、令牌发放、异步下单
  • 数据层:Redis(库存扣减)、消息队列(削峰)、DB(订单)
  • 异步处理层:订单创建、支付处理、库存对账、物流通知

三、库存预热:把热点数据搬到离用户最近的地方

3.1 为什么需要预热

秒杀开始前,商品库存存储在 MySQL 中。如果每次请求都去数据库 SELECT stock FROM product WHERE id = ?,那么:

  • 数据库连接池瞬间耗尽(默认 20-50 个连接)
  • 行锁争抢导致大量请求阻塞
  • 即使 Redis 缓存,冷启动也要穿透到 DB

预热的核心思想:在秒杀开始前,将库存数据加载到 Redis(甚至本地缓存),让真正的扣减操作在内存中完成。

3.2 预热流程

[图2:库存预热流程——从运营后台到Redis加载]

文章配图
文章配图

秒杀开始前 30 分钟,运营后台创建秒杀活动,设置商品、库存、时间。秒杀服务随后将库存加载到 Redis(SET seckill:stock:123 100),并可选加载到本地缓存(Caffeine/Guava Cache)用于极速校验,减少 Redis 网络往返。

3.3 多层缓存架构

[图3:本地缓存 - Redis - MySQL 三层缓存架构]

文章配图
文章配图

关键约束:本地缓存和 Redis 之间、Redis 和 DB 之间,都存在数据一致性窗口。秒杀系统对这种"最终一致性"的容忍度较高,因为扣减以 Redis 为准,DB 作为最终落盘。

3.4 预热失败怎么办

如果 Redis 挂了、预热数据丢失,秒杀开始前必须有兜底:

java
// 兜底逻辑:从 MySQL 加载并预热
public int preheatStock(String productId) {
    String redisKey = "seckill:stock:" + productId;
    Integer stock = (Integer) redisTemplate.opsForValue().get(redisKey);
    if (stock == null) {
        // Redis 中无数据,从 DB 加载
        stock = productMapper.selectStock(productId);
        if (stock == null) {
            throw new BizException("商品不存在或未配置秒杀");
        }
        redisTemplate.opsForValue().set(redisKey, stock, 2, TimeUnit.HOURS);
    }
    return stock;
}

四、削峰:把洪峰变成涓涓细流

4.1 削峰的核心思路

秒杀的流量模型是一个"脉冲":0 秒瞬间冲高,1 秒后迅速回落。削峰的目的不是把峰值压下去,而是把同步的、瞬时的流量,转化为异步的、可排队处理的任务。

削峰有三板斧:

手段 位置 效果
前端削峰 按钮防抖、倒计时结束后才可点击 减少无效请求
网关削峰 令牌发放、排队机制 只让部分请求进入后端
消息队列削峰 下单请求异步化 后端以恒定速率消费

4.2 前端削峰

javascript
// 秒杀按钮防抖:点击后立即置灰,3 秒内不可重复点击
const btn = document.getElementById('seckill-btn');
btn.addEventListener('click', () => {
    btn.disabled = true;
    btn.textContent = '排队中...';
    fetch('/api/seckill/submit', { method: 'POST' })
        .then(res => res.json())
        .then(data => {
            if (data.code === 'QUEUED') {
                btn.textContent = '已加入排队';
            }
        });
    setTimeout(() => { btn.disabled = false; }, 3000);
});

4.3 网关层:令牌桶削峰

网关层不直接下发库存,而是先发"抢购资格令牌"。例如:100 件商品,发 500 个令牌(令牌数 = 库存 × 倍数),只有拿到令牌的请求才能进入后端。

java
// 基于 Redis 的令牌桶
public boolean tryAcquireToken(String userId, String productId) {
    String tokenKey = "seckill:token:" + productId;
    Long remaining = redisTemplate.opsForValue().decrement(tokenKey);
    if (remaining == null || remaining < 0) {
        // 令牌已耗尽,直接返回"已抢完"
        return false;
    }
    // 记录用户拿到令牌
    redisTemplate.opsForSet().add("seckill:token:users:" + productId, userId);
    return true;
}

令牌数量通常设置为库存的 3-5 倍。因为拿到令牌的用户不会 100% 完成支付,存在一定的放弃率。

4.4 消息队列削峰

拿到令牌后,下单请求不直接操作数据库,而是写入消息队列。用户请求 → 网关令牌校验 → 消息队列 → 消费者(恒定速率消费)。

[图4:消息队列削峰——生产者快速写入,消费者匀速消费]

文章配图
文章配图

java
// 生产者:快速写入 MQ
public SeckillResult submitOrder(SeckillRequest req) {
    // 1. 令牌校验
    if (!tokenService.tryAcquireToken(req.getUserId(), req.getProductId())) {
        return SeckillResult.SOLD_OUT;
    }
    // 2. 发送到消息队列,快速返回
    rocketMQTemplate.send("seckill-order-topic", req);
    return SeckillResult.QUEUED;
}

// 消费者:匀速消费,操作数据库
@RocketMQMessageListener(topic = "seckill-order-topic", consumerGroup = "seckill-order")
public class OrderConsumer implements RocketMQListener<SeckillRequest> {
    @Override
    public void onMessage(SeckillRequest req) {
        // 扣减库存、创建订单
        seckillService.createOrder(req);
    }
}

为什么消息队列能削峰:假设秒杀瞬间涌入 10 万 QPS,消息队列可以接收全部请求,但消费者以 1000 QPS 的恒定速度消费。这样后端数据库的负载始终是可控的 1000 QPS,而不是 10 万 QPS。


五、限流:给系统装上"安全阀"

限流和削峰常被混淆,但它们的定位不同:

维度 削峰 限流
目的 流量整形,平滑处理 保护系统,拒绝过量
处理方式 排队、异步化 直接拒绝(返回错误/降级)
关注点 让请求"等等再来" 让请求"别来了"

5.1 限流算法

算法 原理 特点
固定窗口 每个时间窗口内计数 简单,但有临界突变问题
滑动窗口 窗口随时间滑动,精确计数 平滑,Redis ZSET 实现
漏桶 请求进入桶中,以固定速率流出 恒定速率,适合保护后端
令牌桶 以固定速率放入令牌,请求消耗令牌 允许突发流量,适合网关

推荐秒杀场景使用滑动窗口 + 令牌桶组合:

  • 网关层:令牌桶限制总流量(如 5000 QPS)
  • 业务层:滑动窗口限制单用户(如 1 次/秒)

5.2 滑动窗口的 Redis 实现

java
public boolean isAllowed(String userId, int maxRequests, int windowSeconds) {
    String key = "rate:user:" + userId;
    long now = System.currentTimeMillis();
    long windowStart = now - windowSeconds * 1000L;

    // 移除窗口外的记录
    redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
    // 统计窗口内的请求数
    Long count = redisTemplate.opsForZSet().count(key, windowStart, now);

    if (count != null && count >= maxRequests) {
        return false; // 超过限制
    }
    // 记录本次请求
    redisTemplate.opsForZSet().add(key, String.valueOf(now), now);
    redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
    return true;
}

5.3 多级限流架构

[图5:多级限流架构——从CDN到RPC四层限流]

文章配图
文章配图

L1: CDN 边缘限流 → 单 IP 100 QPS L2: Nginx 网关限流 → 单接口 10000 QPS L3: 应用层 Sentinel → 单接口 5000 QPS,热点参数限流 L4: RPC 框架限流 → 单方法 2000 QPS

Sentinel 热点参数限流特别适合秒杀:对同一个商品 ID 的请求进行限流,而不是对整个接口限流。这样即使秒杀流量很大,其他正常商品不受影响。


六、防刷:区分"真用户"和"机器人"

秒杀场景中,有相当比例的请求来自脚本和黄牛。防刷的目标不是 100% 拦截(这不可能),而是大幅提高刷单成本,让自动化攻击无利可图。

6.1 防刷体系

[图6:防刷体系全景——事前预防、事中拦截、事后追查]

文章配图
文章配图

6.2 核心防刷手段

1. 验证码(滑块/图形)

秒杀按钮点击后弹出滑块验证码,通过后才发请求。这是最简单有效的防刷手段:

java
@PostMapping("/seckill/submit")
public Result submit(@RequestBody SeckillReq req) {
    // 验证滑块验证码
    if (!captchaService.verify(req.getCaptchaId(), req.getCaptchaAnswer())) {
        return Result.fail("验证码错误");
    }
    // 后续业务逻辑...
}

2. 隐藏 Token + 动态 URL

秒杀 URL 不应该是一个固定的 /seckill/product/123,而是每次动态生成:

java
// 秒杀开始前,后端生成动态 URL 并下发
public String getSeckillUrl(String productId) {
    String token = UUID.randomUUID().toString().replace("-", "");
    String url = "/seckill/" + productId + "?token=" + token;
    // 缓存 token,有效期 5 分钟
    redisTemplate.opsForValue().set("seckill:url:" + token, productId, 5, TimeUnit.MINUTES);
    return url;
}

3. 用户行为分析

真实用户和脚本的行为差异明显:

维度 真实用户 脚本
页面停留时间 10-60 秒 < 1 秒
鼠标轨迹 非线性 无轨迹或直线
请求间隔 毫秒级抖动 精确定时
设备指纹 多样性 单一或伪造

前端埋点收集这些数据,传给风控引擎做实时判断。

4. 单用户限购

java
// 每人限购 1 件
public boolean checkUserLimit(String userId, String productId) {
    String key = "seckill:user:limit:" + productId + ":" + userId;
    // SETNX 保证原子性
    Boolean success = redisTemplate.opsForValue()
        .setIfAbsent(key, "1", 24, TimeUnit.HOURS);
    return Boolean.TRUE.equals(success);
}

七、扣减一致性:超卖是秒杀系统的"红线"

这是秒杀系统中最核心、最不能出错的一环。超卖 = 事故,没有商量的余地。

7.1 扣减方案对比

方案 实现方式 优点 缺点
MySQL 乐观锁 UPDATE ... SET stock = stock - 1 WHERE stock &amp;gt; 0 简单可靠 行锁竞争,QPS 低
Redis DECR DECR seckill:stock:123 原子操作,高性能 需要与 DB 同步
Redis Lua 脚本 DECR + 判断库存 + 记录用户 原子复合操作 逻辑复杂时调试困难
预扣减 + 异步确认 Redis 扣减 → MQ → DB 确认 最终一致性 需要补偿机制

推荐方案:Redis Lua 脚本 + 异步 DB 同步。

7.2 Redis Lua 脚本实现

lua
-- seckill.lua
-- KEYS[1]: 库存 key (seckill:stock:123)
-- KEYS[2]: 已购买用户 set (seckill:users:123)
-- ARGV[1]: 用户 ID
-- ARGV[2]: 限购数量

local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
    return -1  -- 库存不足
end

-- 检查用户是否已购买
local exists = redis.call('SISMEMBER', KEYS[2], ARGV[1])
if exists == 1 then
    return -2  -- 已购买
end

-- 扣减库存
redis.call('DECR', KEYS[1])
-- 记录用户
redis.call('SADD', KEYS[2], ARGV[1])

return 1  -- 成功

Java 调用:

java
public int deductStock(String productId, String userId) {
    String stockKey = "seckill:stock:" + productId;
    String usersKey = "seckill:users:" + productId;
    Long result = redisTemplate.execute(
        seckillScript,
        Arrays.asList(stockKey, usersKey),
        userId, "1"
    );
    return result != null ? result.intValue() : -1;
}

7.3 数据一致性链路

[图7:数据一致性链路——从Redis扣减到MySQL落盘的对账闭环]

文章配图
文章配图

整个链路:Redis 扣减成功 → 发送 MQ 消息(订单创建事件) → 消费者消费消息 → MySQL 扣减库存(乐观锁)+ 创建订单记录 → 失败则重试/死信队列 → 定时对账任务对比 Redis 库存 vs MySQL 库存,不一致则告警 + 人工处理。

7.4 为什么不能只用 MySQL

sql
-- 乐观锁方案
UPDATE product 
SET stock = stock - 1 
WHERE id = 123 AND stock > 0;

这条 SQL 在 10000 QPS 下会发生什么:

  • InnoDB 行锁:同一行的 UPDATE 操作串行化
  • 大量请求在行锁上排队等待
  • 数据库 CPU 飙升,连接池耗尽
  • 其他正常业务(非秒杀商品)也被拖垮

Redis 的价值:单机 10 万 QPS 的原子操作能力,把热点行锁从 MySQL 转移到 Redis。


八、兜底方案:优雅地处理"所有可能出错的事"

秒杀系统是一个"预期内会部分失败"的系统。兜底不是"备选",而是设计的一部分。

8.1 兜底清单

场景 兜底手段
Redis 宕机 降级到 MySQL 乐观锁,牺牲性能保可用
MQ 积压 动态扩容消费者,限流生产端
数据库故障 主从切换 + 读降级(只返回缓存数据)
秒杀页面崩溃 静态降级页(CDN 缓存)
支付回调丢失 定时对账 + 主动查询
超卖已发生 人工退款 + 补偿 + 库存修正

8.2 降级开关设计

java
@Component
public class SeckillDegradeManager {
    
    @Value("${seckill.degrade.level:0}")
    private int degradeLevel; // 0=正常, 1=部分降级, 2=全面降级
    
    public boolean isRedisAvailable() {
        if (degradeLevel >= 1) return false;
        try {
            redisTemplate.opsForValue().get("health:check");
            return true;
        } catch (Exception e) {
            return false;
        }
    }
    
    public int deductStock(String productId) {
        if (isRedisAvailable()) {
            return deductWithRedis(productId);
        }
        // 降级到 MySQL
        log.warn("Redis unavailable, fallback to MySQL");
        return deductWithMySQL(productId);
    }
}

8.3 前端兜底

html
<!-- 降级静态页:即使后端全挂,用户也能看到有意义的提示 -->
<div class="seckill-degrade">
    <h2>活动太火爆了</h2>
    <p>当前参与人数过多,请稍后再试</p>
    <p>您也可以<a href="/">返回首页</a>浏览其他商品</p>
</div>

8.4 对账与补偿

每天凌晨执行定时对账任务:

java
@Scheduled(cron = "0 0 3 * * ?")
public void reconcile() {
    // 1. 查询所有秒杀活动
    List<SeckillActivity> activities = activityMapper.selectFinished();
    
    for (SeckillActivity activity : activities) {
        // 2. Redis 中的库存扣减量
        Long redisDeducted = activity.getTotalStock() - 
            redisTemplate.opsForValue().get("seckill:stock:" + activity.getProductId());
        
        // 3. MySQL 中的实际订单数
        Long orderCount = orderMapper.countByActivity(activity.getId());
        
        // 4. 对比
        if (!redisDeducted.equals(orderCount)) {
            alertService.send("秒杀对账异常", 
                String.format("活动 %s: Redis扣减 %d, 订单数 %d", 
                    activity.getId(), redisDeducted, orderCount));
        }
    }
}

九、总结:秒杀系统的设计核心

回顾整个秒杀系统,本质上是把不可控的峰值流量,通过多层漏斗,转化为可控的、可处理的任务流:

[图8:流量漏斗——从100万请求到500个订单的转化过程]

文章配图
文章配图

100 万并发请求 → (CDN + 前端防抖) 80 万静态资源直接返回 → (网关限流 + 令牌桶) 5 万拿到令牌 → (Redis 库存扣减) 500 个扣减成功 → (消息队列) 500 个异步创建订单。

六要素的内在关系:

  • 库存预热:把数据从慢介质搬到快介质,是性能的基础
  • 削峰:把同步变异步,保护后端处理能力
  • 限流:给系统设置安全边界,防止过载雪崩
  • 防刷:过滤无效流量,让真实用户获得公平机会
  • 扣减一致性:核心业务逻辑,超卖零容忍
  • 兜底:承认"系统会出问题",提前设计降级路径

面试时回答秒杀系统设计的三个层次:

  1. 基础层:能说出 Redis 扣库存、MQ 削峰、限流防刷
  2. 进阶层:能解释 Lua 脚本的原子性、消息队列的消费模型、数据一致性保障
  3. 专家层:能讨论本地缓存与分布式缓存的一致性、多层降级策略、对账补偿体系、以及与其他系统的交互(支付、物流、风控)

参考资料

  • 《大型网站技术架构:核心原理与案例分析》第 6 章——秒杀系统设计
  • Redis 官方文档:Lua Scripting 与 Transaction
  • 阿里巴巴 Sentinel 限流框架官方文档
  • RocketMQ 事务消息与延迟消息机制
  • Martin Fowler: Patterns of Enterprise Application Architecture

十、面试官视角:追问与回答

以下问题来自真实面试场景,建议在理解全文的基础上独立回答,再对照参考答案。

Q1:Redis 扣减库存后,如果 Redis 宕机了,库存数据就丢了,怎么处理?

Q2:你说用 Lua 脚本保证原子性,那 Lua 脚本执行期间 Redis 是单线程的,会不会阻塞其他请求?

Q3:秒杀系统的消息队列,消费者消费到一半宕机了,这条消息会丢吗?订单会丢吗?

Q4:你们的防刷策略,如果用户用几百个不同的 IP 和账号、不同的设备来抢,怎么防?

Q5:为什么不用"下单时才扣库存"而是"先扣库存再下单"?这两种方案各自的优劣是什么?

Q6:假设秒杀商品有 10000 件库存而不是 100 件,架构设计上需要做哪些调整?

Q7:如果秒杀和普通商品在同一个系统中,如何保证秒杀流量不影响普通商品?

Q8:你说"拿不到令牌的用户直接返回卖完了",但用户可能反复刷新,怎么处理这个场景?

Q9:秒杀结束后,已扣减但未支付的库存怎么释放?

Q10:在设计秒杀系统时,如果让你从 0 到 1 设计,你觉得最难的 3 个点是什么?为什么?


参考答案

A1:Redis 宕机库存丢失:分两种情况。秒杀进行中宕机:降级到 MySQL 乐观锁扣减,虽然性能骤降但保证不超卖。秒杀结束后宕机:Redis 数据持久化(AOF/RDB)可用于恢复,同时定时对账任务将 MySQL 中实际订单数与预期库存做对比,发现差异后告警。从根本上,Redis 应部署哨兵/集群模式保证高可用,单机宕机不应发生。

A2:Lua 脚本阻塞:Redis 单线程执行 Lua 时确实会阻塞其他命令。但 Lua 脚本通常非常短(几十行),执行时间在微秒到毫秒级。对于秒杀场景,单次 Lua 脚本执行远小于 1ms,对 Redis 整体吞吐影响极小。真正需要担心的是在 Lua 中执行 KEYS * 或遍历大集合。如果确实有复杂逻辑,拆成多个小脚本,通过 Redis 事务(MULTI/EXEC)或客户端逻辑串联。

A3:MQ 消息丢失:不会丢。RocketMQ/Kafka 都支持消费确认机制(ACK)。消费者只有在业务处理成功后才返回 CONSUME_SUCCESS,如果消费到一半宕机,MQ 没有收到 ACK,会在超时后重新投递给其他消费者。前提是:(1)订单创建在事务中完成;(2)MQ 消费是幂等的——同一条消息重试不会重复创建订单;(3)MQ 本身配置了同步刷盘。

A4:分布式攻击防刷:这是防刷中最难的问题——"分布式攻击"。单一维度的防刷(IP、设备、账号)都可以被绕过。有效方案是多维度联合风控:(1)收货地址聚集度(100 个账号收货地址相同);(2)支付账户关联(同一银行卡/支付宝);(3)行为相似度(操作时间间隔、页面停留模式);(4)图数据库关联分析(账号-设备-IP-地址的关系图)。这些通常由专门的风控团队和系统来做,秒杀系统本身集成风控 API 即可。

A5:先扣库存 vs 先下单:核心区别在于"谁先谁后"。先扣库存再下单:用户看到"抢到了"就意味着库存已扣,体验好,但需处理用户放弃支付的情况(超时释放库存)。先下单再扣库存:下单时库存还在,但支付时可能库存没了,体验差("超卖感知")。秒杀场景优先选择"先扣库存",因为(1)用户体验更重要;(2)库存极少,释放量可控;(3)配合支付倒计时(15 分钟),未支付自动释放。

A6:大库存场景(10000 件):与 100 件有本质区别:(1)100 件时几乎所有请求都是"无效请求",需用令牌桶极早过滤;10000 件时有效请求比例高,令牌桶倍数可放大(1.5-2 倍);(2)Redis 单 key 热点问题依然存在,但可通过库存分片缓解(如 100 个分片各存 100 件);(3)大库存场景下,MQ 消费能力需对应提升;(4)MySQL 乐观锁竞争压力相对降低,可考虑部分场景直接走 DB。

A7:秒杀与普通商品隔离:核心是隔离:(1)部署隔离:秒杀服务独立部署,不与其他服务共享容器/线程池;(2)数据库隔离:秒杀商品使用独立 Redis 集群和 MySQL 实例/库;(3)连接池隔离:秒杀服务使用独立数据库连接池;(4)熔断保护:秒杀服务失败不应级联影响其他服务。阿里的做法是"秒杀集群"——物理隔离。

A8:反复刷新处理:分两层:(1)前端:拿到"已售罄"状态后,缓存到 localStorage,30 秒内不重复请求,按钮置灰显示"已售罄";(2)后端:对已售罄商品,网关层直接返回"已售罄"(读 Redis 标记),不进入业务逻辑。这比经过完整链路后返回售罄效率高得多。

A9:未支付库存释放:设置订单超时时间(如 15 分钟),超时未支付自动取消。取消时执行库存回补:INCR seckill:stock:123。注意回补也有并发问题——如果用户同时支付和取消,需加分布式锁或使用状态机保证幂等。建议使用延迟消息(RocketMQ 的延迟级别)来实现定时关单。

A10:最难的 3 个点:(1)库存一致性——在 Redis 和 MySQL 之间、在扣减和回补之间,保证最终一致且不超卖,非常考验对分布式事务的理解;(2)热点处理——单行库存的热点问题从应用层到数据库层都存在,需从缓存分片、读写分离、请求合并等多角度综合解决;(3)全链路压测和容量评估——秒杀的实际流量很难模拟,压测不充分导致线上事故的案例比比皆是,需要影子库、流量染色、灰度验证等配套能力。