Redis实战篇3:优惠券秒杀

说明

        该实战篇基于某马的Redis课程中的《某马点评项目》。非常适合有相关经验、缺少企业级解决方案,或者想要复习的人观看,全篇都会一步一步的推导其为什么要这么做,分析其优缺点,达到能够应用的地步。

        本实战篇中心思想就是把项目中的实战抽象成一个个的知识点进行讲解,让初学者达到举一反三的地步而不是只会照着视频敲代码而不去独立思考为什么要这么做。

        关于项目代码请移步到 某马程序员公众号,回复Redis获取。

一、全局唯一ID生成器 

对于一些敏感表的数据,我们的ID尽可能的需要复杂,没有固定的逻辑与规律,并且不重复 。全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息,我们的ID采用Long,8个字节,64个bit。

我们再生产ID的时候只需要干三件事:生成时间戳,生成序列号,然后组合。

 时间戳是一个31位的数组,他的单位是秒,一般来讲就是有一个基础时间值,然后用当前时间-基础时间得到。

  1. 生成基础时间
    1. //生成一个基础秒数时间
      public static void main(String[] args) {
          LocalDateTime baseTime = LocalDateTime.of(2024, 05, 20, 0, 0, 0);
          //toEpochSecond(ZoneOffset.UTC) 获取秒数(时区)
          long second = baseTime.toEpochSecond(ZoneOffset.UTC);
          System.out.println(second);//1716163200
      }
  2. 生成时间戳
    1. private static final long BEGIN_TIMESTAMP = 1716163200L;
      /**
       * 全局唯一ID生成器
       * @param keyPrefix
       * @return
       */
      public long nextId(String keyPrefix){
          // 1. 生产时间戳
          LocalDateTime localDateTime = LocalDateTime.now();
          // 得到当前的秒数
          long nowSecond = localDateTime.toEpochSecond(ZoneOffset.UTC);
          // 用当前秒数 — 基础时间秒数
          long timestamp = nowSecond - BEGIN_TIMESTAMP;
          return null
      }
  3. 生成序列号,序列化采用自增的方式前面是键,.increment("icr:" + keyPrefix + ":" + localDateStr)只是键名,其每一天都会从 1 开始往上自增。效果如图所示(第二次运行)
    1. public long nextId(String keyPrefix) {
          // 1. 生产时间戳
          LocalDateTime localDateTime = LocalDateTime.now();
          // 得到当前的秒数
          long nowSecond = localDateTime.toEpochSecond(ZoneOffset.UTC);
          // 用当前秒数 — 基础时间秒数
          long timestamp = nowSecond - BEGIN_TIMESTAMP;
          // 2. 生产序列号
          // 获取当前日期 精确到天
          DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy:MM:dd");
          String localDateStr = LocalDate.now().format(fmt);
          System.out.println("localDateStr"+localDateStr);
          Long increment = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + localDateStr);
          System.out.println("increment"+increment);
          // 3. 拼接并且返回 一个全局ID为 时间戳+序列号
          return timestamp << 32 | increment;
      }
  4. 测试
    1. @Test
      void test22(){
          RedisIdWorker worker = new RedisIdWorker(stringRedisTemplate);
          long jls = worker.nextId("jls");
          System.out.println(jls);
      }
    2. 第一次运行

    3. 第二次运行

二、优惠券秒杀

优惠券往往是一人一卷,而秒杀通常伴随着开始时间和结束时间,只有在时间范围之内才可以进行抢购,而且库存要充足。
先来看一下基本逻辑
 

2.1 库存超卖问题 

根据上述理论,如果有并发执行抢购,大家都判断到了库存是否充足一步,此时就会出现问题,例如还剩最后一个库存,此时有两个线程同时检测到了库存充足,那么就都会进行扣减库存的步骤,从而使得库存变为-1,这是不行的。

 

这就提到了多线程编程中的多线程安全问题,对应这一问题的办法就是加锁:悲观锁与乐观锁 

 

2.2 乐观锁方案

用库存替代版本

我们之间用CAS法解决库存超卖问题

