黑马点评秒杀优化实战:Redis判断秒杀资格 + 阻塞队列异步下单
今天整理一下黑马点评项目里我觉得非常经典的一块内容:秒杀优化模块 。 这部分的核心思路非常值得学习,因为它不是单纯实现一个"下单功能",而是在高并发场景下,解决了下面几个典型问题:
- 库存超卖
- 一人多单
- 数据库被瞬时流量打垮
- 接口响应慢
而这套优化方案里,最关键的两部分就是:
- Redis + Lua 脚本完成秒杀资格判断
- BlockingQueue 阻塞队列实现异步下单
这篇文章我就重点讲这两个方面
一、为什么秒杀场景下不能直接操作数据库?
先看最朴素的下单流程:
- 查询优惠券信息
- 判断秒杀是否开始/结束
- 查询库存是否充足
- 查询用户是否已经下过单
- 扣减库存
- 创建订单
这个流程在并发不高的时候没问题,但一旦到了秒杀场景(高并发),请求量会瞬间暴涨,问题就出来了:
1. 容易超卖
多个线程同时查询库存,发现都有库存,于是都去扣减,最后可能把库存扣成负数。

2. 容易一人多单
多个请求几乎同时查询"该用户是否下过单",结果都发现没下过,然后都创建订单。

3. 数据库压力太大
即使最后没抢到,很多请求也已经打到数据库了,数据库会承受巨大的无效流量。
所以秒杀优化的核心目标其实很明确:
把高频判断前移到 Redis,把真正慢的数据库操作异步化。
二、整体优化思路
1. 请求线程做什么?
请求线程只做两件事:
- 用 Redis 判断用户是否具备秒杀资格
- 如果具备资格,就把订单任务投递到阻塞队列
2. 后台线程做什么?
后台线程专门做真正的下单动作:
- 从阻塞队列中取出订单任务
- 执行数据库扣库存
- 保存订单记录
也就是说:
前台负责"验资格",后台负责"真下单"。
这样一来,请求线程的执行时间会非常短,接口响应速度会明显提升。
整体流程如下图所示:

