【黑马点评 | 第九篇】秒杀优化-异步实现

秒杀下单的核心矛盾是:请求量很大,但数据库扣库存和创建订单的处理能力有限。如果每个请求都同步完成查询、校验、扣库存和写订单,数据库很快就会成为瓶颈。

这次优化把秒杀流程拆成两段:Redis 负责快速判断库存和一人一单,数据库相关的订单处理放到后台线程异步执行。实现先从 JVM 阻塞队列开始,再解决阻塞队列的内存和可靠性问题,最后把订单消息迁移到 Redis Stream 消费组。

一、同步下单为什么需要优化

原有下单流程通常包含以下步骤:

  1. 查询优惠券和秒杀信息;
  2. 判断秒杀是否开始、是否结束以及库存是否充足;
  3. 查询当前用户是否已经下单;
  4. 扣减数据库库存;
  5. 创建订单。

其中,优惠券查询、重复下单查询、库存更新和订单保存都会访问数据库。高并发到来时,大量请求会同时占用数据库连接和执行 SQL,真正需要写数据库的步骤会互相争抢资源。

优化后的处理顺序是:请求先把库存和一人一单判断放到 Redis 中完成。判断成功后,只把订单所需的用户 ID、优惠券 ID 和订单 ID 交给异步线程,数据库扣库存和创建订单由后台慢慢处理。这样,请求线程不需要等待完整的下单事务结束,可以更快返回订单 ID。

二、用 Redis Lua 完成秒杀资格判断

2.1 把秒杀库存预热到 Redis

创建秒杀优惠券时,数据库保存优惠券和秒杀信息,同时把库存写入 Redis。库存 key 按优惠券 ID 区分,例如:

java 复制代码
@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(
            RedisConstants.SECKILL_STOCK_KEY + voucher.getId(),
            voucher.getStock().toString()
    );
}

请求到达时直接读取 Redis 中的库存,不需要先查询数据库。Redis 中还需要为每张优惠券维护一个 Set,用来记录已经抢购成功的用户 ID。

2.2 为什么库存判断和重复下单判断要放进 Lua

库存判断、重复下单判断、扣减库存和记录用户必须作为一个整体执行。如果先读取库存,再判断用户是否下单,最后分别执行扣库存和写入用户,多个请求之间可能在这些步骤中交错执行,造成超卖或重复下单。

Redis 执行 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

脚本返回 1 表示库存不足,返回 2 表示用户已经购买过,返回 0 表示抢购资格校验通过。这里的 Set 只记录"已经通过 Redis 抢购资格判断"的用户,数据库订单仍然由后续异步流程创建。

2.3 Java 调用 Lua 脚本

Spring Data Redis 可以把 resources 目录下的 Lua 文件加载成 DefaultRedisScript:

java 复制代码
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}

请求方法只负责获取用户 ID、生成订单 ID、执行脚本并根据返回值决定是否继续:

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    //获取用户
    Long userId = UserHolder.getUser().getId();
    //获取订单id
    long orderId = redisIdWork.nextId("order");
    //1.调用lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(),
            userId.toString(),
            String.valueOf(orderId)
    );
    int r = result.intValue();
    //2.判断结果是否为0
    if (r != 0) {
        //2.1不为0,无购买资格
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    //2.2 为0,有购买资格,订单消息已经写入消息队列
    return Result.ok(orderId);
}

当前 Java 调用传入了三个参数:优惠券 ID、用户 ID 和订单 ID。Lua 脚本如果要把订单消息写入 Stream,还必须定义 local orderId = ARGV[3];否则后面的 XADD 命令拿不到订单 ID。

三、第一版实现:阻塞队列加异步线程池

3.1 先把数据库下单放到后台线程

Lua 脚本返回 0 后,说明请求已经完成了库存预扣和一人一单判断。此时可以创建 VoucherOrder 对象,放入 JVM 的 BlockingQueue,然后立即返回订单 ID。

java 复制代码
private static final ExecutorService SECKILL_ORDER_EXECUTOR =
        Executors.newSingleThreadExecutor();

