秒杀系统面试精讲:限流→缓存→队列→数据库的四层防御答题框架
秒杀系统面试精讲:限流→缓存→队列→数据库的四层防御答题框架
适读人群:Java后端开发 | 难度:★★★★★ | 出现频率:极高
开篇故事
秒杀系统是大厂面试最爱问的系统设计题,没有之一。
我以面试官的身份问过这道题至少100次。候选人的回答大致分三类:
第一类:只说"用Redis缓存商品库存,扣减库存用原子操作"。简单,但只答到了一个点,缺乏全局视野。
第二类:能说出限流、缓存、消息队列、数据库几个层次,但说不清楚每层的具体技术选型和为什么这样设计。
第三类:能按层次有条理地讲完整个架构,每层都有具体的技术方案,能说出关键的设计决策和权衡,还能聊到压测、监控、降级。
第三类人,我会直接给优秀,加分进下一轮。
今天我把这道题的完整答题框架送给你。
一、高频考点拆解
秒杀系统这道题,考察三个维度:
技术广度:知道限流、缓存、队列、数据库各自的作用和技术选型 技术深度:能说清楚Redis扣减库存的原子性、消息队列的可靠性、数据库的幂等性 工程思维:能想到超卖、重复购买、恶意刷单等边界情况的处理
二、深度原理分析:四层防御架构
第一层:流量入口防御
目标:拦截90%以上的无效请求,不让它们到达后端服务。
静态资源CDN化:商品图片、详情页HTML、CSS、JS全部放CDN,不走后端服务器。秒杀前的商品浏览页面,可能有几十万人同时访问,这些请求绝大多数是读操作,CDN可以完全承担。
Nginx层限流:在Nginx配置单IP每秒请求数上限(如10次/秒),超过直接返回503。阻挡简单的爬虫和压测脚本。
接口时间控制:秒杀按钮在开始时间之前不可点,服务端也对秒杀时间做校验,提前或过期的请求直接拒绝。
第二层:应用层限流
目标:对进入应用的请求做精细化控制。
令牌桶限流:全局限流,比如每秒只放行1000个请求到核心逻辑。Guava的RateLimiter,或者Redis实现的分布式令牌桶。
用户黑名单:将已知的恶意用户ID、IP放入布隆过滤器,快速拦截(有少量误判但可接受)。
防重复购买:SET user:{userId}:seckill:{itemId} 1 NX EX 300,Redis中记录用户是否已参与,NX保证原子性。
第三层:缓存层(核心!)
目标:在内存中完成库存扣减判断,不让超卖的请求到达数据库。
Redis预加载库存:活动开始前,把商品库存数量写入Redis:SET seckill:stock:{itemId} 100。
原子扣减库存:使用Lua脚本保证"检查+扣减"的原子性。
-- Lua脚本:检查并扣减库存(原子操作)
local stock = redis.call('get', KEYS[1])
if stock == false then
return -1 -- 商品不存在
end
if tonumber(stock) <= 0 then
return 0 -- 库存不足
end
redis.call('decr', KEYS[1])
return 1 -- 扣减成功为什么要用Lua脚本?因为DECR虽然是原子操作,但GET → 判断 → DECR三步不是原子的,两个并发请求可能都通过了判断,都执行了DECR,导致库存变成负数(超卖)。
用户限购Redis计数:INCR user:{userId}:buy_count:{itemId},超过限购数量直接拒绝。
第四层:队列+数据库
目标:把通过前三层的请求异步写入数据库,平滑峰值压力。
消息队列削峰:通过第三层判断的请求,发送"秒杀请求消息"到MQ(RocketMQ/Kafka),立即返回"秒杀成功,处理中"给用户。
消费者写数据库:消费者按照可控的速率(如每秒100个)从MQ消费,扣减实际库存,创建订单,通知用户支付。
兜底对账:定时任务对比Redis库存和数据库库存,修复不一致。
三、标准答案 + 代码验证
3.1 Redis原子扣减库存
@Service
public class SeckillStockService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String STOCK_KEY_PREFIX = "seckill:stock:";
private static final String USER_BUY_PREFIX = "seckill:user_buy:";
// Lua脚本:检查用户限购 + 扣减库存(两步合并为一个原子操作)
private static final String SECKILL_SCRIPT =
"local userKey = KEYS[1] " +
"local stockKey = KEYS[2] " +
"local maxBuyCount = tonumber(ARGV[1]) " +
// 检查用户购买次数
"local buyCount = tonumber(redis.call('get', userKey) or '0') " +
"if buyCount >= maxBuyCount then " +
" return -2 " + // 超出限购
"end " +
// 检查库存
"local stock = tonumber(redis.call('get', stockKey) or '0') " +
"if stock <= 0 then " +
" return 0 " + // 库存不足
"end " +
// 扣减库存,记录用户购买
"redis.call('decr', stockKey) " +
"redis.call('incr', userKey) " +
"redis.call('expire', userKey, 86400) " + // 24小时后过期
"return 1 "; // 成功
public int seckill(long itemId, long userId, int maxBuyCount) {
String userKey = USER_BUY_PREFIX + userId + ":" + itemId;
String stockKey = STOCK_KEY_PREFIX + itemId;
Long result = redisTemplate.execute(
new DefaultRedisScript<>(SECKILL_SCRIPT, Long.class),
Arrays.asList(userKey, stockKey),
String.valueOf(maxBuyCount)
);
return result == null ? -1 : result.intValue();
// 1: 成功, 0: 库存不足, -2: 超出限购, -1: 异常
}
}3.2 秒杀接口完整流程
@RestController
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private SeckillStockService stockService;
@Autowired
private RocketMQTemplate mqTemplate;
@Autowired
private UserService userService;
@PostMapping("/buy")
public Result buy(@RequestBody SeckillRequest request) {
long userId = SecurityContext.getCurrentUserId();
long itemId = request.getItemId();
// 第一层:验证秒杀时间
SeckillItem item = itemCache.get(itemId);
if (item == null) return Result.fail("商品不存在");
long now = System.currentTimeMillis();
if (now < item.getStartTime() || now > item.getEndTime()) {
return Result.fail("不在秒杀时间范围内");
}
// 第二层:用户黑名单(简化)
if (userService.isBlocked(userId)) {
return Result.fail("账号异常,无法参与");
}
// 第三层:Redis原子扣减
int result = stockService.seckill(itemId, userId, item.getLimitPerUser());
if (result == 0) return Result.fail("商品已售罄");
if (result == -2) return Result.fail("超出限购数量");
if (result != 1) return Result.fail("系统繁忙");
// 第四层:发送消息到队列,异步创建订单
SeckillMessage msg = new SeckillMessage(userId, itemId, request.getAddressId());
String orderId = generateOrderId(userId, itemId);
msg.setOrderId(orderId);
mqTemplate.asyncSend("seckill_topic", msg);
// 立即返回,让用户知道"秒杀成功,正在创建订单"
return Result.success(orderId, "秒杀成功,订单正在创建中,请稍候");
}
}3.3 消息消费者
@Component
@RocketMQMessageListener(topic = "seckill_topic", consumerGroup = "seckill_order_group")
public class SeckillOrderConsumer implements RocketMQListener<SeckillMessage> {
@Autowired
private OrderService orderService;
@Autowired
private StockService dbStockService;
@Override
public void onMessage(SeckillMessage message) {
try {
// 幂等检查(用orderId做唯一键)
if (orderService.exists(message.getOrderId())) {
log.info("订单{}已存在,幂等跳过", message.getOrderId());
return;
}
// 真正扣减数据库库存(乐观锁)
boolean stockDeducted = dbStockService.deductStock(message.getItemId(), 1);
if (!stockDeducted) {
// 数据库库存不足(理论上不应该发生,Redis库存已经保护了)
// 但需要做兜底处理:通知用户、回滚Redis库存
stockService.rollback(message.getItemId());
notificationService.notifyFail(message.getUserId(), "库存异常,秒杀失败");
return;
}
// 创建订单
orderService.createSeckillOrder(message);
// 通知用户
notificationService.notifySuccess(message.getUserId(), message.getOrderId());
} catch (Exception e) {
log.error("处理秒杀消息失败: {}", message, e);
throw e; // 重新抛出,触发MQ重试
}
}
}四、面试官追问
追问1:如果Redis宕机了,秒杀系统会怎样?怎么降级?
我的回答:Redis宕机是严重的故障,会影响所有依赖Redis的秒杀逻辑。降级方案有几种:第一,Redis主从+哨兵,自动故障转移,秒级恢复,通常够用。第二,在Redis故障时,切换到纯数据库模式,用数据库的SELECT...FOR UPDATE来保证原子性,但QPS会大幅下降,需要配合限流(只放1%-5%的流量)。第三,提前设置Redis哨兵的自动降级开关,流量探测到Redis延迟过高时自动熔断,保护后端。对于秒杀这种场景,可以接受"Redis故障时秒杀暂停,等Redis恢复后继续",比"强行用数据库撑"更安全。
追问2:如何处理"少买"问题?即Redis扣减了库存,但消息发送失败
我的回答:这是生产场景中必须考虑的数据一致性问题。解决方案是对账+补偿:定时任务(比如每分钟)查询Redis库存总数和数据库库存总数,如果Redis少于数据库(说明有Redis扣减成功但订单未创建的情况),则将差量重新放回Redis或发告警人工介入。更稳健的方案是:Redis扣减成功后,把扣减记录也写入数据库(一张中间表),消息发送失败时,有任务定期检查这张中间表,对没有对应订单的记录重新处理(重发消息)。
追问3:秒杀系统如何防止黄牛
我的回答:防黄牛是运营安全问题,技术手段有限但必不可少。技术层面:图形验证码(在秒杀开始前,手机验证或滑动验证,增加机器操作成本)、设备指纹(识别同一台设备的多账号行为)、行为分析(请求间隔固定、同IP段大量账号等异常行为)、账号风险分级(新注册账号、未实名账号限制参与)。业务层面:限购(每人每次活动限购1件)、实名认证(一个手机号只能绑定一个账号)、收货地址多元化检查(同一地址多账号购买)。完全防止黄牛不可能,但可以提高黄牛的成本,让大多数普通用户有公平参与的机会。
五、同类题目举一反三
如何设计一个高并发的抢优惠券系统?
优惠券系统和秒杀本质相同:有限资源(券的数量)被大量用户争抢。技术方案完全复用:Redis预加载库存、Lua原子扣减、MQ异步落库。区别在于:优惠券通常是"一人一张"而不是"限购N件",用户领取记录的存储方式不同(Set结构存已领取用户,SADD userId和SISMEMBER判断是否已领取)。
六、踩坑实录
坑一:Redis库存设为0,但数据库库存还是1
Redis扣减库存和数据库扣减库存是两个操作,存在短暂不一致。有次秒杀结束后的对账发现,Redis显示库存0,但数据库还有5件库存(消费者异常,部分消息没有成功消费)。用户看到"已售罄",但实际上还有库存,是一次"少卖"事故。教训:对账任务要及时运行,异常消息要有死信队列兜底,运维要有告警。
坑二:Nginx限流配置错误,把正常用户都拦截了
limit_req配置时,zone的burst参数设置过小,导致正常速度点击的用户被误限流。Nginx返回503,用户以为系统挂了,客诉大量涌来。教训:限流参数要充分测试,在压测环境模拟真实用户的点击行为再上线,不要直接在生产调参数。
坑三:消息队列消费者是单线程,处理速度太慢
消息队列里积压了几万条消息,但消费者只有一个线程,处理完要30分钟,用户等待太久。改为多消费者并行消费,同时做好幂等处理,积压问题解决。要根据MQ的TPS和消费者的处理速度来计算需要几个消费者并行,提前规划好消费能力。
七、总结
秒杀系统的答题框架:四层防御,层层收束。
每一层的核心目标:
- 流量入口层:CDN+Nginx,拦截90%静态请求和恶意请求
- 应用限流层:令牌桶+黑名单,精细化限流,防重复下单
- 缓存层(核心):Redis原子扣减,在内存中完成"有没有资格下单"的判断,不超卖
- 队列+数据库层:MQ削峰,消费者按可控速率写数据库,幂等处理
面试时按这个框架有条理地讲,每层都说出具体技术选型和关键设计点,这道题就能拿高分。