@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
    // 1. 查询优惠卷
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2. 判断秒杀是否开始 LocalDateTime.now:2024-05-16T15:18:44.718 年月日时分秒
    if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
        // 秒杀尚未开始
        return Result.fail("秒杀尚未开始");
    }
    // 3. 判断结束时间是否结束
    if(voucher.getEndTime().isBefore(LocalDateTime.now())){
        // 秒杀结束
        return Result.fail("秒杀已经结束");
    }
    // 4. 判断库存是否充足
    if(voucher.getStock()<1){
        return Result.fail("库存不足");
    }
    /**
     * 乐观锁 判断现在查到的库存值与之前获取的库存值是否相同
     */
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock-1")
            .eq("voucher_id", voucherId)
            .gt("stock", 0) //乐观锁
            .update();
    if (!success) {
        return Result.fail("库存扣减失败,库存不足");
    }
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1 订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2 用户ID
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3 代金券ID
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    // 7. 返回订单ID
    return Result.ok(orderId);
}

 

2.3 一人一单问题 

        Long userID = UserHolder.getUser().getId();
        int count = query().eq("user_id", userID)
                .eq("voucher_id", voucherId).count();
        if (count > 0) {
            // 如果>0 证明用户已经购买过了
            return Result.fail("你已经下过此订单了!");
        }

只需要在扣减库存之前判断一下这个人下没下过订单即可。

但是这个如果继续用乐观锁是一定会有问题的。 
为什么?

因为与订单一样,一个人使用工具等插件在很短的时间内疯狂的请求,则还会出现多线程并发问题,在执行查询该用户是否下单时可能会有多个查询共同查询到无订单从而下单成功。而这是查询问题,不是添加问题,而且还是一个人的查询问题,所以这里使用悲观锁。

2.4 基于悲观锁解决一人一单问题

第一个问题,锁要加在哪里?

如果把锁加在类上,那这个类执行时就会发生,张三下订单,获取锁,李四就不能下订单,得等锁释放,这很明显不是我们需要的,我们希望张三下订单,获取锁,之后张三就不能下订单,但是李四可以下订单并且获得一把锁,这就需要我们将锁加在用户ID上,这样保证一个用户一把锁,并且用户之间没有串行。 

 悲观锁函数:

/**
 * 加悲观锁
 * @param voucherId
 * @return
 */
@Transactional
public  Result createVuchorOther(Long voucherId) {
    Long userID = UserHolder.getUser().getId();
    int count = query().eq("user_id", userID)
            .eq("voucher_id", voucherId).count();
    if (count > 0) {
        // 如果>0 证明用户已经购买过了
        return Result.fail("你已经下过此订单了!");
    }
    // 5. 扣减库存 乐观锁形式
    /**
     * 乐观锁 判断现在查到的库存值与之前获取的库存值是否相同
     */
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock-1")
            .eq("voucher_id", voucherId)
            .gt("stock", 0) //乐观锁
            .update();
    if (!success) {
        return Result.fail("库存扣减失败,库存不足");
    }
    // 6. 创建订单 一人最多一单

    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1 订单ID
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2 用户ID
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3 代金券ID
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    // 7. 返回订单ID
    return Result.ok(orderId);
}

 调用:

@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
    // 1. 查询优惠卷
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2. 判断秒杀是否开始 LocalDateTime.now:2024-05-16T15:18:44.718 年月日时分秒
    if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
        // 秒杀尚未开始
        return Result.fail("秒杀尚未开始");
    }
    // 3. 判断结束时间是否结束
    if(voucher.getEndTime().isBefore(LocalDateTime.now())){
        // 秒杀结束
        return Result.fail("秒杀已经结束");
    }
    // 4. 判断库存是否充足
    if(voucher.getStock()<1){
        return Result.fail("库存不足");
    }
    //悲观锁
    Long userID = UserHolder.getUser().getId();
    synchronized(userID.toString().intern()) {
        // 判断用户是否下过订单 考虑多线程 只能用悲观锁
        // .intern()去字符串常量池里面找
        //获取事务
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVuchorOther(voucherId);
    }
}

 来讲一下这个: IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
为什么要这么写?

首先如果仅仅这么写:

那么就代表这个由this调用这个函数,我们知道如果事务想生效是需要他的代理对象,spring会自动的拿到这个类的代理对象来使得事务生效,而这里用this调用则拿到的是这个目标对象,所以事务有可能会失效。

解决方案就是拿到事务代理对象,AopContext.currentProxy()可以获得代理对象,强转为当前类的代理对象即可,再用代理对象调用函数即可完成事务。(在代理对象类中创建这个函数)

他还需要一个依赖:

以及启动项上的设置

再来说一下为什么要用intern(),看tostring源码,他也是新new 一个string 这样的话,即使是同样的ID,通过toString后,也是不同的对象,那就做不到同样用户ID同一把锁了,同一个对象同一个请求后有不同的锁,通过intern后会去线程池上面找。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值