|

第五章 - 秒杀优化模块

本章核心目标:将同步秒杀改造为异步秒杀,大幅提升并发性能


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
代理对象获取 主线程获取后存为成员变量,子线程共享
评论交流

文章目录