Bootstrap

Redis实战篇《黑马点评》5

5.秒杀优化

5.1异步秒杀思路

  • 我们先来回顾一下下单流程

  • 当用户发起请求,此时会先请求Nginx,Nginx反向代理到Tomcat,而Tomcat中的程序,会进行串行操作,分为如下几个步骤

    1. 查询优惠券
    2. 判断秒杀库存是否足够
    3. 查询订单
    4. 校验是否一人一单
    5. 扣减库存
    6. 创建订单
  • 在这六个步骤中,有很多操作都是要去操作数据库的,而且还是一个线程串行执行,这样就会导致我们的程序执行很慢,所以我们需要异步程序执行,那么如何加速呢?

  • 优化方案:我们将耗时较短的逻辑判断放到Redis中,例如:库存是否充足,是否一人一单这样的操作,只要满足这两条操作,那我们是一定可以下单成功的,不用等数据真的写进数据库,我们直接告诉用户下单成功就好了。然后后台再开一个线程,后台线程再去慢慢执行队列里的消息,这样我们就能很快的完成下单业务。

  • 但是这里还存在两个难点

    1. 我们怎么在Redis中快速校验是否一人一单,还有库存判断
    2. 我们校验一人一单和将下单数据写入数据库,这是两个线程,我们怎么知道下单是否完成。
      • 我们需要将一些信息返回给前端,同时也将这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询下单逻辑是否完成
  • 我们现在来看整体思路:当用户下单之后,判断库存是否充足,只需要取Redis中根据key找对应的value是否大于0即可,如果不充足,则直接结束。如果充足,则在Redis中判断用户是否可以下单,如果set集合中没有该用户的下单数据,则可以下单,并将userId和优惠券存入到Redis中,并且返回0,整个过程需要保证是原子性的,所以我们要用Lua来操作,同时由于我们需要在Redis中查询优惠券信息,所以在我们新增秒杀优惠券的同时,需要将优惠券信息保存到Redis中

  • 完成以上逻辑判断时,我们只需要判断当前Redis中的返回值是否为0,如果是0,则表示可以下单,将信息保存到queue中去,然后返回,开一个线程来异步下单,其阿奴单可以通过返回订单的id来判断是否下单成功

5.2Redis完成秒杀资格判断

  • 需求:
    1. 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
    2. 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否秒杀成功
  • 步骤一:修改保存优惠券相关代码
@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);
    // 保存秒杀优惠券信息到Reids,Key名中包含优惠券ID,Value为优惠券的剩余数量
    stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString()); 
}
  • 使用PostMan发送请求,添加优惠券
    请求路径:http://localhost:8080/api/voucher/seckill
    请求方式:POST
    {
        "shopId":1,
        "title":"9999元代金券",
        "subTitle":"365*24小时可用",
        "rules":"全场通用\\nApex猎杀无需预约",
        "payValue":1000,
        "actualValue":999900,
        "type":1,
        "stock":100,
        "beginTime":"2022-01-01T00:00:00",
        "endTime":"2022-12-31T23:59:59"
    }
  • 添加成功后,数据库中和Redis中都能看到优惠券信息
  • 步骤二:编写Lua脚本
    lua的字符串拼接使用..,字符串转数字是tonumber()
-- 订单id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 优惠券key
local stockKey = 'seckill:stock:' .. voucherId
-- 订单key
local orderKey = 'seckill:order:' .. voucherId
-- 判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
    return 1
end
-- 判断用户是否下单
if (redis.call('sismember', orderKey, userId) == 1) then
    return 2
