面试必杀技:如何把“秒杀系统”讲得明明白白?

在节假日的时候我们都喜欢去别的地方旅游,这个时候发现这想象一下12306放票瞬间,我们刚打开app准备抢票结果没了、或者双11的时候商品倒计时开抢还没点"抢"呢就按钮就灰了然后页面显示已经抢完。其实这背后都是"秒杀系统"在支撑。这个问题有时候面试的时候也会问到"秒杀"这个问题。

为什么会问到呢?因为这个问题它考察的东西还是挺多的,比如缓存,多线程,高并发等等。今儿个咱就分析分析这里面的道道儿。咱就以医院看病这种情况举例说明一下。

第一幕:挂号分诊(前端请求拦截)

病人一股脑儿挤进医院大门,肯定不行。秒杀开始前,系统就要开始"分诊":

  1. 按钮防暴击: 医院大门儿一开,病人全都往里面挤不停地挤,就相当于点击按钮一样狂点这样可不行,设置成5秒后才允许再次点击(防止用户狂点),挤一会儿也得歇一会儿吧!是不是有些类似。(不是很恰当,理解意思就行)
  2. 验证码拦截: 在提交订单前加入图形/滑块验证码,过滤掉无脑刷请求的脚本机器人,现在有的是让我们按顺序点击图片上出现的字儿,我感觉这个还是挺好玩儿,有时候看不准就点不对。
html 复制代码
<button id="seckillBtn" onclick="handleClick()">立即抢购</button>
<script>
  let canClick = true;
  function handleClick() {
    if (!canClick) return;
    canClick = false; // 立刻禁用按钮
    
    // 1. 模拟加入验证码校验 (这里简化)
    if (!validateCaptcha()) {
        canClick = true;
        alert('请完成验证码!');
        return;
    }
    
    // 2. 发起秒杀请求
    requestSeckill();
    
    // 3. 5秒后才允许再次点击 (实际场景根据后端响应动态开启更佳)
    setTimeout(() => { canClick = true; }, 5000);
  }
  function validateCaptcha() { return true; } // 模拟验证通过
  function requestSeckill() { /* 发送请求到后端 */ }
</script>

第二幕:诊室叫号(服务端核心逻辑)

挂号成功的人来到诊室门口,得有序叫号,不能一窝蜂冲进去把医生挤趴下。

  1. 请求排队: 用消息队列(如RabbitMQ, Kafka, RocketMQ)把瞬间的海量下单请求缓冲起来,后端服务根据自己的处理能力慢慢"叫号消费"。

  2. 库存关口: 这是秒杀的命门!绝对不能用数据库直接查减库存! 扛不住并发,还容易超卖(卖多了,朋友之前做电商发现有时候直接把库存干成负的了,哈哈)。必杀技:Redis 原子操作!

java 复制代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.Collections;

public class SeckillService {

    private final JedisPool jedisPool;
    private static final String STOCK_PREFIX = "seckill:stock:";
    private static final String LOCK_PREFIX = "seckill:lock:";
    private static final int LOCK_EXPIRE = 5; // 锁过期时间(秒)

    public SeckillService(JedisPool jedisPool) {
        this.jedisPool = jedisPool;
    }

