秒杀系统如何设计
如何设计一个高并发的秒杀系统?超卖问题如何解决?
面试官您好!关于高并发秒杀系统的设计,我会从核心挑战、整体架构、高并发优化和超卖问题根治四个维度来回答,确保方案既落地又能扛住百万级 QPS。
设计秒杀系统,核心就三个字:快、稳、准。
- 快:响应要快,流量要在上游就被挡住。
- 稳:系统不能崩,要层层削峰。
- 准:库存不能多扣,绝对不能超卖。
咱们直接看一个全局架构图,心里先有个底 👇
这个链路,就是我们今天要手撕的全部。
秒杀系统的三大核心挑战 ⚠️
| 挑战 | 本质问题 | 后果 |
|---|---|---|
| 瞬时高并发 | 短时间内流量暴增 100-1000 倍 | 服务器雪崩、数据库宕机 |
| 超卖问题 | 并发读写导致库存不一致 | 订单量 > 库存量,资损 |
| 恶意请求 | 脚本刷单、黄牛抢购 | 普通用户抢不到,体验差 |
整体架构设计 🏗️
秒杀系统的核心思想是:分层限流 + 异步削峰 + 最终一致性,把流量层层拦截,最终只有极少部分请求能到达数据库。
核心流程拆解(按时间线,告诉你每一步在做什么)
我用一个时序图,把一次正确的秒杀请求走通:
设计原则:
- 能在前端拦截的,绝不放到后端
- 能在缓存拦截的,绝不放到数据库
- 能异步处理的,绝不同步处理
高并发解决方案 🚄
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 脚本:
-- 原子扣减库存脚本
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”等必要信息,闪电般地扔进 RocketMQ 或 Kafka 里。服务端就直接结束了。
- 这一步,把瞬时的百万级并发,变成了后端消费服务可以慢慢处理的流式消息,削峰填谷。
为什么 Lua 脚本能保证原子性?
- Redis 是单线程执行的
- 整个 Lua 脚本会作为一个整体执行
- 执行过程中不会被其他命令打断
最终一致性保证 🤝
Redis 扣减成功后,通过 MQ 异步创建订单。如果订单创建失败,需要进行库存回补:
- 订单服务消费消息失败,发送死信消息
- 库存回补服务消费死信消息,将库存加回 Redis
- 定时任务扫描超时未支付订单,自动取消并回补库存
🛡️ 数据库——最后的兜底防线
面试官追问:那如果消息队列出错了,或者 Redis 有什么问题,数据库层面怎么兜底?
我: 问得好。MySQL 是最后一道闸门,我们必须做最坏的打算。
方案:乐观锁防超卖
消费服务慢慢从 MQ 拉消息,真正去创建订单、扣减数据库库存时,必须用乐观锁。
-- 不用版本号,直接用库存数当条件,更简洁
UPDATE seckill_product
SET stock = stock - 1
WHERE id = #{productId}
AND stock >= 1; -- 核心兜底条件- 执行完后,看返回值。如果这个 SQL 影响行数为 0,说明库存其实已经没了(可能 Redis 那边因为主从延迟或异常,多扣了一点点)。
- 此时,进行补偿操作:把 Redis 里多扣的库存加回去,然后标记该订单失败或回滚。整个链路最终一致。
🔥 极限挑战:热点数据问题怎么解?
如果是不限量的茅台,谁也救不了,只能用更大的集群硬扛。但我们可以:
- 业务隔离:把秒杀系统单独部署,不和日常业务抢资源。
- JVM 本地缓存:如果某个商品真的是绝对热点,可以把它的部分库存前置到服务本地的内存里,用
AtomicInteger自旋扣减,再定时异步同步给 Redis。这样连 Redis 的网络开销都省了。💨
其他关键问题处理 🛠️
- 防黄牛:一人一单限制、实名认证、收货地址校验
- 数据一致性:最终一致性模型,定时对账
- 容灾备份:Redis 主从 + 哨兵,数据库主从复制
- 监控告警:实时监控 QPS、库存、订单量,异常告警
总结 📝
同学,我们回顾下这场秒杀设计的整个逻辑闭环,一张图记牢:
| 层次 | 核心组件/策略 | 解决的核心问题 |
|---|---|---|
| 👆 前端/网关 | CDN、验证码、动态URL、网关令牌桶 | 挡住垃圾流量,削减尖峰 |
| ⚡ 核心业务 | Redis + Lua 原子扣减 | 彻底杜绝超卖 |
| 🔄 异步处理 | 消息队列 RocketMQ/Kafka | 削峰填谷,将并发变流式 |
| 🗄️ 最终兜底 | 数据库乐观锁(库存 ≥ 1) | 防止极端情况下数据不一致 |
| 📈 可观测性 | 监控、告警、限流动态调整 | 随时知道系统还能撑多久 |
高并发秒杀系统的设计精髓就是:"挡"、"削"、"异" 三个字
- 挡:在前端、网关、缓存层挡住 99.9% 的流量
- 削:用消息队列削平流量峰值
- 异:将非核心流程异步化
超卖问题的最优解是Redis+Lua 原子脚本,它在保证原子性的同时,提供了极高的性能,完全能扛住百万级 QPS 的秒杀场景。
核心代码
面试官您好,接下来我补充生产级核心代码实现和高频技术难点解决方案,这些都是大厂面试中最看重的落地细节。
核心代码实现(带技术亮点)
1. Redis 原子性库存预扣(Lua 脚本)💡
技术亮点:用 Lua 脚本将 "库存判断 + 扣减 + 用户限购" 打包成原子操作,彻底解决 Redis 并发下的竞态问题,比多次 Redis 调用性能高 3 倍以上。
/**
* 原子性预扣减库存(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 看门狗自动续期,支持可重入,是生产环境唯一推荐的分布式锁实现。
/**
* 防止同一用户重复下单
*/
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 消息重复消费导致的重复下单问题,同时配合事务保证消息处理的原子性。
/**
* 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 行锁防止超卖,同时用乐观锁版本号控制并发更新,在保证数据一致性的前提下最大化性能。
-- 最终库存扣减(行锁实现,绝对不会超卖)
UPDATE seckill_goods
SET stock_count = stock_count - 1,
version = version + 1
WHERE goods_id = #{goodsId}
AND stock_count > 0
AND version = #{version};5. 接口层的防刷与排队反馈(限流 + 秒杀结果异步通知)
@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. 消费者手动提交 offset 3. 订单 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 倍 |
🔁 难点解决亮点图解(以“超卖”为例)
核心思路:
- Redis 做快速准入(高效,允许少量超卖“假象”)
- DB 做最终兜底(利用行锁/乐观锁严格不准超卖)
- 失败补偿:DB 发现库存真没了,Redis 加的库存要吐回去。
这样一套组合拳,既保证了高并发下的性能,又用最终一致性保证不超卖,大厂里就是这么玩的 😎。
面试加分项总结 ✨
- 不要只说 "用 Redis 扣库存",一定要提到Lua 脚本原子性,这是区分初级和中级工程师的关键
- 不要只说 "用分布式锁",一定要提到Redisson 看门狗机制,说明你了解分布式锁的坑
- 不要只说 "用消息队列削峰",一定要提到幂等性和消息可靠性,说明你考虑了生产环境的异常情况
- 不要只说 "不超卖",一定要提到Redis+DB 双层防护,以及库存回补机制,说明你考虑了全流程的一致性