private final BlockingQueue<VoucherOrder> orderTasks =
        new ArrayBlockingQueue<>(1024 * 1024);

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

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);
            }
        }
    }
}

ArrayBlockingQueue 是有界阻塞队列。生产者把订单放入队列,消费者线程通过 take 阻塞等待消息;队列为空时,消费者不会空转,队列有消息时才继续处理。单线程执行器保证当前实例内按顺序处理订单。

3.2 消费线程中的一人一单和数据库操作

订单处理线程从队列拿到消息后,再获取用户维度的分布式锁,查询数据库订单、扣减库存并保存订单:

java 复制代码
private void handleVoucherOrder(VoucherOrder voucherOrder) {
    // 1.获取用户
    Long userId = voucherOrder.getUserId();
    // 2.创建锁对象
    RLock redisLock = redissonClient.getLock("lock:order:" + userId);
    // 3.尝试获取锁
    boolean isLock = redisLock.tryLock();
    // 4.判断是否获得锁成功
    if (!isLock) {
        // 获取锁失败,直接返回失败或者重试
        log.error("不允许重复下单!");
        return;
    }
    try {
        proxy.createVoucherOrder(voucherOrder);
    } finally {
        // 释放锁
        redisLock.unlock();
    }
}

@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
    Long userId = voucherOrder.getUserId();
    // 5.1.查询订单
    int count = query()
            .eq("user_id", userId)
            .eq("voucher_id", voucherOrder.getVoucherId())
            .count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        log.error("用户已经购买过一次!");
        return;
    }

    // 6.扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1") // set stock = stock - 1
            .eq("voucher_id", voucherOrder.getVoucherId())
            .gt("stock", 0) // where id = ? and stock > 0
            .update();
    if (!success) {
        log.error("库存不足!");
        return;
    }
    // 7.创建订单
    save(voucherOrder);
}

异步线程不会自动继承请求线程中的 Spring 事务上下文,事务方法需要通过 Spring 代理调用。直接使用 this 调用会绕过事务代理,导致 @Transactional 不生效,因此阻塞队列版本需要保存订单服务代理对象,再由消费者线程调用代理方法。

3.3 为什么异步后响应更快

同步方案要等数据库查询、扣库存和保存订单都完成后才能返回。异步方案把 Redis 中可以快速完成的资格判断放在请求线程,数据库写操作交给后台线程。请求线程只负责生成订单 ID、投递订单消息和返回结果,等待时间明显缩短。

这里的"异步"只改变调用方是否等待,不代表数据库操作消失了。订单最终仍然要经过库存更新和订单保存,后台消费者的处理能力决定了消息堆积速度和订单落库速度。

四、阻塞队列实现存在的问题

4.1 JVM 内存限制

BlockingQueue 的数据保存在当前 Java 进程的堆内存中。队列容量虽然可以设置得很大,但仍然受到 JVM 内存上限约束。秒杀流量持续升高时,订单消息可能不断堆积,最终导致内存压力甚至触发频繁 GC 或内存溢出。

此外,单个实例中的队列只能被这个实例自己的消费者线程读取。应用扩容后,每个实例都有一份独立队列,订单消息不会自动在多个实例之间共享,实例之间也无法统一协调积压情况。

4.2 数据安全问题

队列中的消息还没有落到 Redis 或数据库时,数据只存在 JVM 内存中。应用重启、服务器宕机或进程异常退出,队列里的订单消息都会丢失。

消费者通过 take 取出消息后,如果数据库处理过程中发生异常,消息已经从队列中移除,BlockingQueue 没有消息确认和待处理列表,程序也无法根据消息 ID 自动恢复。因此,阻塞队列适合演示异步流程或低风险的临时任务,不适合承载需要可靠投递的订单消息。

要解决这两个问题,订单消息需要放到独立于应用 JVM 的持久化存储中,并且要能记录"消息已经被哪个消费者取走但还没有处理完成"。Redis Stream 的消费者组提供了这两种能力。

五、使用 Redis Stream 改造消息队列

5.1 Stream 的消息模型

