Redis+Lua实现秒杀优化

本章核心的算法逻辑与代码实现主要围绕 异步秒杀优化 方案展开,其核心在于利用 Redis + Lua脚本 实现原子化的库存与一人一单校验,并通过 内存阻塞队列 + 异步线程 将数据库持久化操作与前端响应解耦。以下是基于博客内容进行的深度拆解。

一、 核心算法逻辑:基于 Redis 的预减库存与异步下单

整个优化方案的核心逻辑可以概括为"快速校验,异步落库"。其核心思想是将耗时短、确定性高的判断逻辑前移至内存数据库 Redis 中执行,从而在毫秒级内响应用户请求,而将复杂的数据库事务操作(如创建订单、扣减库存)交由后台线程异步处理。

该流程包含两个关键阶段,其架构演进对比如下:

处理阶段 同步方案(优化前) 异步方案(优化后) 核心优化点
请求入口 Nginx -> Tomcat Nginx -> Tomcat 无变化
核心校验 在 Tomcat 中串行执行: 1. 查询优惠券 2. 查库存 3. 查订单 4. 一人一单校验 5. 扣库存 6. 创建订单 在 Redis 中原子化执行 Lua 脚本: 1. 判断库存 (stock:vid:) 2. 判断用户是否下单 (order:vid: ) 3. 扣减库存并记录用户 校验逻辑前移、原子化操作
响应时机 所有数据库操作完成后,才返回结果给用户 Lua 脚本执行成功后(约毫秒级),立即返回订单ID给用户 响应速度提升数个数量级
数据持久化 同步在主线程中完成 异步:将订单信息放入内存阻塞队列,由独立线程池消费并写入数据库 请求处理与数据落库解耦
技术栈 MySQL 为主 Redis (Lua) + 内存队列 + MySQL 引入缓存与消息队列思想

二、 核心代码函数深度拆解

实现此逻辑的核心代码集中在 VoucherOrderServiceImpl 类中,主要包含三个部分:Lua脚本、秒杀入口函数、异步订单处理器。

1. Lua 脚本:seckill.lua (原子化校验的核心)

这是整个方案的技术基石,它保证了在高并发场景下,库存检查和用户下单记录的判断与修改是原子性的,避免了超卖和一人多单的问题 。

lua 复制代码
-- 参数列表
local voucherId=ARGV[1]   -- 优惠券id
local userId=ARGV[2]      -- 用户id
-- 构造缓存数据Key
local stockKey ='seckill:stock:' .. voucherId   -- 库存key
local orderKey ='seckill:order:' .. voucherId   -- 下单用户集合key
-- 1. 判断库存是否充足
if tonumber(redis.call('get', stockKey)) <= 0 then
    return 1  -- 库存不足
end
-- 2. 判断用户是否已下单 (SISMEMBER 命令检查Set中是否存在该用户)
if redis.call('sismember', orderKey, userId) == 1 then
    return 2  -- 用户已下单,禁止重复购买
end
-- 3. 执行扣减库存和记录用户操作
redis.call('incrby', stockKey, -1)  -- 库存减1
redis.call('sadd', orderKey, userId) -- 将用户ID加入已下单集合
return 0  -- 成功,允许下单

逻辑拆解:

  • 输入参数voucherId(商品ID)和 userId(用户ID),通过 ARGV 数组从Java传入。
  • 键构造 :动态拼接出库存键(如 seckill:stock:14)和订单用户集合键(如 seckill:order:14)。
  • 三步原子操作
    • 库存检查 :使用 get 命令获取库存值。若小于等于0,直接返回 1,流程终止。
    • 用户去重 :使用 sismember 命令检查用户ID是否存在于代表"已购买用户"的Set集合中。若存在,返回 2,流程终止。
    • 扣减与记录 :若前两步都通过,则顺序执行 incrby(库存-1)和 sadd(将用户加入集合)。这两个命令在同一个Lua脚本中执行,具备原子性。
  • 返回值0 表示成功, 1 表示库存不足, 2 表示重复下单。这个返回值是后续Java逻辑分支判断的依据。
2. 秒杀入口函数:seckillVoucher(Long voucherId)

