设计秒杀系统
一句话速记
秒杀核心矛盾:瞬时超高并发(10 万+ QPS)vs 有限库存(100 件)。解法:流量漏斗(层层过滤,让尽量少的请求打到数据库)+ 库存预减(Redis 原子操作提前扣减,避免超卖)+ 异步下单(MQ 削峰,订单异步创建)+ 幂等(重复请求只处理一次)。
设计要求分析
功能需求:
- 商品详情展示(秒杀前)
- 秒杀按钮(倒计时)
- 秒杀请求(按钮点击后)
- 订单查询(秒杀结果)
非功能需求:
- 高并发:100 件商品,100 万用户抢,瞬时 10 万+ QPS
- 不超卖:库存 100,最多只能卖 100
- 防刷:单用户只能买 1 件
- 高可用:单个组件故障不能整体崩溃
- 低延迟:用户操作要有快速反馈
架构设计
1)流量漏斗(层层拦截)
用户(100万) → CDN(静态资源缓存)
→ Nginx(限流:单IP速率限制)
→ API Gateway(用户维度限流:每用户每秒1次)
→ 秒杀服务(令牌桶,防止穿透)
→ Redis 库存预减(原子,快速失败)
→ MQ(只有少量成功请求入队)
→ 订单服务(异步创建订单)
→ 数据库(少量 TPS)
目标:100万请求 → 数据库只处理 100 个
2)商品详情页(读多,CDN 缓存)
秒杀开始前:
商品信息(图片、描述、价格)→ CDN 缓存(全球边缘节点)
秒杀状态:NOT_STARTED / STARTED / SOLD_OUT → Redis 缓存(TTL=秒杀剩余时间)
秒杀倒计时:
前端 JS 计时(本地时间)
避免所有人同时在 00:00 刷新(服务端时间校准,允许 ±1s 误差)
秒杀按钮控制:
状态=NOT_STARTED → 按钮灰色不可点
状态=STARTED → 按钮可点
状态=SOLD_OUT → 按钮变灰"已售罄"
3)秒杀请求处理
用户身份验证 + 防刷:
// 1. 登录校验(JWT/Session)
// 2. 用户维度限流(Redis SETNX,1秒内只处理1次)
String userLimitKey = "seckill:user:" + userId + ":" + itemId;
if (!redis.setnx(userLimitKey, "1", 1, TimeUnit.SECONDS)) {
return Response.fail("操作太频繁,请稍后重试");
}
// 3. 用户是否已购买(已秒杀成功不允许重复)
if (redis.sismember("seckill:bought:" + itemId, userId)) {
return Response.fail("您已参与过本次秒杀");
}Redis 库存预减(原子操作,防超卖):
-- Lua 脚本(原子:查询+扣减 不可分割)
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) <= 0 then
return -1 -- 库存不足
end
-- 原子扣减
redis.call('decrby', KEYS[1], 1)
-- 记录已购用户(防重复购买)
redis.call('sadd', KEYS[2], ARGV[1]) -- KEYS[2]=bought:itemId, ARGV[1]=userId
return 1 -- 成功Long result = redis.eval(LUA_SCRIPT,
Arrays.asList("seckill:stock:" + itemId, "seckill:bought:" + itemId),
Arrays.asList(userId.toString()));
if (result == -1L) {
return Response.fail("库存不足,秒杀结束");
}
// 4. 放入 MQ 异步下单
String orderNo = generateOrderNo(); // 预生成订单号
mqProducer.send("seckill_order", new SeckillMessage(userId, itemId, orderNo));
return Response.success("秒杀成功,订单处理中", orderNo);异步下单(MQ 削峰):
// 消费者
@KafkaListener(topics = "seckill_order")
public void handleSeckillOrder(SeckillMessage msg) {
// 幂等检查(消息可能重复消费)
if (orderDao.existsByOrderNo(msg.getOrderNo())) return;
// 数据库扣库存(二次校验,防止 Redis 数据误差)
int affected = itemDao.decreaseStock(msg.getItemId(), 1);
if (affected == 0) {
// 数据库库存也不够(极少数情况)
// 发送失败通知给用户(MQ 补偿通知)
notifyUser(msg.getUserId(), "很遗憾,秒杀失败");
return;
}
// 创建订单
Order order = new Order(msg.getOrderNo(), msg.getUserId(), msg.getItemId());
orderDao.save(order);
// 通知用户(短信/App 推送)
notifyUser(msg.getUserId(), "恭喜秒杀成功!订单号:" + msg.getOrderNo());
}4)超卖防护(双重保险)
防护 1(Redis):Lua 原子扣减,Redis 库存 ≥ 1 才允许
防护 2(DB): UPDATE items SET stock=stock-1 WHERE id=? AND stock>0
affected_rows=0 → 超卖(极少发生,Redis 已拦截大部分)
防护 3(唯一索引):(user_id, item_id) 唯一索引 → 防止同一用户多次下单
5)系统初始化
秒杀开始前(提前 10 分钟):
1. Redis 预热:SET seckill:stock:{itemId} 100(库存写入 Redis)
2. 清空 bought 集合:DEL seckill:bought:{itemId}
3. 预生成秒杀 token 池(可选,进一步过滤流量)
4. CDN 预热:商品详情页图片和静态资源推到 CDN
6)订单支付超时处理
秒杀成功 → 创建订单 → 用户需在 15 分钟内支付
超时未支付 → 取消订单 → 释放库存
实现:
Redis Sorted Set(score=过期时间戳)
定时任务每秒扫描 score < now() 的订单 → 取消
或:延迟消息(RocketMQ 延迟级别,15分钟后触发取消)
取消时:
UPDATE orders SET status=CANCELLED WHERE order_no=?
Redis INCR seckill:stock:{itemId} // 归还库存
Redis SREM seckill:bought:{itemId} userId // 允许用户再次秒杀(可选)
关键数字
场景:100 件商品,100 万用户
目标:QPS 峰值 10 万
流量漏斗各层处理量:
CDN:10 万 QPS(静态资源直接返回)
API Gateway 限流:过滤 90%,剩 1 万 QPS
用户去重(Redis SETNX):过滤 99%,剩 100-1000 QPS
Redis 库存扣减:成功 100 次,其余快速返回"库存不足"
MQ:入队 100 条消息
DB:写入 100 条订单记录
延伸追问
- Q:Redis 扣完库存但 MQ 发送失败,怎么处理? → 本地事务消息:Redis 扣减成功 + 本地 DB 写 outbox 消息(同一 Redis 事务无法做到,但可以先扣 Redis,MQ 发失败则重试 3 次,3 次失败则归还库存)。更可靠:用 Redis INCR 归还,用幂等 ID 保证重试不重复扣减。
- Q:如果只有一台 Redis,Redis 挂了怎么办? → Redis 集群(主从+哨兵或 Cluster)保证高可用。极端情况 Redis 全挂:降级到数据库(SELECT FOR UPDATE 悲观锁),但 QPS 大幅下降(只能承受几百 TPS),同时触发限流保护 DB。
我的记法
- 核心:流量漏斗 + Redis 原子扣减 + MQ 异步下单
- 防超卖:Lua 原子操作(查+减不可分)+ DB where stock>0(兜底)
- 防刷:用户维度限流(SETNX 1s)+ bought 集合去重
- 初始化:秒杀前预热 Redis 库存
- 一句话:「CDN 挡静态,限流挡重复,Redis Lua 原子扣库存,MQ 异步创订单」
状态
- 已背速记
- 能画完整数据流
- 能写 Redis Lua 扣减脚本
- 能解释防超卖的双重保险