end
-- 扣减库存
redis.call('incrby', stockKey, -1)
-- 将userId存入当前优惠券的set集合
redis.call('sadd', orderKey, userId)
return 0

  • 修改业务逻辑
    @Override
    public Result seckillVoucher(Long voucherId) {
        //1. 执行lua脚本
        Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
                Collections.emptyList(), voucherId.toString(),
                UserHolder.getUser().getId().toString());
        //2. 判断返回值,并返回错误信息
        if (result.intValue() != 0) {
            return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
        }
        long orderId = redisIdWorker.nextId("order");
        //TODO 保存阻塞队列
    
        //3. 返回订单id
        return Result.ok(orderId);
    }

  • 现在我们使用PostMan发送请求,redis中的数据会变动,而且不能重复下单,但是数据库中的数据并没有变化
  • 5.3基于阻塞队列实现秒杀优化

  • 修改下单的操作,我们在下单时,是通过Lua表达式去原子执行判断逻辑,如果判断结果不为0,返回错误信息,如果判断结果为0,则将下单的逻辑保存到队列中去,然后异步执行
  • 需求
    1. 如果秒杀成功,则将优惠券id和用户id封装后存入阻塞队列
    2. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
    • 步骤一:创建阻塞队列
      阻塞队列有一个特点:当一个线程尝试从阻塞队列里获取元素的时候,如果没有元素,那么该线程就会被阻塞,直到队列中有元素,才会被唤醒,并去获取元素
      阻塞队列的创建需要指定一个大小
      private final BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

    • 那么把优惠券id和用户id封装后存入阻塞队列
      @Override
      public Result seckillVoucher(Long voucherId) {
          Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
                  Collections.emptyList(), voucherId.toString(),
                  UserHolder.getUser().getId().toString());
          if (result.intValue() != 0) {
              return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
          }
          long orderId = redisIdWorker.nextId("order");
          //封装到voucherOrder中
          VoucherOrder voucherOrder = new VoucherOrder();
          voucherOrder.setVoucherId(voucherId);
          voucherOrder.setUserId(UserHolder.getUser().getId());
          voucherOrder.setId(orderId);
          //加入到阻塞队列
          orderTasks.add(voucherOrder);
          return Result.ok(orderId);
      }

      步骤二:实现异步下单功能

    • 先创建一个线程池
      private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
    • 创建线程任务,秒杀业务需要在类初始化之后,就立即执行,所以这里需要用到@PostConstruct注解
      @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);
                  }
              }
          }
      }
    • 编写创建订单的业务逻辑
      private IVoucherOrderService proxy;
      private void handleVoucherOrder(VoucherOrder voucherOrder) {
          //1. 获取用户
          Long userId = voucherOrder.getUserId();
          //2. 创建锁对象,作为兜底方案
          RLock redisLock = redissonClient.getLock("order:" + userId);
          //3. 获取锁
          boolean isLock = redisLock.tryLock();
          //4. 判断是否获取锁成功         
          if (!isLock) {
              log.error("不允许重复下单!");
              return;
          }
          try {
              //5. 使用代理对象,由于这里是另外一个线程,
              proxy.createVoucherOrder(voucherOrder);
          } finally {
              redisLock.unlock();
          }
      }
    • 查看AopContext源码,它的获取代理对象也是通过ThreadLocal进行获取的,由于我们这里是异步下单,和主线程不是一个线程,所以不能获取成功
      private static final ThreadLocal<Object> currentProxy = new NamedThreadLocal("Current AOP proxy");
    • 但是我们可以将proxy放在成员变量的位置,然后在主线程中获取代理对象
      @Override
      public Result seckillVoucher(Long voucherId) {
          Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
                  Collections.emptyList(), voucherId.toString(),
                  UserHolder.getUser().getId().toString());
          if (result.intValue() != 0) {
              return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
          }
          long orderId = redisIdWorker.nextId("order");
          //封装到voucherOrder中
          VoucherOrder voucherOrder = new VoucherOrder();
          voucherOrder.setVoucherId(voucherId);
          voucherOrder.setUserId(UserHolder.getUser().getId());
          voucherOrder.setId(orderId);
          //加入到阻塞队列
          orderTasks.add(voucherOrder);
          //主线程获取代理对象
          proxy = (IVoucherOrderService) AopContext.currentProxy();
          return Result.ok(orderId);
      }

      5.4小结

    • 秒杀业务的优化思路是什么?

      1. 先利用Redis完成库存容量、一人一单的判断,完成抢单业务
      2. 再将下单业务放入阻塞队列,利用独立线程异步下单
    • 基于阻塞队列的异步秒杀存在哪些问题?

      1. 内存限制问题:
        • 我们现在使用的是JDK里的阻塞队列,它使用的是JVM的内存,如果在高并发的条件下,无数的订单都会放在阻塞队列里,可能就会造成内存溢出,所以我们在创建阻塞队列时,设置了一个长度,但是如果真的存满了,再有新的订单来往里塞,那就塞不进去了,存在内存限制问题
      2. 数据安全问题:
        • 经典服务器宕机了,用户明明下单了,但是数据库里没看到
;