第五章 - 秒杀优化模块
本章核心目标:将同步秒杀改造为异步秒杀,大幅提升并发性能
5.1 异步秒杀思路(p069)
5.1.1 原有秒杀业务流程回顾
原有秒杀下单流程(串行执行):
请求 → 查询优惠券 → 判断库存 → 查询订单 → 获取分布式锁 → 判断一人一单 → 扣减库存 → 创建订单 → 释放锁核心问题: - 整个流程有 4 步数据库操作(查优惠券、查订单、扣库存、创建订单) - 数据库并发能力差,其中扣库存和创建订单还是写操作 - 还加了分布式锁,进一步影响并发 - 性能测试结果:平均耗时 ~400ms,最小 126ms,最大 800ms+
5.1.2 异步秒杀优化思路
类比饭店经营模式:
| 角色 | 职责 | 对应业务 |
|---|---|---|
| 前台小姐姐(主线程) | 接待顾客、点餐、收银(快速操作) | 秒杀资格判断 |
| 后厨(独立线程) | 做饭(耗时操作) | 扣减库存、创建订单 |
优化后的业务拆分:
第一部分(主线程 - 快速):秒杀资格判断
├── 库存是否充足
└── 是否一人一单
第二部分(独立线程 - 耗时):扣减库存 + 下单
└── 数据库写操作5.1.3 基于 Redis 完成秒杀资格判断
将秒杀资格判断从数据库迁移到 Redis,进一步提升性能:
数据结构选型:
| 需求 | 数据结构 | Key 设计 | Value 设计 |
|---|---|---|---|
| 库存判断 | String | seckill:stock:{voucherId} |
库存数值 |
| 一人一单 | Set | seckill:order:{voucherId} |
已购买用户ID集合 |
为什么用 Set? 满足两个条件:① 一个 key 存多个值 ② 元素唯一不重复
5.1.4 Lua 脚本保证原子性
判断流程涉及多个 Redis 操作,必须保证原子性,因此使用 Lua 脚本实现:
1. 判断库存是否充足 → 不足返回 1
2. 判断用户是否已下单 → 已下单返回 2
3. 扣减库存(DECR)
4. 保存用户ID到 Set(SADD)
5. 返回 0(有购买资格)5.1.5 完整异步秒杀流程
用户请求
│
├─→ 调用 Lua 脚本判断秒杀资格
│ │
│ ├─ 结果 = 0 → 有资格
│ │ ├─ 将订单信息(voucherId, userId, orderId)保存到阻塞队列
│ │ └─ 返回订单ID给用户 ← 秒杀流程结束!
│ │
│ └─ 结果 = 1/2 → 无资格,返回错误信息
│
└─→ 独立线程从队列取出订单信息 → 执行真正的下单操作(异步)关键优势: - 用户请求耗时极短(只做 Redis 判断 + 入队) - 数据库写操作完全异步化,不影响响应时间 - 大幅提高并发吞吐能力
5.2 基于 Redis 完成秒杀资格判断(p070)
5.2.1 需求一:新增秒杀券时保存库存到 Redis
在 VoucherController 的新增秒杀券方法中,增加 Redis 写入操作:
@Resource
private StringRedisTemplate stringRedisTemplate;
// 新增秒杀券时,同时保存库存到 Redis
stringRedisTemplate.opsForValue()
.set(RedisConstants.SECKILL_STOCK_KEY + voucher.getId(),
voucher.getStock().toString());Redis Key 常量定义:
// RedisConstants.java
public static final String SECKILL_STOCK_KEY = "seckill:stock:";
public static final String SECKILL_ORDER_KEY = "seckill:order:";注意: 库存不需要设置过期时间,永久保存,手动移除秒杀信息时再删除
5.2.2 需求二:Lua 脚本实现秒杀资格判断
Lua 脚本文件(seckill.lua):
-- 1. 参数列表
local voucherId = ARGV[1]
local userId = ARGV[2]
-- 2. 数据 key
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
-- 3. 业务逻辑
-- 3.1 判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1 -- 库存不足
end
-- 3.2 判断用户是否已下单
if (redis.call('sismember', orderKey, userId) == 1) then
return 2 -- 重复下单
end
-- 3.3 扣减库存
redis.call('incrby', stockKey, -1)
-- 3.4 保存用户ID到订单集合
redis.call('sadd', orderKey, userId)
return 0 -- 有购买资格关键细节: - Redis 返回的是字符串,必须用
tonumber()转换才能与数字比较 -sismember判断用户是否在 Set 中(存在返回 1,不存在返回 0) - Lua 脚本内用..拼接字符串,不是+
5.2.3 需求三 & 四:Java 代码执行 Lua 脚本
改造 VoucherOrderService.seckillVoucher() 方法:
// 1. 执行 Lua 脚本
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setLocation(new ClassPathResource("seckill.lua"));
script.setResultType(Long.class);
Long result = stringRedisTemplate.execute(
script,
Collections.emptyList(), // 无 K 类型参数,传空集合(不能传 null)
voucherId.toString(),
userId.toString()
);
// 2. 判断结果
int r = result.intValue();
if (r != 0) {
// 没有购买资格
if (r == 1) {
return Result.fail("库存不足!");
} else {
return Result.fail("不能重复下单!");
}
}
// 3. 有购买资格 → 生成订单ID
long orderId = redisIdWorker.nextId("order");
// TODO: 保存到阻塞队列(下节课实现)
return Result.ok(orderId);5.2.4 性能测试对比
| 指标 | 同步方案 | 异步方案(仅资格判断) |
|---|---|---|
| 平均耗时 | ~400ms | ~176ms |
| 最小耗时 | ~126ms | ~24ms |
| 最大耗时 | ~800ms | ~559ms |
5.3 基于阻塞队列实现秒杀异步下单(p071)
5.3.1 创建阻塞队列
在 VoucherOrderService 中创建阻塞队列:
// 阻塞队列:存储待处理的秒杀订单
private BlockingQueue<VoucherOrder> orderTasks =
new ArrayBlockingQueue<>(1024 * 1024);BlockingQueue 特点: 当线程尝试从空队列取元素时会被阻塞,直到有元素可用
5.3.2 保存订单信息到队列
在秒杀资格判断通过后,封装订单信息并入队:
if (r == 0) {
// 有购买资格
long orderId = redisIdWorker.nextId("order");
// 封装订单信息
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
// 放入阻塞队列
orderTasks.add(voucherOrder);
return Result.ok(orderId);
}5.3.3 创建线程池和异步任务
// 线程池:单线程处理秒杀订单
private static final ExecutorService SECKILL_ORDER_HANDLER =
Executors.newSingleThreadExecutor();
// 异步任务
class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 阻塞获取订单信息
VoucherOrder order = orderTasks.take();
// 处理订单
handleVoucherOrder(order);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}5.3.4 初始化时启动异步任务
使用 @PostConstruct 注解,在类初始化完成后立即启动:
@PostConstruct
private void init() {
SECKILL_ORDER_HANDLER.submit(new VoucherOrderHandler());
}5.3.5 处理订单逻辑
private void handleVoucherOrder(VoucherOrder order) {
// 1. 获取用户ID(从 order 对象获取,不能用 UserHolder)
Long userId = order.getUserId();
// 2. 创建锁对象(兜底,理论上 Redis 已判断过)
RLock lock = redissonClient.getLock("lock:order:" + userId);
try {
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!isLock) {
log.error("不允许重复下单");
return; // 异步处理,无需返回结果给前端
}
// 3. 获取代理对象并调用创建订单方法
proxy.createVoucherOrder(order);
} catch (InterruptedException e) {
log.error("处理订单异常", e);
} finally {
lock.unlock();
}
}5.3.6 ⚠️ 关键问题:子线程获取不到 ThreadLocal
问题: 异步线程无法从 ThreadLocal 获取用户信息和事务代理对象
解决方案:
| 场景 | 解决方案 |
|---|---|
| 获取用户信息 | 从 voucherOrder 对象中获取 userId,而不是用 UserHolder |
| 获取代理对象 | 在主线程获取代理对象,保存为成员变量,供子线程使用 |
// 成员变量存储代理对象
private IVoucherOrderService proxy;
// 在主线程获取并保存(秒杀方法中)
proxy = (IVoucherOrderService) AopContext.currentProxy();5.3.7 改造 createVoucherOrder 方法
改造要点: 1. 方法参数改为接收 VoucherOrder 对象(而非 voucherId) 2. 不再需要手动创建订单对象(已在外层创建) 3. 返回值改为 void(异步处理,无需返回) 4. 用户 ID 从 voucherOrder 获取,不从 UserHolder 获取
@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;
}
// 扣减库存(兜底)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 乐观锁
.update();
if (!success) {
log.error("库存不足");
return;
}
// 保存订单(不再创建,直接保存)
save(voucherOrder);
}5.3.8 完整优化流程图
┌─────────────────────────────────────────────────────────────┐
│ 用户请求 │
└─────────────────────┬───────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ VoucherOrderService.seckillVoucher() │
│ ① 执行 Lua 脚本 → 判断库存 + 一人一单(Redis,极快) │
│ ② 生成订单ID │
│ ③ 封装 VoucherOrder,放入阻塞队列 │
│ ④ 返回订单ID ← 秒杀流程结束! │
└─────────────────────┬───────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 阻塞队列(BlockingQueue) │
│ [order1, order2, order3, ...] │
└─────────────────────┬───────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 独立线程池(@PostConstruct 启动) │
│ ⑤ 从队列取出订单 │
│ ⑥ 获取分布式锁(兜底) │
│ ⑦ 判断一人一单 + 扣减库存 + 保存订单(数据库操作) │
└─────────────────────────────────────────────────────────────┘5.4 性能总结与遗留问题
5.4.1 性能对比总结
| 方案 | 平均耗时 | 最小耗时 | 核心瓶颈 |
|---|---|---|---|
| 同步方案(数据库 + 分布式锁) | ~400ms | ~126ms | 数据库串行读写 |
| 异步方案(Redis + 阻塞队列) | ~216ms | ~17ms | CPU 切换开销 |
异步方案的额外开销来自子线程处理和数据库兜底操作,但在资源充足时性能会更好
5.4.2 遗留问题
使用 JDK 阻塞队列(ArrayBlockingQueue)存在两个问题:
问题一:内存限制 - 队列使用 JVM 内存,有容量上限 - 高并发时订单量可能超过队列容量 - 超出容量后新订单无法入队 → 订单丢失
问题二:数据安全 - 服务宕机/重启 → 内存中队列数据全部丢失 - 线程取出任务后执行前发生异常 → 任务丢失且不会重新执行 - 导致用户显示下单成功,但后台无订单数据 → 数据不一致
解决方案: 引入独立的消息队列服务(如 Redis Stream),见下一章
5.5 核心知识点速查
| 知识点 | 要点 |
|---|---|
| 异步秒杀核心思想 | 资格判断(快)+ 下单(慢)分离 |
| Redis 数据结构 | String(库存)+ Set(一人一单) |
| Lua 脚本作用 | 保证判断+扣减+记录的原子性 |
tonumber() 必要性 |
Redis GET 返回字符串,不能直接与数字比较 |
Collections.emptyList() |
Lua 无 key 参数时传空集合,不能传 null |
| BlockingQueue | 阻塞等待,不浪费 CPU |
@PostConstruct |
Bean 初始化完成后立即启动异步任务 |
| 子线程获取用户 | 从 Order 对象获取,不能用 ThreadLocal |
| 代理对象获取 | 主线程获取后存为成员变量,子线程共享 |