此函数是前端请求的入口,负责调用Lua脚本并处理其返回结果,是同步响应部分的关键。

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    // 1. 获取用户ID(从ThreadLocal中)
    Long userId = UserHolder.getUser().getId();
    
    // 2. 执行Lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,          // 上一步加载的脚本对象
            Collections.emptyList(), // Keys列表,本例为空
            voucherId.toString(),    // ARGV[1]
            userId.toString()        // ARGV[2]
    );
    
    // 3. 解析脚本执行结果
    int r = result.intValue();
    if (r != 0) {
        // 3.1 脚本返回非0,代表失败
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    
    // 3.2 脚本返回0,代表有购买资格
    // 4. 生成全局唯一订单ID(使用雪花算法等)
    long orderId = redisIdWorker.nextId("order");
    
    // 5. 构造订单对象(此时订单并未存入数据库)
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(userId);
    voucherOrder.setVoucherId(voucherId);
    
    // 6. 将订单对象放入内存阻塞队列
    orderTasks.add(voucherOrder);
    
    // 7. 获取当前Service的代理对象(用于后续异步方法的事务管理)
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    
    // 8. 立即返回成功响应和订单ID给前端
    return Result.ok(orderId);
}

逻辑拆解:

  • 步骤1-2:获取用户上下文并调用Redis执行Lua脚本。这是整个流程中唯一与外部存储进行网络IO的同步操作,但因其在内存中执行,速度极快。
  • 步骤3:根据Lua脚本返回值进行分支判断。若失败,直接返回错误信息,流程结束。此处的错误(库存不足、重复购买)是在请求层面做出的最终判断,后续异步线程不会再处理这些无效请求。
  • 步骤4-6 :脚本执行成功(返回0)后的处理。生成订单ID、封装订单对象,并将其放入一个预定义的 BlockingQueue<VoucherOrder> orderTasks 中。此处的 add 操作是内存操作,速度极快,是同步流程的终点。
  • 步骤7-8:获取代理对象(为异步线程调用事务方法做准备)并立即向用户返回成功响应。此时,用户的购买请求在数十毫秒内已得到确认,而实际的数据库写入操作尚未开始。
3. 异步订单处理器:VoucherOrderHandler 内部类

这是一个实现了 Runnable 接口的内部类,在服务启动时由单线程线程池执行,负责消费阻塞队列中的订单信息并完成最终的数据库持久化,是异步落库部分的核心。

java 复制代码
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);
               // 此处应添加异常处理机制,如将失败订单转入死信队列或记录日志
           }
       }
   }
    
   private void handleVoucherOrder(VoucherOrder voucherOrder) {
       Long userId = voucherOrder.getUserId();
       // 使用分布式锁(或乐观锁)确保用户级别操作的幂等性,防止极端情况下的重复消费
       RLock lock = redissonClient.getLock("lock:order:" + userId);
       boolean isLock = lock.tryLock();
       if (!isLock) {
           log.error("不允许重复下单");
           return;
       }
       try {
           // 通过获取的代理对象调用事务方法,确保数据库操作的ACID特性
           proxy.createVoucherOrder(voucherOrder);
       } finally {
           lock.unlock();
       }
   }
}
// 在Service初始化后启动处理器线程
@PostConstruct
private void init() {
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

逻辑拆解:

  • 常驻循环while (true) 确保线程持续运行,监听队列。
  • 阻塞消费orderTasks.take() 是一个阻塞方法。当队列为空时,线程会挂起,不消耗CPU资源;当有新的订单对象被 seckillVoucher 方法放入队列时,线程被唤醒并取出订单。
  • 异步处理handleVoucherOrder 方法执行实际的数据库操作(查询优惠券、二次校验库存与一人一单、扣减库存、创建订单)。这部分是原同步流程中耗时最长的部分,现在被转移到后台异步执行,与用户请求线程完全分离。
  • 事务与锁
    • proxy.createVoucherOrder(voucherOrder):通过代理对象调用,是为了使 @Transactional 注解生效,保证数据库操作的原子性。
    • RLock lock = redissonClient.getLock(...):虽然Lua脚本在Redis层面保证了原子性,但此处加锁是针对 "同一个用户" 的额外防护。目的是防止在极端高并发下,由于网络延迟或队列消费速度问题,导致同一个用户的多个订单对象被同时处理。这是一个防御性编程和幂等性保障。
java 复制代码
@Transactional
public void creatVoucherOrder(VoucherOrder voucherOrder) {
    //5.一人一单
    Long userId = UserHolder.getUser().getId();
    //5.1查询订单
    int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getUserId()).count();
    //5.2判断是否存在
    if (count > 0){
        //用户购买过了
        log.error("您已经购买过一次了");
        return;
    }
    //6.扣减库存
    boolean success = seckillService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherOrder.getVoucherId())
            .gt("stock", 0).update();  // ✅ 核心:只要库存大于0就可以扣.update();   //乐观锁,cas法
    //6.1库存不足
    if (!success) {
        // 扣减失败
        log.error("库存不足!");
        return;
    }
    save(voucherOrder);
}