Redis Stream 是 Redis 提供的日志型数据结构。生产者通过 XADD 把一条带字段和值的消息追加到 Stream,Redis 为消息生成递增的消息 ID。消费者可以按 ID 读取历史消息,也可以使用阻塞读取等待新消息。

异步秒杀中需要一个名为 stream.orders 的 Stream,以及一个消费者组 g1。消费者组会记录组的消费位置,并为每个消费者维护 Pending Entries List,简称 pending-list:

  • 消费者读取到消息后,消息先进入当前消费者的 pending-list;
  • 业务处理成功后,通过 XACK 确认消息,消息才会从 pending-list 移除;
  • 消费者处理过程中宕机时,消息仍然保留在 pending-list,可以重新读取处理。

创建消息队列和消费者组的命令如下:

text 复制代码
XGROUP CREATE stream.orders g1 0 MKSTREAM

其中,0 表示从 Stream 的第一条消息开始消费,MKSTREAM 表示 Stream 不存在时自动创建。生产环境使用时,消费者组创建操作应保证只执行一次;重复创建同名消费者组会返回已存在错误,需要按项目启动流程处理。

5.2 让 Lua 在资格通过后直接发送订单消息

Redis 资格判断和消息投递需要保持一致。Lua 脚本只有在库存充足、用户未下单、库存扣减和用户记录都成功后,才向 stream.orders 写入订单消息:

lua 复制代码
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]

-- 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)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*',
        'userId', userId,
        'voucherId', voucherId,
        'id', orderId)
return 0

XADD 与前面的库存扣减、用户记录处于同一个 Lua 脚本中。脚本返回 0 后,订单消息已经进入 Redis Stream,Java 请求线程只需要返回订单 ID,不再把订单对象放进 JVM 队列。

5.3 消费者组读取新消息

项目启动后创建单线程执行器,并提交订单消费者任务。消费者使用 g1 消费组和 c1 消费者,从最后消费位置读取新消息;没有消息时最多阻塞两秒:

java 复制代码
private static final ExecutorService SECKILL_ORDER_EXECUTOR =
        Executors.newSingleThreadExecutor();

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

private class VoucherOrderHandler implements Runnable {
    private final String queueName = "stream.orders";

    @Override
    public void run() {
        while (true) {
            try {
                // 1.获取消息队列中的订单信息
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        StreamOffset.create(queueName, ReadOffset.lastConsumed())
                );
                //2.判断消息获取是否成功
                if (list == null || list.isEmpty()) {
                    // 如果为null,说明没有消息,继续下一次循环
                    continue;
                }
                // 3.解析消息中的订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(
                        values, new VoucherOrder(), true);
                // 4.如果获取成功,可以下单
                createVoucherOrder(voucherOrder);
                // 5.确认消息 XACK stream.orders g1 id
                stringRedisTemplate.opsForStream()
                        .acknowledge(queueName, "g1", record.getId());
            } catch (Exception e) {
                log.error("处理订单异常:", e);
                //处理异常消息
                handlePendingList();
            }
        }
    }
}

StreamReadOptions 的 count(1) 限制每次最多读取一条消息,block(Duration.ofSeconds(2)) 让没有消息时的读取进入阻塞等待。ReadOffset.lastConsumed() 对应消费者组的未消费位置,消息被读取后会进入当前消费者的 pending-list。

订单处理成功后才执行 acknowledge。XACK 的作用是从消费者组的 pending-list 中移除指定消息,表示这条订单消息已经完成处理。如果 createVoucherOrder 抛出异常,代码不会执行 XACK,而是进入 pending-list 恢复逻辑。

5.4 处理 pending-list 中未确认的消息

消费者进程可能在"读取消息"和"确认消息"之间异常退出。重新启动后,不能只读取新消息,还要先处理之前留在 pending-list 中的消息。项目通过相同的消费者组和消费者读取起始 ID 0,循环处理未确认消息:

