设计一个电商秒杀系统
提出问题
秒杀是电商系统中最具挑战性的场景之一。双 11 秒杀、限量抢购、限时折扣——这些活动的共同特征是:瞬间涌入大量用户,但商品数量极其有限(比如 1000 台 iPhone 卖 1999 元,10 万人同时抢)。面试官出这道题,核心考察三个维度:库存扣减的并发安全(怎么保证不多卖)、流量削峰(怎么让系统不被瞬间冲垮)、高可用兜底(Redis 挂了怎么办)。这是面试中出现频率最高的系统设计题,没有之一。
生产上,秒杀系统是"不能出错的系统"——多卖了要赔钱,少卖了要背锅,系统挂了则整场活动归零。所以这道题不是考你背方案,而是看你有没有真正处理过百万级并发写。
漏斗架构:流量逐层过滤
秒杀系统的核心设计思想是"漏斗"——流量从入口到最终下单,每一层都在削峰减量。典型的四层漏斗:
用户请求 (10万 QPS)
│
▼
┌─────────────────────────────┐
│ 接入层: Nginx 限流 + 验证码 │ → 过滤到 ~1万 QPS
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ 应用层: 令牌桶 + 去重 │ → 过滤到 ~5000 QPS
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ 扣库存层: Redis Lua 原子扣减 │ → 只放行真正扣到库存的请求
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ 持久化层: MQ 异步写 DB + 对账 │ → 最终 DB 写 ~1000 条
└─────────────────────────────┘秒杀 1000 件商品,1 秒 10 万请求进来,经过四层过滤后,最终落到 DB 的写操作只有 1000 条。每一层都把上一层的流量再砍掉一个数量级。
接入层细节
Nginx 层面做两件事:
- IP 维度的单机限流:
limit_req_zone $binary_remote_addr zone=flash:10m rate=1r/s,每个 IP 每秒只能发 1 个请求 - 验证码/UUID 准入:前端在秒杀前 1 秒才展示验证码,防止机器提前刷到接口
Nginx 配置示例:
limit_req_zone $binary_remote_addr zone=flaship:10m rate=1r/s;
limit_req zone=flaship burst=5 nodelay;踩坑记录:某次秒杀活动,Nginx 的 limit_req 配置了 burst=0,结果大量正常用户因为瞬间并发被拦截,投诉率飙升。生产上 burst 至少设为 5-10,配合 nodelay 使用,让短时间内的突发请求在限流速率内尽快通过,而不是排成队列。
库存扣减:原子的 Redis DECR 还不够
库存扣减是秒杀中最敏感的环节。先看错的方案:
// ❌ 错误做法:先查再扣,非原子操作
int stock = redisTemplate.opsForValue().get("stock:iphone16");
if (stock > 0) {
redisTemplate.opsForValue().decrement("stock:iphone16");
// 并发竞争:两个线程同时读到 stock=1,都执行 decrement,库存变 -1
}正确的做法是用 Redis 原子操作:
// ✅ 正确做法:Lua 脚本保证原子性
// 注意:Redis 集群模式下,KEYS 必须都在同一个 slot
// 否则会报 CROSSSLOT 错误
String luaScript = """
local stock = redis.call('GET', KEYS[1])
if not stock or tonumber(stock) <= 0 then
return 0 -- 库存不足
end
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功
""";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList("stock:iphone16")
);为什么单机 Redis 只用 DECR 就够了? Redis 的 DECR 命令本身是原子的(单线程模型),但生产环境基本不会用单机 Redis,集群模式下 GET 和 DECR 是两个网络往返,非原子。所以必须用 Lua 脚本将两个操作打包成一次执行。
踩坑:CROSSSLOT 错误
ERR CROSSSLOT Keys in request don't hash to the same slot在 Redis Cluster 中使用 Lua 脚本时,KEYS 数组里的所有 key 必须落在同一个 hash slot。解决方案:用 {hashtag} 强制 key 到同一个 slot:
redisTemplate.execute(script,
Collections.singletonList("stock:{iphone16}"), // 用 {xxx} 保证同 slot
args
);双写 + 对账是生产必选项
Redis 扣减成功后,将扣减记录写入 MQ,消费端异步写 DB 订单。完整流程:
用户请求 → Redis Lua 扣库存(同步)
├─ 成功 → 发送 MQ 消息(异步)
│ └─ 消费端写 DB 订单表
└─ 失败 → 返回"已售罄"// 服务端完整流程
public boolean flashSale(Long userId, Long productId) {
// 1. 去重(用户只能抢一次)
String userKey = "flash:users:" + productId;
if (Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(userKey, userId.toString()))) {
return false; // 已抢过,直接返回
}
// 2. Redis Lua 原子扣库存
Long result = executeStockLua(productId);
if (result == 0) {
return false; // 库存不足
}
// 3. 记录用户(防重复,同时用于后续释放未支付库存)
redisTemplate.opsForSet().add(userKey, userId.toString());
// 4. 发送 MQ 消息,异步写订单
FlashSaleMessage msg = new FlashSaleMessage(userId, productId);
kafkaTemplate.send("flash-sale-order", JSON.toJSONString(msg));
return true;
}对账脚本:每隔 5 分钟,对比 Redis 剩余库存 + DB 已售数量 = 总库存。不一致则触发告警。对账脚本的核心逻辑:
-- 对账查询
SELECT COUNT(*) as sold_count FROM flash_sale_order
WHERE product_id = 1001 AND status = 'PAID' AND create_time > '2026-07-23 00:00:00';
-- 获取 Redis 剩余库存
redis> GET stock:{1001}
-- 判断:sold_count + redis_stock == total_stock ?踩坑:对账脚本查 DB 时,如果不加时间范围,会把历史已售数据也统计进去,导致一直对不平。必须加 create_time > 活动开始时间 过滤。
流量削峰:MQ 异步 + 限流四件套
削峰不止是"用 MQ"这么简单,需要组合拳:
// 1. 令牌桶限流(Guava RateLimiter)
RateLimiter limiter = RateLimiter.create(1000); // 每秒最多 1000 个令牌
// 2. 秒杀请求入队(Kafka/RocketMQ)
public boolean tryFlashSale(Long userId, Long productId) {
// 限流检查
if (!limiter.tryAcquire()) {
return false; // 直接返回"抢光了"
}
// 去重检查(同一用户只能抢一次)
Boolean existed = redisTemplate.opsForSet().isMember("sale:iphone16:users", userId.toString());
if (Boolean.TRUE.equals(existed)) {
return false;
}
// 发送到 MQ,消费端异步处理
kafkaTemplate.send("flash-sale-order", userId + ":" + productId);
return true;
}
// 3. 消费端限速:逐个处理,不过载 Redis
@KafkaListener(topics = "flash-sale-order", containerFactory = "singleRecordFactory")
public void consume(ConsumerRecord<String, String> record) {
// 扣库存(Redis DECR)→ 写订单(DB)
// 失败则重试 3 次,仍失败入死信队列人工处理
}消费端限速:最容易忽略的一环
错误的消费端配置:
# 默认配置:一次拉取 500 条,并发处理
spring.kafka.consumer.max-poll-records: 500这样配置后,500 个扣库存请求同时打到 Redis,Redis 单线程 CPU 直接飙到 100%,延迟从 1ms 升到 100ms+,雪崩。
正确的配置:
spring.kafka.consumer.max-poll-records: 1
spring.kafka.listener.concurrency: 1RocketMQ 对应设置 consumeMessageBatchMaxSize=1。让消费端逐个处理,而不是批量处理。
四种限流方案对比
| 方案 | 适用场景 | 优点 | 缺点 | 生产推荐度 |
|---|---|---|---|---|
| Nginx limit_req | IP 维度接入层 | 配置简单,不侵入代码 | 只能按 IP,无法按用户 | ⭐⭐⭐⭐ |
| Guava RateLimiter | 单机应用层 | 轻量,零依赖 | 不适用于分布式,多节点不准确 | ⭐⭐⭐ |
| Sentinel | 分布式应用层 | 支持集群流控、熔断降级、实时监控 | 需要额外部署 dashboard | ⭐⭐⭐⭐⭐ |
| Redis 计数器 | 分布式全局流控 | 精确,能按用户/商品维度 | 高并发下 Redis 压力大 | ⭐⭐⭐ |
生产推荐:Sentinel + Nginx 双层限流。Nginx 挡大头,Sentinel 做精细控制。
三级库存与"静默期"预热
P7/P8 级别的生产方案,需要区分三级库存:
| 库存类型 | 定义 | 存储位置 | 更新方式 | 典型值 |
|---|---|---|---|---|
| 展示库存 | 用户看到的"还剩 X 件" | Redis | 异步更新,允许秒级延迟 | 每 2 秒同步一次 |
| 冻结库存 | 秒杀期间预扣的 | Redis | 秒杀预扣时实时更新 | 预扣数量 = 下单数量 |
| 实际库存 | 仓库真实库存 | DB | 订单支付后扣减 | 扣减完成后写入 |
为什么需要三级库存? 没有三级库存时,一个用户秒杀成功但 15 分钟内未支付,这 15 分钟内商品显示"已售罄",其他用户无法购买,造成库存浪费。三级库存的解耦思路:
秒杀成功 → 冻结库存 +1(Redis)
└─ 15 分钟内支付 → 冻结库存 -1,实际库存 -1(DB)
└─ 15 分钟后未支付 → 冻结库存 -1,释放到展示库存(Redis)定时释放脚本(每 30 秒执行一次):
-- 查询超时未支付的订单
UPDATE flash_sale_order
SET status = 'TIMEOUT'
WHERE status = 'CREATED' AND create_time < NOW() - INTERVAL 15 MINUTE;
-- 已释放的订单,Redis 中补回展示库存(异步通知)静默期预热
秒杀开始前 10 分钟,将商品详情、库存快照推入 Redis,活动开始时所有流量只走 Redis,不碰 DB。预热阶段要做的事:
- 商品缓存预热:Redis 写入
product:{1001} = { "name":"iPhone16", "price":1999, "stock":1000 } - 库存预热:Redis 写入
stock:{1001} = 1000 - 预热队列建立:前 10 分钟用户可点击"提醒我",减轻瞬时流量
- CDN 静态资源预热:秒杀页面的 HTML/CSS/JS 推送到 CDN 边缘节点
// 预热脚本
@Scheduled(cron = "0 0 10 * * ?") // 每天 10:00 检查是否有秒杀活动
public void warmUp() {
List<FlashSaleActivity> activities = flashSaleMapper.getTodayActivities();
for (FlashSaleActivity activity : activities) {
// 预热商品缓存
String productKey = "product:" + activity.getProductId();
redisTemplate.opsForValue().set(productKey, JSON.toJSONString(activity));
// 预热库存
String stockKey = "stock:" + activity.getProductId();
redisTemplate.opsForValue().set(stockKey, String.valueOf(activity.getTotalStock()));
// 设置过期时间(活动结束后自动清理)
redisTemplate.expire(productKey, 24, TimeUnit.HOURS);
redisTemplate.expire(stockKey, 24, TimeUnit.HOURS);
}
}生产踩坑实录
踩坑 1:Redis 主从切换导致库存回弹
某次秒杀活动,Redis 主节点宕机触发 Sentinel 切换。新主节点是从节点升上来的,而 DECR 命令在从节点上默认不执行(从节点只读),导致主从切换期间的 2-3 秒内,库存扣减请求全部失败。切换完成后,新主节点上的库存值还是活动开始时的值,等于之前 3 秒的扣减全部丢失,库存"回弹"了。
解决方案:对账脚本在活动结束后,根据 DB 实际订单数修正 Redis 库存。同时在 Redis 侧做 WAIT 命令确保写操作同步到至少一个从节点:
redis> SET stock:1001 1000
redis> WAIT 1 1000 -- 等待至少 1 个从节点确认,超时 1000ms踩坑 2:用户重复请求放大
秒杀页面被用户反复刷新,前端虽做了按钮置灰,但部分用户通过 F12 禁用 JS 或直接 CURL 请求接口。解决方案:前端 + 后端双重去重。
前端下发一个 UUID 令牌(通过 SSR 注入到页面),每次请求携带该令牌,后端验证令牌是否已被消费。令牌有效期 10 秒,用 Redis INC 的返回值判断是否重复消费。
踩坑 3:库存扣完了但订单还在写
Redis 库存扣到 0 后,MQ 中还有大量未消费的消息。这时消费端扣库存 Redis 返回 0,订单写入失败,但这些消息会进入重试队列,重复尝试 3 次,最终进入死信队列。死信队列积累几万条消息,人工处理耗时 2 小时。
解决方案:消费端先 check Redis 库存,如果为 0 则直接丢弃消息(不重试),不进入死信队列:
if (redisTemplate.opsForValue().get("stock:" + productId) <= 0) {
log.warn("库存已售罄,丢弃消息: {}", message);
return; // 不抛异常,避免重试
}面试回答策略
面试官问"设计秒杀系统"时,不要一上来就画架构图,答案要有层次:
- 先说约束:QPS 量级(10 万?100 万?)、商品数量(1000 件?1 万件?)、库存精度要求(允许超卖 1%?)
- 再说核心挑战:库存扣减并发安全、流量削峰、高可用
- 然后说方案:四层漏斗架构,每层具体怎么实现
- 最后说踩坑:Redis 主从回弹、消费端限速、死信队列堆积
面试话术示例:"我们某次秒杀活动 1000 台手机,10 万人同时抢。采用四层漏斗架构:Nginx 做 IP 限流,每 IP 每秒 1 次;应用层用 Redis Lua 脚本原子扣减库存,扣减成功后通过 RocketMQ 异步写订单;消费端设置 consumeMessageBatchMaxSize=1,逐个处理,避免 Redis 过载;活动结束后定时对账脚本保证 Redis 与 DB 库存一致。Redis 挂了怎么办?我们有 Redis 主从 + RDB 持久化,同时 DB 侧有对账机制,最坏情况下回滚未支付的订单,保证实际库存不超卖。曾经踩过一个坑:Redis 主从切换时库存回弹,导致最后超卖了 3 件,后来在活动结束后加了对账修正脚本才解决。"
参考:淘宝秒杀系统架构演进、Redis 秒杀 Lua 脚本设计、Sentinel 限流文档、某电商 618 秒杀复盘