每日Java面试场景题知识点之-分布式秒杀系统的设计
一、秒杀系统核心挑战
秒杀系统是电商、票务等高频业务场景中的典型难题,其核心挑战集中在以下三个维度:
- 瞬时高并发:秒杀活动开启瞬间,流量可达平时的数十倍甚至数百倍,QPS峰值可能突破百万级别,对系统承载能力提出极高要求。
- 库存超卖风险:在高并发写场景下,多个请求同时读取库存并扣减,极易出现库存被超卖的问题,直接导致资损。
- 系统雪崩效应:若缓存穿透、数据库连接池耗尽或下游服务不可用,会引发级联故障,最终导致整个系统不可用。
理解这三个核心挑战,是设计秒杀系统的出发点。
二、整体架构设计
一个成熟的分布式秒杀系统通常采用分层架构,从入口到数据层逐层削峰:
第一层:CDN + 静态资源分离
将秒杀活动页面、商品图片、CSS/JS等静态资源全部推送到CDN节点,使得用户访问时无需回源到应用服务器。活动页面的动态数据(如倒计时、库存状态)通过AJAX异步加载,实现动静分离。这样可以过滤掉90%以上的读请求。
第二层:网关层限流
在API网关(如Spring Cloud Gateway、Nginx)层面进行请求限流,采用令牌桶或漏桶算法控制进入后端的请求速率。同时可以加入IP黑白名单、防刷校验(如验证码、滑块验证),进一步过滤恶意流量。
第三层:应用服务层
秒杀服务独立部署,与常规业务服务物理隔离,避免秒杀流量拖垮核心业务。应用层负责核心的秒杀逻辑编排,包括活动校验、资格预判、订单创建等。
第四层:数据层
数据层以Redis为核心,承担库存存储与扣减操作;MySQL仅负责最终订单数据的持久化;消息队列作为应用层与数据层之间的缓冲,实现异步削峰。
三、缓存预热与库存初始化
秒杀活动开始前,必须完成缓存预热,将热点数据提前加载到Redis中:
1. 库存预热
将秒杀商品的库存数量写入Redis,使用Hash结构存储:
java
// 库存预热
public void preheatStock(Long seckillId, Integer stock) {
String key = "seckill:stock:" + seckillId;
redisTemplate.opsForValue().set(key, stock);
// 同时初始化已售数量
redisTemplate.opsForValue().set("seckill:sold:" + seckillId, 0);
}
2. 本地缓存+Redis二级缓存
对于秒杀活动的元数据(活动规则、商品信息),可以使用Caffeine构建本地缓存,减少Redis访问次数。本地缓存设置短过期时间(如5秒),Redis设置较长过期时间,形成二级缓存体系。
3. 缓存防穿透
对于不存在的秒杀商品ID,在Redis中存储空值(设置短过期时间),防止恶意请求穿透缓存直接打到数据库。
四、Redis原子性库存扣减
库存扣减是秒杀系统的核心环节,必须保证原子性,防止超卖。常见方案如下:
方案一:Redis Lua脚本(推荐)
利用Redis单线程执行Lua脚本的特性,保证库存读取和扣减的原子性:
java
// Lua脚本:原子性扣减库存
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1 -- 活动不存在
end
stock = tonumber(stock)
if stock <= 0 then
return 0 -- 库存不足
end
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功
在Java中调用该脚本:
java
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String STOCK_LUA =
"local stock = redis.call('GET', KEYS[1]) " +
"if not stock then return -1 end " +
"stock = tonumber(stock) " +
"if stock <= 0 then return 0 end " +
"redis.call('DECR', KEYS[1]) " +
"return 1";
public boolean deductStock(Long seckillId) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(STOCK_LUA, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList("seckill:stock:" + seckillId));
return result != null && result == 1;
}
方案二:Redis DECR命令
如果业务允许少量超卖(如前端多展示一些库存),可以直接使用DECR命令,该命令本身是原子的。但需注意DECR到负数的问题,需结合业务逻辑判断。
方案三:分布式锁(不推荐)
使用Redisson分布式锁来保护库存扣减虽然可以保证正确性,但性能极差,在高并发场景下会成为严重瓶颈,不适用于秒杀场景。
五、消息队列异步削峰
秒杀的核心思路是"快进慢出"------前端快速接收请求,后端慢慢处理订单。消息队列是这一思路的关键实现:
1. 削峰流程
- 用户发起秒杀请求
- 应用层完成资格校验后,将订单消息发送到MQ(如RocketMQ、Kafka)
- 立即向用户返回"排队中"的状态
- 消费者从MQ中拉取消息,异步创建订单并扣减数据库库存
- 用户通过轮询或WebSocket获取最终结果
java
// 生产端:发送秒杀订单消息
public SeckillResult submitSeckill(SeckillRequest request) {
// 1. 校验活动是否在进行中
// 2. 校验用户是否已有资格(防止重复购买)
// 3. Redis原子扣减库存
boolean success = deductStock(request.getSeckillId());
if (!success) {
return SeckillResult.fail("手慢了,商品已售罄");
}
// 4. 发送消息到MQ
SeckillOrderMessage msg = new SeckillOrderMessage(
request.getUserId(), request.getSeckillId(), request.getGoodsId());
rocketMQTemplate.convertAndSend("seckill-order-topic", msg);
// 5. 返回排队状态
return SeckillResult.processing("正在排队中,请稍候");
}
2. 消费端幂等处理
消息可能被重复投递,消费端必须保证幂等性:
java
@RocketMQMessageListener(topic = "seckill-order-topic", consumerGroup = "seckill-group")
public class SeckillOrderConsumer implements RocketMQListener<SeckillOrderMessage> {
@Override
public void onMessage(SeckillOrderMessage msg) {
// 幂等校验:根据userId+seckillId判断是否已处理
String orderKey = msg.getUserId() + ":" + msg.getSeckillId();
Boolean isFirst = redisTemplate.opsForValue()
.setIfAbsent("seckill:order:" + orderKey, "1", 10, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(isFirst)) {
return; // 已处理,直接返回
}
// 创建订单并持久化到数据库
createOrder(msg);
}
}
六、限流与降级策略
限流降级是保护系统不被流量击垮的最后一道防线:
1. 多级限流体系
- 前端限流:按钮置灰、点击冷却(如5秒内只能点击一次),最粗粒度地过滤无效请求
- 网关限流:基于Sentinel或Nginx的令牌桶算法,按接口维度设置QPS阈值
- 应用限流:在服务内部使用Guava RateLimiter或Sentinel进行细粒度限流
- 数据层限流:控制数据库连接池大小、Redis连接数,防止资源耗尽
2. 降级策略
- 读降级:当缓存不可用时,返回默认数据或历史缓存,而非穿透到数据库
- 写降级:当数据库写入压力过大时,先写入Redis,异步同步到数据库
- 功能降级:关闭非核心功能(如推荐、评论),释放资源给核心秒杀链路
- 熔断:当下游服务错误率超过阈值时,快速失败而非等待超时
3. Sentinel限流示例
java
@SentinelResource(value = "seckillSubmit",
blockHandler = "seckillBlockHandler",
fallback = "seckillFallback")
public SeckillResult submitSeckill(SeckillRequest request) {
// 核心秒杀逻辑
}
// 限流处理
public SeckillResult seckillBlockHandler(SeckillRequest request, BlockException ex) {
return SeckillResult.fail("系统繁忙,请稍后重试");
}
// 降级处理
public SeckillResult seckillFallback(SeckillRequest request, Throwable ex) {
log.error("秒杀异常", ex);
return SeckillResult.fail("系统异常,请稍后重试");
}
七、防刷与安全机制
秒杀场景是黑产攻击的重灾区,必须建立完善的安全防线:
1. 验证码机制
在秒杀请求前增加验证码环节,既能防刷又能错峰(将瞬时流量分散到几秒内):
java
public SeckillResult submitSeckill(SeckillRequest request) {
// 1. 校验验证码
String captchaKey = "captcha:" + request.getUserId();
String cachedCaptcha = redisTemplate.opsForValue().get(captchaKey);
if (!request.getCaptcha().equals(cachedCaptcha)) {
return SeckillResult.fail("验证码错误");
}
// 2. 后续秒杀逻辑...
}
2. 接口隐藏
秒杀接口URL动态化,防止用户提前获取接口地址并编写脚本直接调用。每次请求前先获取动态URL:
java
// 获取动态秒杀地址
@GetMapping("/seckill/path")
public String getSeckillPath(Long userId, Long seckillId) {
// 校验用户资格后,生成动态路径
String path = MD5Util.md5(userId + ":" + seckillId + ":" + System.currentTimeMillis());
// 存入Redis,设置短过期时间
redisTemplate.opsForValue().set("seckill:path:" + userId, path, 30, TimeUnit.SECONDS);
return path;
}
// 使用动态地址发起秒杀
@PostMapping("/seckill/{path}/submit")
public SeckillResult submit(@PathVariable String path, SeckillRequest request) {
String cachedPath = redisTemplate.opsForValue().get("seckill:path:" + request.getUserId());
if (!path.equals(cachedPath)) {
return SeckillResult.fail("非法请求");
}
// 继续秒杀逻辑...
}
3. 用户维度防重复
通过Redis Set记录已参与秒杀的用户,防止同一用户重复下单:
java
// 在扣减库存前,先判断用户是否已参与
Boolean isMember = redisTemplate.opsForSet()
.isMember("seckill:users:" + seckillId, userId);
if (Boolean.TRUE.equals(isMember)) {
return SeckillResult.fail("您已参与过本次秒杀");
}
redisTemplate.opsForSet().add("seckill:users:" + seckillId, userId);
八、数据一致性保障
分布式系统中,Redis库存与MySQL数据的一致性是难点:
1. 最终一致性方案
- Redis作为库存的"真实源",所有扣减操作先在Redis完成
- 通过MQ异步消息将扣减结果同步到MySQL
- 若MySQL扣减失败,通过回补机制恢复Redis库存
2. 对账与补偿
- 定时任务定期对账:比较Redis库存 + MySQL已售数量 = 总库存
- 发现不一致时,以MySQL数据为准进行修正
- 关键操作记录操作日志,便于问题追溯和数据恢复
3. 分布式事务处理
秒杀场景通常不需要强一致性事务(如Seata的AT模式),因为:
- 强一致性事务会严重影响吞吐量
- 秒杀场景允许短暂的不一致,最终一致即可
- 通过消息队列+幂等消费+补偿机制,可以保证最终一致性
九、性能优化要点总结
- 能缓存就缓存:秒杀页面静态化、商品信息缓存、库存Redis化,最大限度减少数据库访问
- 能异步就异步:订单创建、库存同步到数据库均通过MQ异步完成,快速释放应用线程
- 能前置就前置:将校验逻辑尽量前置到网关层,减少无效请求对后端的压力
- 能独立就独立:秒杀服务独立部署、独立数据库、独立缓存,避免影响核心业务
- 容量评估先行:上线前进行全链路压测,确保每个环节的容量配比合理
十、面试高频问题
Q1:如何防止库存超卖?
核心是保证库存扣减的原子性。推荐使用Redis Lua脚本,将读取库存和扣减库存合并为一个原子操作。同时配合分布式锁之外的方案,因为分布式锁在高并发下性能极差。
Q2:秒杀系统如何做到高可用?
多级限流 + 多级缓存 + 异步削峰 + 服务隔离 + 熔断降级。确保即使Redis不可用,也有本地缓存兜底;即使MQ不可用,也有同步降级方案;即使数据库扛不住,也能通过限流保护不被击垮。
Q3:如何处理秒杀流量尖峰?
三层削峰:CDN静态化过滤读流量 → 网关限流过滤无效写流量 → MQ异步将写请求排队处理。加上验证码错峰,将瞬间流量分散到数秒内。
感谢读者观看