    // 初始化库存(项目启动时执行)
    public void initStock(String goodsId, int stock) {
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.set(STOCK_PREFIX + goodsId, String.valueOf(stock));
        }
    }

    // 秒杀核心方法(原子操作版)
    public boolean trySeckill(String goodsId, String userId) {
        String stockKey = STOCK_PREFIX + goodsId;
        String lockKey = LOCK_PREFIX + goodsId;

        try (Jedis jedis = jedisPool.getResource()) {
            // 第一重校验:快速判断库存
            String remainStock = jedis.get(stockKey);
            if (remainStock == null || Integer.parseInt(remainStock) <= 0) {
                return false;
            }

            // 获取分布式锁(防止极端情况下的超卖)
            String lockToken = tryGetLock(jedis, lockKey);
            if (lockToken == null) return false; // 获取锁失败直接返回

            try {
                // 第二重校验:加锁后再次确认库存
                remainStock = jedis.get(stockKey);
                if (remainStock == null || Integer.parseInt(remainStock) <= 0) {
                    return false;
                }

                // 原子操作:减少库存并返回最新值
                Long newStock = jedis.decr(stockKey);
                
                if (newStock >= 0) {
                    // 扣减成功!生成订单记录(异步处理)
                    asyncCreateOrder(goodsId, userId);
                    return true;
                } else {
                    // 库存不足,回滚操作
                    jedis.incr(stockKey);
                    return false;
                }
            } finally {
                // 释放分布式锁
                releaseLock(jedis, lockKey, lockToken);
            }
        }
    }

    // 获取分布式锁(简化版)
    private String tryGetLock(Jedis jedis, String key) {
        String token = String.valueOf(System.currentTimeMillis());
        String result = jedis.set(key, token, "NX", "EX", LOCK_EXPIRE);
        return "OK".equals(result) ? token : null;
    }

    // 释放分布式锁
    private void releaseLock(Jedis jedis, String key, String token) {
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                           "return redis.call('del', KEYS[1]) " +
                           "else return 0 end";
        jedis.eval(luaScript, Collections.singletonList(key), 
                   Collections.singletonList(token));
    }

    // 异步创建订单(实际开发中放入消息队列)
    private void asyncCreateOrder(String goodsId, String userId) {
        // 这里模拟异步处理
        new Thread(() -> {
            System.out.println("生成订单:商品=" + goodsId + " 用户=" + userId);
            // 实际业务:订单入库、扣减数据库库存等
        }).start();
    }
}

为什么用Lua脚本? 因为Redis单线程执行Lua脚本是原子的!GETDECR操作之间不会被其他命令打断,彻底杜绝超卖。这是秒杀系统的黄金搭档。

第三幕:药房配药 ------ 订单异步落库

医生开了处方,病人拿着单子去药房排队取药。不能堵在诊室门口等。

  1. 快速响应: 用户只要知道"我抢没抢到"这个结果要快(秒级)。至于生成完整订单、扣积分、通知发货...这些都是"药房"后台慢慢处理的"体力活"。
  2. 异步下单: 把生成订单明细、扣减数据库库存(最终一致性)、记录日志等耗时操作,放到消息队列里,由专门的订单处理服务慢慢消费完成。用户秒级知道"抢购成功",后面可能等几分钟才在"我的订单"里看到。

总结:秒杀系统

  1. 削峰填谷: 前端限流(挂号分诊) + 消息队列(排队叫号),把瞬时巨峰流量变成后端能承受的平缓河流。
  2. 缓存为王: 核心库存判断必须用Redis(诊室叫号),利用其超高并发能力和原子操作(Lua脚本),死守库存防线。
  3. 异步落地: 抢购结果快速返回,订单生成等耗时操作异步处理(药房配药),保证核心流程速度。

面试点睛之笔: 当被问到"怎么防止超卖?"时,目光坚定,脱口而出:"用Redis的原子操作在缓存层扣减和检查库存,数据库层最终异步扣减保证最终一致性。" 这句话,价值千金!


相关推荐
lypzcgf12 分钟前
Coze源码分析-API授权-获取令牌列表-后端源码
数据库·人工智能·后端·系统架构·go·开源软件·安全架构
冷冷的菜哥20 分钟前
ASP.NET Core上传文件到minio
后端·asp.net·上传·asp.net core·minio
几颗流星30 分钟前
Spring Boot 项目中使用 Protobuf 序列化
spring boot·后端·性能优化
IT_陈寒2 小时前
7个Vue 3.4新特性实战心得:从Composition到性能优化全解析
前端·人工智能·后端
BillKu2 小时前
Spring Boot 后端接收多个文件的方法
spring boot·后端·python
hui函数2 小时前
订单后台管理系统-day07菜品模块
数据库·后端·python·flask
wr2 小时前
解决 NetMQ 创建Demo调试失败问题
后端
DashVector2 小时前
如何通过Java SDK获取Doc
大数据·后端·阿里巴巴
架构师沉默2 小时前
同事查日志太慢,我现场教他一套 grep 组合拳
java·后端·架构