如何设计一个秒杀系统?

2025年 阅读约 15 分钟 面试指南 · 系统设计

深入解析秒杀系统设计:前端限流+CDN静态化、Nginx网关层限流、Redis预减库存+Lua脚本、消息队列异步下单削峰、数据库最终扣减,附面试模拟问答。

一句话总结

秒杀系统核心挑战:高并发(瞬时流量洪峰)、超卖(库存扣减一致性)、恶意请求。分层限流:前端(按钮置灰+验证码)→ 网关(令牌桶限流)→ 应用层(Redis预减库存+Lua原子操作)→ 消息队列(异步下单削峰)→ 数据库(最终扣减)。核心口诀:前端防重、网关限流、Redis减库存、MQ削峰、DB最终一致

初级理解

秒杀系统架构分层

# 秒杀请求链路 # 用户 → CDN(静态页面)→ Nginx(限流)→ 秒杀服务 # → Redis(预减库存)→ MQ(异步下单)→ DB(扣减库存) # 各层职责 # 1. 前端:按钮防重、验证码、静态化 # 2. CDN:缓存静态页面,减少源站压力 # 3. Nginx:令牌桶限流,拒绝多余请求 # 4. Redis:预减库存(Lua脚本原子操作) # 5. MQ:异步下单,削峰填谷 # 6. DB:最终库存扣减,保证一致性

超卖问题

# ❌ 错误做法(有并发问题) # 1. 查库存: SELECT stock FROM product WHERE id=1 # stock=1 # 2. 判断: if stock > 0 # 3. 扣减: UPDATE product SET stock=stock-1 WHERE id=1 # 问题:两个线程同时查到 stock=1,都执行扣减 → 超卖! # ✅ 正确做法1:数据库行锁 UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0; # 利用 InnoDB 行锁保证原子性 # ✅ 正确做法2:Redis Lua 脚本 local stock = redis.call('get', KEYS[1]) if tonumber(stock) > 0 then redis.call('decr', KEYS[1]) return 1 # 秒杀成功 end return 0 # 库存不足
一句话总结:超卖用 Redis Lua 原子操作或 DB 行锁(WHERE stock > 0)。

中级深入

Redis 预减库存 + Lua 脚本

# Lua 脚本(原子操作) local product_id = KEYS[1] local user_id = ARGV[1] local stock_key = "seckill:stock:" .. product_id local bought_key = "seckill:bought:" .. product_id # 1. 检查是否已购买(防重复) if redis.call('sismember', bought_key, user_id) == 1 then return -1 # 已购买 end # 2. 检查库存 local stock = redis.call('get', stock_key) if not stock or tonumber(stock) <= 0 then return 0 # 库存不足 end # 3. 扣减库存 + 记录用户 redis.call('decr', stock_key) redis.call('sadd', bought_key, user_id) return 1 # 秒杀成功

消息队列异步下单

# 秒杀成功 → 发送MQ消息 → 异步创建订单 # 为什么用MQ? # 1. 削峰:DB 处理能力有限,MQ 缓冲请求 # 2. 解耦:秒杀服务和订单服务解耦 # 3. 异步:用户快速拿到结果,后台慢慢处理 # 流程 # 1. Redis 扣减成功 → 发送 MQ 消息 # 2. 返回用户"排队中" # 3. 订单服务消费 MQ → 创建订单 # 4. 用户轮询或 WebSocket 通知结果
中级要点:Redis Lua 原子减库存+防重复;MQ 异步下单削峰解耦。

高级拓展

限流策略

# 1. 前端限流 # - 按钮置灰(点击后禁用 5 秒) # - 验证码(滑块/图形验证码) # - 答题(秒杀前答题,过滤脚本) # 2. Nginx 限流 # 令牌桶算法 limit_req_zone $binary_remote_addr zone=seckill:10m rate=10r/s; location /seckill { limit_req zone=seckill burst=20 nodelay; } # 3. 应用层限流 # Sentinel/Guava RateLimiter RateLimiter limiter = RateLimiter.create(1000); # 1000 QPS if (!limiter.tryAcquire()) { return "系统繁忙,请稍后再试"; }

数据一致性保证

# 最终一致性方案 # 1. Redis 预减库存(快速返回) # 2. MQ 异步下单 # 3. DB 扣减库存(WHERE stock > 0) # 4. 定时对账:Redis 库存 + DB 已售 = 总库存 # 异常处理 # - MQ 消费失败 → 重试 + 死信队列 # - DB 扣减失败 → 回滚 Redis 库存 # - 订单超时未支付 → 定时取消 + 回补库存

实战场景

场景:秒杀接口核心代码

@RestController public class SeckillController { @Autowired private RedisTemplate redis; @Autowired private RocketMQTemplate mq; @PostMapping("/seckill") public Result seckill(Long productId, Long userId) { # 1. Redis Lua 脚本预减库存 String lua = "local stock = redis.call('get', KEYS[1])\n" + "if stock and tonumber(stock) > 0 then\n" + " redis.call('decr', KEYS[1])\n" + " return 1\n" + "end\n" + "return 0"; Long result = redis.execute( new DefaultRedisScript<>(lua, Long.class), List.of("seckill:stock:" + productId)); if (result == 0) return Result.fail("已售罄"); # 2. 发送 MQ 异步下单 mq.send("seckill-order", new SeckillMsg(productId, userId)); # 3. 返回排队中 return Result.ok("排队中,请稍后查看结果"); } }

面试模拟

面试官:秒杀系统怎么防止超卖?

你:三层防护:1)Redis Lua 脚本原子预减库存(单线程,天然原子);2)DB 用行锁 UPDATE ... WHERE stock > 0;3)用户维度防重(Redis Set 记录已购买用户)。Redis 预减库存是核心,99% 的请求在 Redis 层就返回了。

面试官:秒杀系统怎么应对高并发?

你:分层限流:前端按钮防重+验证码 → CDN 静态化 → Nginx 令牌桶限流 → Redis 预减库存(扛住大部分流量)→ MQ 异步下单削峰 → DB 最终扣减。核心思路是尽量把请求挡在上游,减少到达 DB 的请求量。