三、困惑解答

  1. 异步线程里为什么还要加锁?

    • 锁的作用只是:同一时间只让一个线程执行
    • 锁不负责"记住有没有执行过",只负责:同一时间,只能有一个线程进去
    • 线程1执行完 → 释放锁 → 线程2再来获取锁 → 能成功,这是正常的
  2. 那线程2进来会不会重复下单?

    • 不会!
    • 因为数据库有兜底判断:
      线程1执行完 → 数据库里已经有订单了。
      线程2即使拿到锁,一查 count > 0直接 return,什么都不做!
  3. 三层防御闭环(秒杀核心)

    • 第一层:Redis Lua → 绝对防重复入队
    • 第二层:分布式锁 → 防同一时间并发执行
    • 第三层:数据库幂等 → 绝对防重复创建订单
  4. 你担心的场景答案

    • 线程1执行完,线程2再执行 → 绝对不会超卖!
    • 因为数据库已经有订单,会被第三层拦住。

2. 为什么获取不到锁就视为"重复下单"?

  1. 锁 key 是 lock:order:userId同一用户同一时间只能一把锁生效
  2. 获取不到锁 = 已有线程在为该用户创建订单 = 当前请求属于重复执行 = 直接拒绝。
  3. 就算前一个线程执行完释放锁,后续线程拿到锁也没关系,数据库会兜底拦截

一句话口诀:
Lua 防重复入队,锁防并发执行,数据库防重复落库。

3. gt("stock", 0)count 的作用关系

  1. gt("stock", 0) 是乐观锁,作用是:防超卖

    • 保证库存 永远不会扣成负数
    • 解决的是:所有人总共不能多抢 的问题
  2. count > 0 是数据库幂等判断,作用是:防重复下单

    • 保证同一个用户 永远不能重复购买
    • 解决的是:同一个人不能重复抢 的问题
  3. 两者缺一不可,作用完全不同

    • 没有乐观锁 :库存会扣成负数,导致 超卖
    • 没有 count 判断 :同一个用户可以无限下单,导致 重复下单
  4. 最终总结

    • gt("stock", 0) 防"大家多抢"
    • count > 0 防"自己重复抢"

四、 架构价值与潜在风险分析

  • 性能提升:将响应时间从依赖多个数据库查询的数百/数千毫秒(如测试中的平均957ms),降低到依赖单次Redis调用(通常<1ms)的毫秒级,吞吐量大幅提升。
  • 可靠性保障:Lua脚本的原子性操作是防止超卖和重复购买的技术核心,比在应用层使用锁或事务更为高效和可靠。
  • 系统解耦 :前端响应与后端数据持久化通过内存队列解耦,提高了系统的可伸缩性和抗冲击能力。即使数据库暂时性能下降或短暂不可用,秒杀请求仍可被快速接受并排队。
    潜在风险与优化方向:
  1. 内存队列积压ArrayBlockingQueue 容量有限(博客中设置为 1024*1024)。在瞬时流量远超数据库处理能力时,队列可能被填满,导致新的秒杀请求失败。可考虑引入更强大的外部消息队列(如Kafka、RabbitMQ),具备持久化和更强大的堆积能力。
  2. 数据一致性延迟:用户下单后,数据库中的订单记录并非立即可查,存在短暂延迟。前端需设计"查询中"状态,并通过订单ID轮询后端直至成功。
  3. 故障恢复:内存队列中的数据在服务重启后会丢失。需要设计队列持久化或消费确认机制,确保消息不丢失。
  4. 脚本健壮性:Lua脚本未处理Redis命令执行失败(如网络中断)的异常情况,在实际生产环境中需要增强错误处理和重试机制。
相关推荐
弘毅 失败的 mian2 小时前
嵌入式系统观
数据库·经验分享·笔记·物联网·嵌入式
小年糕是糕手2 小时前
【35天从0开始备战蓝桥杯 -- Day7】
开发语言·jvm·数据库·c++·蓝桥杯
色空大师2 小时前
网站搭建实操(二)后台管理(1)登录
java·linux·数据库·搭建网站·论坛
ShineWinsu2 小时前
MySQL安全加固十大硬核操作:硬核防护指南
数据库·mysql·安全
骑龙赶鸭2 小时前
mysql binlog中QUERY_EVENT的status_var结构
数据库·mysql
Skilce8 小时前
ZrLog 高可用部署
运维·服务器·数据库·阿里云·maven
indexsunny10 小时前
互联网大厂Java求职面试实战:微服务与Spring生态全攻略
java·数据库·spring boot·安全·微服务·面试·消息队列
沪漂阿龙10 小时前
别再让数据库“吃”脏数据了!一文讲透MySQL约束,从入门到精通
数据库·mysql
2401_8735449212 小时前
使用Python进行PDF文件的处理与操作
jvm·数据库·python