秒杀优化
约 2205 字大约 7 分钟
2025-07-04
6.1 异步秒杀思路
回顾下单流程,传统模式下,用户请求经过 Nginx 到 Tomcat, Tomcat 程序会串行执行以下步骤:
- 查询优惠券
- 判断秒杀库存是否足够
- 查询订单
- 校验是否一人一单
- 扣减库存
- 创建订单
这些步骤包含大量数据库操作,且为线程串行执行,导致程序执行缓慢。
为了提升订单处理速度,可以采用异步方式来优化程序执行流程。具体做法是:将耗时短的逻辑判断(例如:库存是否足够、是否一人一单)放入 Redis 中进行处理。如果这些逻辑判断通过,则立即向用户返回成功,然后在后台开启线程,异步执行队列中的消息。
采用异步化方案的优势:
- 程序响应速度快
- 无需担心线程池资源耗尽
在整个异步化流程中,需要解决以下两个关键问题:
如何在 Redis 中快速校验一人一单和库存判断?
利用 Lua 脚本保证原子性操作,在 Redis 中进行快速校验。 这样做可以确保在高并发场景下,一人一单和库存判断的准确性。
如何知道后台 Tomcat 下单逻辑是否成功?
在 Redis 操作完成后,将信息返回给前端,并同时将这些信息放入异步队列中。 后续可以通过返回的订单 ID 来查询 Tomcat 中的下单逻辑是否完成。

因此,详细流程如下:
- 用户发起下单请求。
- 首先在 Redis 中判断库存是否充足。
- 如果库存充足,则继续在 Redis 中判断用户是否可以下单(通过
SET
集合判断是否为一人一单)。 - 如果用户可以下单,则将用户 ID 和优惠券信息存入 Redis,并返回 0。
- 如果 Redis 返回的结果是 0,则表示可以下单,将相关信息存入消息队列,并返回给前端订单 ID。
- 后台线程异步执行下单逻辑。

