在节假日的时候我们都喜欢去别的地方旅游,这个时候发现这想象一下12306放票瞬间,我们刚打开app准备抢票结果没了、或者双11的时候商品倒计时开抢还没点"抢"呢就按钮就灰了然后页面显示已经抢完。其实这背后都是"秒杀系统"在支撑。这个问题有时候面试的时候也会问到"秒杀"这个问题。
为什么会问到呢?因为这个问题它考察的东西还是挺多的,比如缓存,多线程,高并发等等。今儿个咱就分析分析这里面的道道儿。咱就以医院看病这种情况举例说明一下。
第一幕:挂号分诊(前端请求拦截)
病人一股脑儿挤进医院大门,肯定不行。秒杀开始前,系统就要开始"分诊":
- 按钮防暴击: 医院大门儿一开,病人全都往里面挤不停地挤,就相当于点击按钮一样狂点这样可不行,设置成5秒后才允许再次点击(防止用户狂点),挤一会儿也得歇一会儿吧!是不是有些类似。(不是很恰当,理解意思就行)
- 验证码拦截: 在提交订单前加入图形/滑块验证码,过滤掉无脑刷请求的脚本机器人,现在有的是让我们按顺序点击图片上出现的字儿,我感觉这个还是挺好玩儿,有时候看不准就点不对。
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>
第二幕:诊室叫号(服务端核心逻辑)
挂号成功的人来到诊室门口,得有序叫号,不能一窝蜂冲进去把医生挤趴下。
-
请求排队: 用消息队列(如RabbitMQ, Kafka, RocketMQ)把瞬间的海量下单请求缓冲起来,后端服务根据自己的处理能力慢慢"叫号消费"。
-
库存关口: 这是秒杀的命门!绝对不能用数据库直接查减库存! 扛不住并发,还容易超卖(卖多了,朋友之前做电商发现有时候直接把库存干成负的了,哈哈)。必杀技: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脚本是原子的!GET
和DECR
操作之间不会被其他命令打断,彻底杜绝超卖。这是秒杀系统的黄金搭档。
第三幕:药房配药 ------ 订单异步落库
医生开了处方,病人拿着单子去药房排队取药。不能堵在诊室门口等。
- 快速响应: 用户只要知道"我抢没抢到"这个结果要快(秒级)。至于生成完整订单、扣积分、通知发货...这些都是"药房"后台慢慢处理的"体力活"。
- 异步下单: 把生成订单明细、扣减数据库库存(最终一致性)、记录日志等耗时操作,放到消息队列里,由专门的订单处理服务慢慢消费完成。用户秒级知道"抢购成功",后面可能等几分钟才在"我的订单"里看到。
总结:秒杀系统
- 削峰填谷: 前端限流(挂号分诊) + 消息队列(排队叫号),把瞬时巨峰流量变成后端能承受的平缓河流。
- 缓存为王: 核心库存判断必须用Redis(诊室叫号),利用其超高并发能力和原子操作(Lua脚本),死守库存防线。
- 异步落地: 抢购结果快速返回,订单生成等耗时操作异步处理(药房配药),保证核心流程速度。
面试点睛之笔: 当被问到"怎么防止超卖?"时,目光坚定,脱口而出:"用Redis的原子操作在缓存层扣减和检查库存,数据库层最终异步扣减保证最终一致性。" 这句话,价值千金!