用redis项目练习笔记,跟着黑马敲,并有自己的理解在里面

发布时间 2023-05-28 18:01:40作者: letfly

点评中,优惠卷牵扯到的秒杀问题。

超卖现象

如果多线程同时执行会因为高并发,先查询 再插入之间会有空档时间,发生超卖问题。可以使用悲观锁或者乐观锁解决,出于对性能的考虑,用到了乐观锁。

乐观锁的实现,用到了数据库where语句 多加一个条件。 每次判断跟上次相同,(这样会造成大量的失败问题)

于是引出,用库存>0做判断。(但是其实这样用到的也是类似悲观锁的mysql的行锁。

 

乐观锁
  • 假定数据一般情况下不会被其他线程修改,所以不会事先上锁。

  • 访问数据时,判断数据是否被其他线程修改,如果未修改则正常访问,如果被修改则采取其他措施(重试、报错等)。

  • 这种方式可以提高性能,但可能会造成数据的不一致性,需要容错措施。

悲观锁
  • 假定将要访问的数据一定会被其他线程修改,所以在访问数据之前会先加锁,阻止其他线程访问。

  • 加锁后,才访问数据,使用完成后解锁,释放锁。

  • 这种方式可以保证加锁段的原子性,但锁的粒度过大会影响性能。

一人一单

超卖的现象解决之后,又遇到了,每个人会买很多单。为了杜绝黄牛现象,使用一人一单,在每次查询数据库库存的时候单独查询一次下单的订单表有没有相同id的用户

 int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
 if (count>0) return Result.fail("不能重复购买");

使用悲观锁 让一个人只能买一单

这里锁的条件 如果只用toString 是锁不住的 ,因为 直接调用toString()方法,返回该userId对象的字符串表示。返回的字符串对象是新创建的,不是字符串常量池中的对象。每次调用都会创建一个新字符串对象,这些对象在JVM中是不同的对象,有不同的标识。

intern()方法会返回字符串常量池中的字符串对象。如果池中已经存在相同内容的字符串,则返回池中的字符串对象,否则将此字符串对象加入池中,并返回该对象所以,相同内容的字符串,intern()返回的始终是同一对象

     Long userId = UserHolder.getUser().getId();
     synchronized (userId.toString().intern()){
         IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
         return createVoucherOrder(voucherId,userId);
    }
 }
 
 @Transactional
 public Result createVoucherOrder(Long voucherId,Long userId) {
 
     int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
     if (count>0) return Result.fail("不能重复购买");
 
     //扣库存
     seckillVoucherService.update().setSql("stock = stok - 1").eq("voucher_id", voucherId).update();
     //创建订单
     long orderid = redisWorker.nextId("order");
     VoucherOrder voucherOrder = VoucherOrder.builder()
            .id(orderid)
            .userId(userId)
            .voucherId(voucherId)
            .build();
     boolean save = save(voucherOrder);
 
     //返回订单ID
     return Result.ok(orderid);
 }

这里把createVoucherOrder 封装成了单独的一个方法,但是 调用它的方法并没有开启事物,这就造成了可能事物不生效。于是要使用代理对象调用。

 IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
 return proxy.createVoucherOrder(voucherId,userId);

这里用到了AspectJ的 AopContext 对象 找到当前的代理对象 强制转换成对应的接口,并调用接口中定义好的方法。相当于调用的动态代理生成后的方法。可以解决事物生效问题。

 

但是由于业务需要 ,又扩展了一台服务器,这就牵扯到服务器集群 不同jvm环境 不同常量池的问题 就引出了 分布式锁

分布式锁

这里实现分布式锁使用Redis的SETNX。 实现分布式锁需要实现两个基本方法

获取锁
 @Override
 public boolean tryLock(Long timeoutSec) {
    String name1 = Thread.currentThread().getName();
    //获取锁
    Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name,name1,timeoutSec, TimeUnit.MINUTES);
    return Boolean.TRUE.equals(success);
 }

这里用到一个亮点 自动拆箱与自动装箱 如果这里的布尔值获取不到,可能会出现空指针异常,所以,直接用封装类的判断方法直接去判断。

释放锁
 @Override
 public void unlock() {
     //释放锁
     stringRedisTemplate.delete(KEY_PREFIX+name);
 }
极端情况下产生的问题:

衍生出另一个问题,如果Thread1获取到锁之后,业务阻塞足够长时间,到锁超时释放也没有变成运行状态,这时候Thread2趁虚而入,又获取到锁,但是这时候Thread1业务完成了,把锁刚好释放了,Thread3又拿到了锁。又导致线程安全问题。

一个思路:在每次释放锁的时候,判断是不是自己的锁,是自己的再释放,不是自己的就不管了。

 @Override
 public void unlock() {
     //获取线程标识
     String threadId = ID_PREFIX+Thread.currentThread().getName();
     String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
     //判断标识是否一致
     if (threadId.equals(id)){
         //释放锁
         stringRedisTemplate.delete(KEY_PREFIX+name);
    }
 }

但是这样的话,判断锁标识和释放锁是两个动作,这两个动作之间产生了阻塞,会造成redis中定义的锁超时释放,Thread2趁虚而入,这时阻塞状态刚好结束,继续执行释放锁的方法,那么会造成之前的问题。如下图

 

解决这个问题,我们使用Redis的lua脚本 ,把几条指令封装成一个批处理程序,原子性的同时执行。

 --比较线程标识与锁中的标识是否一致
 if(redis.call('get',KEY[1]) == ARGV[1]) then
     --释放锁
     return redis.call('del',KEY[1])
 end
 return 0
 

然后利用stringRedisTemplate 执行这段代码

 //先把代码加载到类中
     private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
 
     static {
         UNLOCK_SCRIPT = new DefaultRedisScript<>();
         UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
         UNLOCK_SCRIPT.setResultType(Long.class);
    }
 
 public void unlock() {
     String threadId = ID_PREFIX+Thread.currentThread().getName();
 
     stringRedisTemplate.execute(UNLOCK_SCRIPT,
             Collections.singletonList(KEY_PREFIX+name),
             threadId);
 }

但是基于以上做的分布式锁中 有一些缺点, 不可重入,不可重试,超时释放,主从一致性

不可重入:同一个线程无法多次获取同一把锁。

不可重试:获取锁值尝试一次就返回false,没有重试机制。

超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患。

主从一致性:如果Redis提供了主从几圈,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现多个线程拿到锁。

 

为了解决这些问题,手写会非常麻烦,我们索性直接用框架解决问题Redisson

Redisson

Redisson,它不仅提供了一系列的分布式的java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现、

可重入锁的底层:当再一次获取锁发现锁被占用,会看看占用此锁的线程是不是自己,内部会有计数功能,看自己获取了多少次锁。

底层就是调用到了lua脚本实现。

重试:利用信号量和PubSub功能实现消息发送订阅, 等待、唤醒、获取锁失败的重试机制,其间会一直判断有没有超过已设置好的等待时间,如果超过返回false

超时延续:利用WacthDog 利用定时任务Timeout每隔一段时间 重置超时时间。 并且在释放锁的时候 获取到Timeout对象,clear掉。