三、Redis 如何完成秒杀资格判断?
这一部分是整个秒杀优化最核心的地方。
1. Redis 中保存什么数据?
为了让 Redis 能快速完成判断,通常会提前保存两类数据:
库存
用一个 key 保存优惠券库存,例如:seckill:stock:{voucherId} value 就是库存数量。
已下单用户
用一个 Set 保存已经抢到券的用户 ID,例如:seckill:order:{voucherId}
Set 中存放的是所有已抢购成功的 userId。
2. 为什么要用 Lua 脚本?
判断秒杀资格需要做两步:
- 判断库存是否充足
- 判断用户是否已经下过单
这两步本质上都是 Redis 操作,但如果用 Java 分两次调用 Redis,就会存在原子性问题:两次操作之间可能被其他线程插入,导致并发漏洞。
Lua 脚本的核心价值:在 Redis 中原子性地执行多步操作。
Redis 执行 Lua 脚本是单线程的,整个脚本执行过程不会被打断,天然保证了原子性,完美解决并发问题。
3. Lua 脚本实现
lua
-- seckill.lua
-- 参数说明:
-- KEYS[1]: 库存 key,如 seckill:stock:1
-- KEYS[2]: 订单 key,如 seckill:order:1
-- ARGV[1]: 用户 ID
-- 1. 判断库存是否充足
local stock = tonumber(redis.call('get', KEYS[1]))
if stock <= 0 then
-- 库存不足,返回 1
return 1
end
-- 2. 判断用户是否已经购买过
local isMember = redis.call('sismember', KEYS[2], ARGV[1])
if isMember == 1 then
-- 用户已经购买过,返回 2
return 2
end
-- 3. 两个条件都满足,扣减库存,记录用户
redis.call('incrby', KEYS[1], -1)
redis.call('sadd', KEYS[2], ARGV[1])
-- 返回 0 表示有购买资格
return 0
返回值含义:
| 返回值 | 含义 |
|---|---|
0 |
有资格,可以下单 |
1 |
库存不足 |
2 |
该用户已经购买过 |
4. Java 中如何调用 Lua 脚本?
首先在项目中加载 Lua 脚本(建议放在 resources 目录下):
java
// 在 Service 类中,静态加载 Lua 脚本
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
// 脚本路径在 classpath 下
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
然后在秒杀方法中调用:
VoucherOrderServiceImpl
public Result seckillVoucher(Long voucherId) {
// 获取用户ID
Long userId = UserHolder.getUser().getId();
// 1.执行Lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString()
);
// 2.判断结果是否为0
int r = result.intValue();
if (r != 0) {
// 2.1.不为0,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 2.2.为0,代表有购买资格,把下单信息保存到阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.订单ID
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 2.4.用户ID
voucherOrder.setUserId(userId);
// 2.5.代金券ID
voucherOrder.setVoucherId(voucherId);
// 2.6.创建阻塞队列
orderTasks.add(voucherOrder);
// 获取代理对象(事务)
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 3.返回订单ID
return Result.ok(orderId);
}
小结: 到这一步,用户请求线程已经结束了,整个过程只有 Redis 操作,速度极快,数据库完全没有被碰到。
四、BlockingQueue 阻塞队列实现异步下单
1. 什么是阻塞队列?
BlockingQueue 是 Java 并发包中的一个接口,核心特点是:
- 入队(put/offer) :队列满了会阻塞,直到有空间
- 出队(take/poll) :队列空了会阻塞,直到有元素
在秒杀场景下,我们利用它的这个特性来做生产者-消费者模型:
- 生产者:请求线程把订单信息放入队列
- 消费者:后台线程不断从队列中取任务,执行数据库操作
2. 定义阻塞队列和后台线程
VoucherOrderServiceImpl
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder>
implements IVoucherOrderService {
// 阻塞队列,容量设置为 1024 * 1024
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
// 线程池,用于执行异步任务
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
// Spring 初始化完成后,启动后台线程
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 内部类:后台消费线程任务
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 从队列中取出订单信息(队列为空时会阻塞,不占用 CPU)
VoucherOrder voucherOrder = orderTasks.take();
// 执行真正的数据库下单逻辑
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
// 真正操作数据库的方法
private void handleVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
// 注意:此处仍然需要加分布式锁保证安全(兜底)
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("不允许重复下单!");
return;
}
try {
// 通过代理对象调用,保证事务生效
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
// 兜底校验:数据库层面再次确认是否已下过单(防止极端情况)
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
log.error("用户已经购买过一次!");
return;
}
// 扣减库存(使用乐观锁:stock > 0 作为条件)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 乐观锁,防超卖
.update();
if (!success) {
log.error("库存不足!");
return;
}
// 保存订单
save(voucherOrder);
}
}
3. 为什么数据库操作里还要加分布式锁?
有人可能会问:Redis 的 Lua 脚本不是已经保证原子性了吗?为什么数据库操作里还要加锁?
这里其实是双重保险的设计思路:
| 层次 | 手段 | 作用 |
|---|---|---|
| Redis 层 | Lua 脚本原子操作 | 拦截 99% 的并发请求,快速判断资格 |
| 数据库层 | 分布式锁 + 乐观锁 | 兜底,防止极端情况下的数据不一致 |
Redis 判断资格虽然是原子的,但 Redis 和数据库之间的数据同步存在时间差。在极端情况下,Redis 中记录了购买成功但数据库还没落库,这时候数据库层的校验就是最后一道防线。
4. 关于代理对象调用的问题
在 handleVoucherOrder 方法中,调用的是 proxy.createVoucherOrder(voucherOrder) ,而不是 this.createVoucherOrder(voucherOrder)。
这是因为:Spring 事务依赖 AOP 代理,如果直接用 this 调用同类方法,事务注解会失效。
需要提前获取代理对象并保存起来:
java
// 在 seckillVoucher 方法中,提前获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 将 proxy 存成成员变量,供后台线程使用
this.proxy = proxy;
五、整体流程总结
到这里,整套秒杀优化方案就完整了,来做一个总结:


markdown
秒杀开始前:
将优惠券库存写入 Redis:SET seckill:stock:{id} 100
用户发起秒杀请求:
① 执行 Lua 脚本(原子操作):
- 判断库存 > 0
- 判断用户未购买过(SISMEMBER)
- 扣减库存(INCRBY -1)
- 记录用户(SADD)
② 有资格 → 生成订单ID → 投递到 BlockingQueue → 返回订单ID
③ 无资格 → 直接返回失败
后台线程(@PostConstruct 启动):
① 阻塞等待队列中的订单任务
② 加分布式锁(Redisson)
③ 操作数据库:扣库存 + 保存订单
④ 释放锁
这套方案的核心优势:
| 优化点 | 实现方式 |
|---|---|
| 防止超卖 | Lua 脚本原子扣减 Redis 库存 + 数据库乐观锁兜底 |
| 防止一人多单 | Lua 脚本 SISMEMBER 检查 + 分布式锁 + 数据库校验 |
| 减少数据库压力 | 请求线程只操作 Redis,数据库操作完全异步化 |
| 提升接口响应速度 | 请求线程执行时间 = Redis Lua 脚本耗时(通常 < 1ms) |
总结
这套秒杀优化方案的设计思路其实可以用一句话概括:
用 Redis 的速度挡住高并发,用异步队列解耦耗时操作,用数据库兜底保证数据正确性。
三层各司其职,这也是高并发系统设计中非常经典的分层思路,值得好好消化。