day40 Redis秒杀优化与阻塞队列异步下单

day40 Redis秒杀优化与阻塞队列异步下单

一、原秒杀业务的问题分析

优化前的秒杀下单流程中,一次请求需要完成以下全部操作后才返回订单 id:

1
2
3
4
5
6
7
8
9
10
11
查询优惠券
↓
判断秒杀库存是否充足
↓
查询订单(校验一人一单)
↓
扣减库存
↓
创建订单
↓
返回订单 id

其中涉及 2 次数据库查询和 2 次数据库修改,且所有操作串行执行。数据库 IO 耗时较高,用户必须等待全部操作完成才能拿到响应,吞吐量受限。

图中红色标记的”判断秒杀库存”和”校验一人一单”是判断逻辑,本身不产生订单数据,但每次都要查库,是主要耗时点。

二、优化整体思路

2.1 核心方案

将耗时短的判断逻辑前移到 Redis 中执行,判断通过后立即返回订单 id,实际下单操作异步执行:

  1. 新增秒杀券时,将库存缓存到 Redis,并用 Set 维护已下单用户
  2. 使用 Lua 脚本原子执行库存判断与一人一单检查
  3. 判断通过后,生成订单 id 并立即返回给用户
  4. 将订单信息放入阻塞队列
  5. 异步线程从队列取订单,完成数据库扣减库存和保存订单

图中左侧红色框为 Redis 预判断阶段,中间为阻塞队列,右侧为异步执行的数据库操作。用户请求只需完成左侧判断即可返回,不必等待右侧全部执行完毕。

2.2 优化前后对比

对比项 优化前 优化后
库存判断 查数据库 读 Redis
一人一单判断 查数据库 Redis Set SISMEMBER
判断与扣减是否原子 否,需加锁 Lua 脚本原子执行
用户响应时机 全部操作完成后 判断通过后立即返回
下单操作 同步串行 异步线程从阻塞队列消费

三、Redis 缓存秒杀库存

3.1 缓存时机

在新增秒杀券时,将库存写入 Redis。项目实现在 VoucherServiceImpl.addSeckillVoucher 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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());
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
-- 优惠券id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]

-- 库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 订单key
local orderKey = 'seckill:order:' .. voucherId

-- 查库存,如果库存不足,则返回1
if(tonumber(redis.call("get", stockKey)) < 1) then
return 1
end

-- 一人一单,如果用户已下单,则返回2
if(redis.call("SISMEMBER", orderKey, userId) == 1) then
return 2
end

-- 减库存
redis.call("DECR", stockKey)

-- 下单
redis.call("SADD", orderKey, userId)

-- 返回0
return 0

4.2 返回值含义

返回值 含义 前端提示
0 库存充足且未重复下单,抢购成功 返回订单 id
1 库存不足 库存不足
2 用户已下过单 不允许重复下单

Java 侧通过 stringRedisTemplate.execute 执行脚本,KEYS 传空列表,ARGV 传 voucherId 和 userId:

1
2
3
4
5
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);

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
2
3
4
5
6
private static final ExecutorService executorService = Executors.newSingleThreadExecutor();

@PostConstruct
private void init() {
executorService.submit(new VoucherOrderHandler());
}

@PostConstruct 标注的方法在 Bean 完成依赖注入后自动执行一次,适合做初始化。VoucherOrderHandler 是内部类,实现了 Runnable,在 while(true) 循环中通过 take() 阻塞获取订单:

1
2
3
4
5
6
7
8
9
10
11
12
13
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
VoucherOrder voucherOrder = orderTasks.take();
iVoucherOrderService.createVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
log.error("从阻塞队列中获取订单异常", e);
}
}
}
}

主线程下单入口:seckillVoucher 方法在主线程(Tomcat 请求线程)中执行,获取用户 id、执行 Lua 脚本、创建订单对象、放入队列并返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT, Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
if (r != 0) {
return Result.fail(r == 1 ? "库存不足!" : "不允许重复下单!");
}

VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);

orderTasks.add(voucherOrder);
iVoucherOrderService = (IVoucherOrderService) AopContext.currentProxy();
return Result.ok(orderId);
}

关键点:

  • userId 在主线程通过 UserHolder.getUser().getId() 获取,并存入 VoucherOrder 对象。异步线程直接从订单对象读取,不再调用 UserHolder
  • orderId 由 RedisIdWorker.nextId("order") 生成,判断通过后才生成并返回给用户
  • AopContext.currentProxy() 获取 Spring 代理对象,确保异步线程调用 createVoucherOrder 时 @Transactional 生效

异步线程执行数据库操作:createVoucherOrder 只做扣减库存和保存订单,不再校验一人一单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long voucherId = voucherOrder.getVoucherId();

boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
log.error("库存不足");
return;
}

save(voucherOrder);
}

.gt("stock", 0) 是数据库层面的兜底保护:即使 Redis 判断已通过,数据库仍确保不会扣到负数。

5.3 完整调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
用户请求
↓
seckillVoucher (主线程)
├── UserHolder.getUser().getId() 获取用户id
├── 执行 Lua 脚本 原子判断库存与一人一单
├── 判断返回值
│ 0 → 继续; 1 → 库存不足; 2 → 重复下单
├── 创建 VoucherOrder 对象
├── orderTasks.add(voucherOrder) 放入阻塞队列
└── 返回订单 id
↓
VoucherOrderHandler (异步线程)
├── orderTasks.take() 阻塞获取订单
└── createVoucherOrder(order)
├── 扣减数据库库存 (stock > 0)
└── save(order) 保存订单到数据库

六、今日问题与注意事项

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 存储时间字段