阅读时间:约 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 挂了、预热数据丢失,秒杀开始前必须有兜底:
// 兜底逻辑:从 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 前端削峰
// 秒杀按钮防抖:点击后立即置灰,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 个令牌(令牌数 = 库存 × 倍数),只有拿到令牌的请求才能进入后端。
// 基于 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:消息队列削峰——生产者快速写入,消费者匀速消费]

// 生产者:快速写入 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 实现
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. 验证码(滑块/图形)
秒杀按钮点击后弹出滑块验证码,通过后才发请求。这是最简单有效的防刷手段:
@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,而是每次动态生成:
// 秒杀开始前,后端生成动态 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. 单用户限购
// 每人限购 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 &gt; 0 |
简单可靠 | 行锁竞争,QPS 低 |
| Redis DECR | DECR seckill:stock:123 |
原子操作,高性能 | 需要与 DB 同步 |
| Redis Lua 脚本 | DECR + 判断库存 + 记录用户 |
原子复合操作 | 逻辑复杂时调试困难 |
| 预扣减 + 异步确认 | Redis 扣减 → MQ → DB 确认 | 最终一致性 | 需要补偿机制 |
推荐方案:Redis Lua 脚本 + 异步 DB 同步。
7.2 Redis 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 调用:
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
-- 乐观锁方案
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 降级开关设计
@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 前端兜底
<!-- 降级静态页:即使后端全挂,用户也能看到有意义的提示 -->
<div class="seckill-degrade">
<h2>活动太火爆了</h2>
<p>当前参与人数过多,请稍后再试</p>
<p>您也可以<a href="/">返回首页</a>浏览其他商品</p>
</div>
8.4 对账与补偿
每天凌晨执行定时对账任务:
@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 个异步创建订单。
六要素的内在关系:
- 库存预热:把数据从慢介质搬到快介质,是性能的基础
- 削峰:把同步变异步,保护后端处理能力
- 限流:给系统设置安全边界,防止过载雪崩
- 防刷:过滤无效流量,让真实用户获得公平机会
- 扣减一致性:核心业务逻辑,超卖零容忍
- 兜底:承认"系统会出问题",提前设计降级路径
面试时回答秒杀系统设计的三个层次:
- 基础层:能说出 Redis 扣库存、MQ 削峰、限流防刷
- 进阶层:能解释 Lua 脚本的原子性、消息队列的消费模型、数据一致性保障
- 专家层:能讨论本地缓存与分布式缓存的一致性、多层降级策略、对账补偿体系、以及与其他系统的交互(支付、物流、风控)
参考资料
- 《大型网站技术架构:核心原理与案例分析》第 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)全链路压测和容量评估——秒杀的实际流量很难模拟,压测不充分导致线上事故的案例比比皆是,需要影子库、流量染色、灰度验证等配套能力。