day40 Redis秒杀优化与阻塞队列异步下单
一、原秒杀业务的问题分析
优化前的秒杀下单流程中,一次请求需要完成以下全部操作后才返回订单 id:
1 | 查询优惠券 |
其中涉及 2 次数据库查询和 2 次数据库修改,且所有操作串行执行。数据库 IO 耗时较高,用户必须等待全部操作完成才能拿到响应,吞吐量受限。

图中红色标记的”判断秒杀库存”和”校验一人一单”是判断逻辑,本身不产生订单数据,但每次都要查库,是主要耗时点。
二、优化整体思路
2.1 核心方案
将耗时短的判断逻辑前移到 Redis 中执行,判断通过后立即返回订单 id,实际下单操作异步执行:
- 新增秒杀券时,将库存缓存到 Redis,并用 Set 维护已下单用户
- 使用 Lua 脚本原子执行库存判断与一人一单检查
- 判断通过后,生成订单 id 并立即返回给用户
- 将订单信息放入阻塞队列
- 异步线程从队列取订单,完成数据库扣减库存和保存订单

图中左侧红色框为 Redis 预判断阶段,中间为阻塞队列,右侧为异步执行的数据库操作。用户请求只需完成左侧判断即可返回,不必等待右侧全部执行完毕。
2.2 优化前后对比
| 对比项 | 优化前 | 优化后 |
|---|---|---|
| 库存判断 | 查数据库 | 读 Redis |
| 一人一单判断 | 查数据库 | Redis Set SISMEMBER |
| 判断与扣减是否原子 | 否,需加锁 | Lua 脚本原子执行 |
| 用户响应时机 | 全部操作完成后 | 判断通过后立即返回 |
| 下单操作 | 同步串行 | 异步线程从阻塞队列消费 |
三、Redis 缓存秒杀库存
3.1 缓存时机
在新增秒杀券时,将库存写入 Redis。项目实现在 VoucherServiceImpl.addSeckillVoucher 中:
1 |
|
SECKILL_STOCK_KEY 的值为 "seckill:stock:",因此 Redis 中的库存 key 格式为 seckill:stock:{voucherId},value 为库存数量的字符串。
3.2 Redis 数据结构
| 用途 | 数据结构 | key 格式 | value |
|---|---|---|---|
| 优惠券库存 | String | seckill:stock:{voucherId} |
库存数量 |
| 已下单用户 | Set | seckill:order:{voucherId} |
已下单的 userId 集合 |
库存使用 String 类型,通过 DECR 原子扣减;已下单用户使用 Set 类型,通过 SISMEMBER 判断是否重复下单、SADD 记录下单用户。
注意:当前方案将库存存为 String,未在 Redis 中保存秒杀时间信息,因此 Lua 脚本无法校验秒杀时间。
四、Lua 脚本原子判断
4.1 脚本内容
项目中的 Lua 脚本位于 resources/seckill.lua,由 DefaultRedisScript 加载:
1 | -- 优惠券id |
4.2 返回值含义
| 返回值 | 含义 | 前端提示 |
|---|---|---|
| 0 | 库存充足且未重复下单,抢购成功 | 返回订单 id |
| 1 | 库存不足 | 库存不足 |
| 2 | 用户已下过单 | 不允许重复下单 |
Java 侧通过 stringRedisTemplate.execute 执行脚本,KEYS 传空列表,ARGV 传 voucherId 和 userId:
1 | Long result = stringRedisTemplate.execute( |
4.3 为什么后续不需要加锁
Lua 脚本在 Redis 中以原子方式执行”判断库存 → 判断一人一单 → 扣减库存 → 记录用户”这四步,中间不会被其他命令插入。加上 Redis 本身是单线程执行命令的,因此不存在并发安全问题。
这意味着后续异步线程执行数据库扣减库存和保存订单时,不需要再判断一人一单,也不需要加分布式锁。项目中的 createVoucherOrder 方法因此省略了旧版本中的 query().eq("user_id", userId).count() 查询。
4.4 执行流程

图中左侧为 Lua 脚本内部判断流程,右侧为 Java 侧的整体调用流程:执行脚本 → 判断返回值 → 存入阻塞队列 → 返回订单 id → 异步下单。
五、阻塞队列与异步下单
5.1 阻塞队列
阻塞队列(BlockingQueue)是一种支持阻塞操作的队列:当队列为空时,take() 方法会阻塞等待,直到有元素入队才返回。项目使用 ArrayBlockingQueue:
1 | BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024); |
容量设为 1024 * 1024(约 100 万),存储在 JVM 内存中。主线程将订单放入队列,异步线程从队列取出处理,实现生产者-消费者解耦。
5.2 异步下单实现
初始化异步线程:使用 @PostConstruct 在 Bean 创建后立即提交异步任务,单线程池持续从队列消费订单:
1 | private static final ExecutorService executorService = Executors.newSingleThreadExecutor(); |
@PostConstruct 标注的方法在 Bean 完成依赖注入后自动执行一次,适合做初始化。VoucherOrderHandler 是内部类,实现了 Runnable,在 while(true) 循环中通过 take() 阻塞获取订单:
1 | private class VoucherOrderHandler implements Runnable { |
主线程下单入口:seckillVoucher 方法在主线程(Tomcat 请求线程)中执行,获取用户 id、执行 Lua 脚本、创建订单对象、放入队列并返回:
1 |
|
关键点:
userId在主线程通过UserHolder.getUser().getId()获取,并存入VoucherOrder对象。异步线程直接从订单对象读取,不再调用UserHolderorderId由RedisIdWorker.nextId("order")生成,判断通过后才生成并返回给用户AopContext.currentProxy()获取 Spring 代理对象,确保异步线程调用createVoucherOrder时@Transactional生效
异步线程执行数据库操作:createVoucherOrder 只做扣减库存和保存订单,不再校验一人一单:
1 |
|
.gt("stock", 0) 是数据库层面的兜底保护:即使 Redis 判断已通过,数据库仍确保不会扣到负数。
5.3 完整调用链
1 | 用户请求 |
六、今日问题与注意事项
6.1 ThreadLocal 在异步线程中失效
- 现象:在异步线程(
VoucherOrderHandler)中调用UserHolder.getUser().getId()抛出异常。 - 原因:
UserHolder内部使用ThreadLocal存储用户信息,ThreadLocal的数据只对当前线程可见。主线程(Tomcat 请求线程)存入的用户信息,异步线程无法读取。 - 解决:在主线程中获取
userId并存入VoucherOrder对象,异步线程通过voucherOrder.getUserId()读取,不再依赖ThreadLocal。 - 提醒:任何在拦截器中通过
ThreadLocal设置的上下文信息(用户、请求头等),传递到异步线程时都需要显式传入对象,不能直接调用ThreadLocal的 getter。
6.2 阻塞队列方案的局限性
- 内存限制:
ArrayBlockingQueue存储在 JVM 堆内存中,容量受限于内存大小。队列设置过大可能引发 OOM,过小则在高并发时可能拒绝入队。 - 数据安全问题:服务突然宕机时,队列中尚未消费的订单会随内存丢失,用户已收到订单 id 但实际订单未落库。
这两个问题是阻塞队列作为消息队列的固有缺陷,后续可改用 Redis Stream 或专业消息队列(如 RabbitMQ、Kafka)解决,利用持久化和确认机制保证消息不丢失。
6.3 未校验秒杀时间
当前 Lua 脚本只判断库存和一人一单,没有校验秒杀时间。虽然前端会限制秒杀时间窗口,但后端缺少校验存在安全隐患。
改进思路:将 Redis 中库存的存储方式从 String 改为 Hash,同时存储库存、开始时间和结束时间,在 Lua 脚本中增加时间判断逻辑。
今日总结
| 模块 | 核心要点 |
|---|---|
| 性能问题 | 原方案 2 查 2 改串行执行,数据库 IO 是主要耗时点 |
| 优化方案 | Redis 预判断 + 阻塞队列异步下单,判断通过即返回 |
| Redis 缓存 | 新增秒杀券时缓存库存(String)和已下单用户(Set) |
| Lua 脚本 | 原子执行库存判断、一人一单检查、扣减库存、记录用户 |
| 返回值约定 | 0 = 成功,1 = 库存不足,2 = 重复下单 |
| 阻塞队列 | ArrayBlockingQueue + 单线程异步消费,主线程只负责入队 |
| ThreadLocal 踩坑 | 异步线程无法读取主线程 ThreadLocal,需通过对象传递 |
| 阻塞队列局限 | 内存限制 + 宕机丢数据,可改用消息队列持久化 |
| 遗留问题 | Lua 脚本未校验秒杀时间,可改用 Hash 存储时间字段 |