6.2 Redis 完成秒杀资格判断
需求:
- 新增秒杀优惠券的同时,将优惠券信息保存到 Redis 中。
- 基于 Lua 脚本,判断秒杀库存、一人一单,决定用户是否抢购成功。
- 如果抢购成功,将优惠券 ID 和用户 ID 封装后存入阻塞队列。
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能。
具体实现:
首先,在 VoucherServiceImpl
中新增秒杀优惠券时,将优惠券信息保存到数据库,并将秒杀库存同步到 Redis 中:
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到Redis中
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
接下来,使用 Lua 脚本进行秒杀资格判断。完整的 Lua 表达式如下:
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0
此 Lua 脚本首先检查库存是否充足,然后检查用户是否已经下单。如果两个条件都满足,则扣减库存并将用户 ID 存入已下单的 SET 集合中,最后将消息发送到队列中,整个过程是原子性的。
在 VoucherOrderServiceImpl
中,调用 Lua 脚本:
// 初始化秒杀 Lua 脚本
private static final DefaultRedisScript<Long> SCIKILL_SCRIPT;
static {
SCIKILL_SCRIPT = new DefaultRedisScript<>();
SCIKILL_SCRIPT.setLocation(new ClassPathResource("lua/scikill.lua"));
SCIKILL_SCRIPT.setResultType(Long.class);
}
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1. 执行 Lua 脚本
Long result = stringRedisTemplate.execute(
SCIKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString()
);
// 2. 判断结果是否为 0
if (result != 0L) {
return result == 1L ? Result.fail(MessageConstants.STOCK_OUT) : Result.fail(MessageConstants.SINGLE_ORDER_LIMIT_REACHED);
}
// TODO: 3. 保存阻塞队列
return Result.ok();
}
根据 Lua 脚本的执行结果,如果返回 0,则表示抢购成功,将订单信息保存到阻塞队列中,等待后续异步处理。
6.3 基于阻塞队列实现秒杀优化
在 VoucherOrderServiceImpl
中,修改下单动作,通过 Lua 表达式原子性地执行判断逻辑。如果判断结果不为 0,则返回错误信息(库存不足或重复下单);如果结果为 0,则将下单逻辑保存到阻塞队列中,异步执行。
创建阻塞队列,存放订单信息。
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);
在
seckillVoucher
方法中,将抢购成功的订单信息放入阻塞队列:// 将代理对象设为成员变量,以便在对象成中可以调用 Spring 的事务 private IVoucherOrderService proxy; @Override public Result seckillVoucher(Long voucherId) { Long userId = UserHolder.getUser().getId(); // 1. 执行 Lua 脚本 Long result = stringRedisTemplate.execute( SCIKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString() ); // 2. 判断结果是否为 0 if (result != 0L) { return result == 1L ? Result.fail(MessageConstants.STOCK_OUT) : Result.fail(MessageConstants.SINGLE_ORDER_LIMIT_REACHED); } // 3. 保存订单到阻塞队列 long orderId = redisIdWorker.nextId("order"); VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setVoucherId(voucherId); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); orderTasks.add(voucherOrder); // 4. 获取代理对象 proxy = (IVoucherOrderService) AopContext.currentProxy(); return Result.ok(orderId); }
代码解释:
proxy = (IVoucherOrderService) AopContext.currentProxy();
这行代码的目的是获取当前对象的代理对象。在 Spring 中,如果一个 Bean 需要进行事务管理,Spring 会为其创建一个代理对象,代理对象会负责事务的开启、提交或回滚。由于后续的订单处理是异步的,在新的线程中 Spring 的事务可能失效,因此需要提前获取代理对象,以便在异步线程中也能使用 Spring 的事务管理。
创建线程池,异步执行秒杀任务。
private static final ExecutorService SCIKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
提交订单处理任务。在类初始化后,提交订单处理任务到线程池中。
@PostConstruct private void init() { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); }
代码解释:
@PostConstruct
标记一个在依赖注入完成后执行的方法,当 Spring 容器创建并装配好该类的所有依赖后,就会自动调用被@PostConstruct
注解的方法。- 选择在类初始化后启动订单处理,是一种比较稳妥和常见的做法。它可以确保所有依赖都已经注入,资源已经准备就绪,并且避免了并发问题:
- 如果过早启动订单处理,可能会因为资源未就绪(例如数据库连接池、Redis 连接等)而导致订单处理失败。
- 在高并发场景下,如果订单处理启动较晚,可能会导致阻塞队列中堆积大量的订单,增加系统的压力。
订单处理。
VoucherOrderHandler
是一个实现了Runnable
接口的内部类,负责从阻塞队列中获取订单信息,并调用handleVoucherOrder
方法进行处理。private class VoucherOrderHandler implements Runnable { // 处理订单 @Override public void run() { while (true) { try { // 1. 获取队列中的订单信息 VoucherOrder voucherOrder = orderTasks.take(); // 2. 创建订单 handleVoucherOrder(voucherOrder); } catch (Exception e) { log.error("订单处理异常", e); } } } }
将阻塞队列中的订单传入数据库。
// 创建订单 private void handleVoucherOrder(VoucherOrder voucherOrder) { // 1. 获取用户 id Long userId = voucherOrder.getUserId(); // 2. 获取锁 RLock lock = redissonClient.getLock("lock:order:" + userId); boolean isLock = lock.tryLock(); // 3.1 失败则返回错误信息 if (!isLock) { log.error(MessageConstants.SINGLE_ORDER_LIMIT_REACHED); } // 3.2 成功则创建订单 try { proxy.createVoucherOrder(voucherOrder); } finally { lock.unlock(); } }
代码解释:
- 即使在 Redis 中执行 Lua 脚本后,重复下单的概率很低,但为了确保数据的一致性,这里仍然使用了 Redisson 分布式锁。
- 由于 Spring 的事务是基于 ThreadLocal 的,在多线程环境下事务会失效,因此使用之前获取的代理对象
proxy
来调用createVoucherOrder
方法,以确保事务的有效性。
数据库操作。由于订单已被放入阻塞队列中,因此这一步只需对数据库操作。
@Override @Transactional public void createVoucherOrder(VoucherOrder voucherOrder) { // 1. 一人一单 // 1.1 获取用户 id Long userId = voucherOrder.getUserId(); // 1.2 判断是否已有购买记录 int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count(); if (count > 0) { log.error(MessageConstants.SINGLE_ORDER_LIMIT_REACHED); } // 2. 扣减库存 boolean isSuccess = seckillVoucherService.update() .setSql("stock= stock -1") .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0).update(); if (!isSuccess) { log.error(MessageConstants.STOCK_OUT); } save(voucherOrder); }