java 复制代码
private void handlePendingList() {
    while (true) {
        try {
            // 1.获取pending-list中的订单信息
            List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"),
                    StreamReadOptions.empty().count(1),
                    StreamOffset.create(queueName, ReadOffset.from("0"))
            );
            // 2.判断消息获取是否成功
            if (list == null || list.isEmpty()) {
                // 如果获取失败,说明pending-list没有异常消息,结束循环
                break;
            }
            // 3.解析消息中的订单信息
            MapRecord<String, Object, Object> record = list.get(0);
            Map<Object, Object> values = record.getValue();
            VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(
                    values, new VoucherOrder(), true);
            // 4.如果获取成功,可以下单
            createVoucherOrder(voucherOrder);
            // 5.确认消息 XACK stream.orders g1 id
            stringRedisTemplate.opsForStream()
                    .acknowledge(queueName, "g1", record.getId());
        } catch (Exception e) {
            log.error("处理pending订单异常", e);
            try {
                Thread.sleep(20);
            } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }
}

新消息读取使用最后消费位置,pending-list 恢复使用 ID 0,这两个读取入口不能混淆。前者负责接收尚未分配给消费者的新消息,后者负责重新处理已经分配但没有确认的消息。

5.5 Stream 版本的完整调用链

改造后的订单链路可以按下面的顺序理解:

  1. 请求线程生成订单 ID,并调用 Lua 脚本;
  2. Lua 检查 Redis 库存和用户下单集合;
  3. 校验通过后,Lua 原子扣减库存、记录用户并向 stream.orders 写入订单消息;
  4. 请求线程立即返回订单 ID;
  5. 后台消费者从 g1 消费组读取订单消息;
  6. 消费者把消息字段转换成 VoucherOrder,获取用户维度的 Redisson 锁;
  7. 查询数据库订单、扣减数据库库存、保存订单;
  8. 数据库处理成功后执行 XACK;
  9. 处理异常时保留 pending 状态,后续从 pending-list 继续消费。

这里 Redis 的库存预扣和数据库的最终扣库存各自承担不同职责:Redis 负责在高并发入口快速筛掉库存不足和重复请求,数据库负责最终落库。数据库更新仍然带有 stock > 0 条件,避免异步消费过程中把库存扣成负数。

总结

秒杀优化的第一步是把库存和一人一单判断放到 Redis Lua 中,以原子方式快速完成资格校验;第二步是把数据库下单从请求线程中移走,先用 BlockingQueue 验证异步处理流程;第三步是使用 Redis Stream 消费组替代 JVM 阻塞队列,让订单消息脱离单个应用实例的内存,并通过 pending-list 和 XACK 支持异常恢复。

阻塞队列适合说明异步模型,但受 JVM 内存限制,应用重启还会丢消息。Redis Stream 把消息存储、消费位置、待确认消息和恢复流程放在 Redis 中,当前项目的 stream.orders、g1 和 c1 正好对应这条异步下单链路。

相关推荐
Wx-bishekaifayuan1 小时前
springboot户外登山社交小程序19787-计算机课程设计、毕业设计
spring boot·后端·python·spring·elasticsearch·django·课程设计
+VX:Fegn08951 小时前
计算机毕业设计|基于springboot + vue外卖点餐系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
FYKJ_20101 小时前
springboot刑事案件管理系统03047-计算机课程设计、毕业设计
vue.js·spring boot·python·mysql·typescript·spark·django
小辰爱喝汤2 小时前
新闻管理系统|SpringBoot + Vue 毕业设计完整方案
spring boot·毕业设计·课程设计
FYKJ_20103 小时前
springboot助农产品销售商城05829-计算机课程设计、毕业设计
java·spring boot·python·mysql·spark·django
Ticnix3 小时前
RAG 烂大街?烂大街的只是那条流水线——真正的分水岭在这五处
后端·python·agent
Wx-bishekaifayuan4 小时前
springboot社区扶贫救助管理系统13300-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
DolphinDB4 小时前
数据库自带 Agent:10 分钟搭起 DolphinX-Web 数据入库与分析框架
后端·架构
弈栈录4 小时前
基于 Spring Boot 构建生产级 AI 应用平台
后端·面试·架构