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

在节假日的时候我们都喜欢去别的地方旅游,这个时候发现这想象一下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的原子操作在缓存层扣减和检查库存,数据库层最终异步扣减保证最终一致性。" 这句话,价值千金!


相关推荐
国服第二切图仔7 小时前
Rust开发之使用panic!处理不可恢复错误
开发语言·后端·rust
جيون داد ناالام ميづ7 小时前
Spring Boot 核心原理(一):基础认知篇
java·spring boot·后端
南囝coding8 小时前
现代Unix命令行工具革命:30个必备替代品完整指南
前端·后端
WYiQIU8 小时前
高级Web前端开发工程师2025年面试题总结及参考答案【含刷题资源库】
前端·vue.js·面试·职场和发展·前端框架·reactjs·飞书
夏之小星星8 小时前
Springboot结合Vue实现分页功能
vue.js·spring boot·后端
唐僧洗头爱飘柔95278 小时前
【SpringCloud(8)】SpringCloud Stream消息驱动;Stream思想;生产者、消费者搭建
后端·spring·spring cloud·设计思想·stream消息驱动·重复消费问题
韩立学长8 小时前
【开题答辩实录分享】以《自动售货机刷脸支付系统的设计与实现》为例进行答辩实录分享
vue.js·spring boot·后端
GISer_Jing8 小时前
小米前端面试
前端·面试·职场和发展
cj6341181509 小时前
DBeaver连接本地MySQL、创建数据库表的基础操作
java·后端
小龙报9 小时前
《赋能AI解锁Coze智能体搭建核心技能(2)--- 智能体开发基础》
人工智能·程序人生·面试·职场和发展·创业创新·学习方法·业界资讯