每日Java面试场景题知识点之-分布式秒杀系统的设计

每日Java面试场景题知识点之-分布式秒杀系统的设计

一、秒杀系统核心挑战

秒杀系统是电商、票务等高频业务场景中的典型难题,其核心挑战集中在以下三个维度:

  1. 瞬时高并发:秒杀活动开启瞬间,流量可达平时的数十倍甚至数百倍,QPS峰值可能突破百万级别,对系统承载能力提出极高要求。
  2. 库存超卖风险:在高并发写场景下,多个请求同时读取库存并扣减,极易出现库存被超卖的问题,直接导致资损。
  3. 系统雪崩效应:若缓存穿透、数据库连接池耗尽或下游服务不可用,会引发级联故障,最终导致整个系统不可用。

理解这三个核心挑战,是设计秒杀系统的出发点。


二、整体架构设计

一个成熟的分布式秒杀系统通常采用分层架构,从入口到数据层逐层削峰:

第一层: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模式),因为:

  • 强一致性事务会严重影响吞吐量
  • 秒杀场景允许短暂的不一致,最终一致即可
  • 通过消息队列+幂等消费+补偿机制,可以保证最终一致性

九、性能优化要点总结

  1. 能缓存就缓存:秒杀页面静态化、商品信息缓存、库存Redis化,最大限度减少数据库访问
  2. 能异步就异步:订单创建、库存同步到数据库均通过MQ异步完成,快速释放应用线程
  3. 能前置就前置:将校验逻辑尽量前置到网关层,减少无效请求对后端的压力
  4. 能独立就独立:秒杀服务独立部署、独立数据库、独立缓存,避免影响核心业务
  5. 容量评估先行:上线前进行全链路压测,确保每个环节的容量配比合理

十、面试高频问题

Q1:如何防止库存超卖?

核心是保证库存扣减的原子性。推荐使用Redis Lua脚本,将读取库存和扣减库存合并为一个原子操作。同时配合分布式锁之外的方案,因为分布式锁在高并发下性能极差。

Q2:秒杀系统如何做到高可用?

多级限流 + 多级缓存 + 异步削峰 + 服务隔离 + 熔断降级。确保即使Redis不可用,也有本地缓存兜底;即使MQ不可用,也有同步降级方案;即使数据库扛不住,也能通过限流保护不被击垮。

Q3:如何处理秒杀流量尖峰?

三层削峰:CDN静态化过滤读流量 → 网关限流过滤无效写流量 → MQ异步将写请求排队处理。加上验证码错峰,将瞬间流量分散到数秒内。


感谢读者观看

相关推荐
Python+9913 小时前
C++ 注解(注释)完整讲解
java·开发语言·c++
梵得儿SHI13 小时前
SpringCloud 进阶拓展:性能优化指南(缓存三大问题 + 分库分表入门)
spring cloud·缓存·微服务·性能优化·高并发·分库分表·数据库优化
Reisentyan13 小时前
[Review]GoLang Learn Data Day 3
java·开发语言·golang
H_老邪13 小时前
Java基础-Java 核心语法与面向对象(底层原理级)篇
java·开发语言
暗冰ཏོ13 小时前
Java 后端开发完整学习指南:从基础语法到 Spring Boot 项目实战
java·spring boot·后端·spring·java-ee
牵着毛驴唱着歌14 小时前
JaVers 版本历史功能完整实现指南
java·javers·变更记录
better_liang14 小时前
每日Java面试场景题知识点之-JUC并发编程核心原理与实战
java·线程池·并发编程·juc·aqs·reentrantlock·concurrenthashmap
小张小张爱学习14 小时前
Java-io流
java·开发语言
cfm_291414 小时前
了解Redis
数据库·redis·缓存