day4_redis中分布式锁实现一人一单

单体系统中实现一人一单

如果在秒杀过程中,需要实现一人一单的需求,那么这时候可以有2种方法:
①修改数据库的结构:因为需要实现一人一单的需求,那么只需要给订单表中的user_id和voucher_id添加联合的唯一约束即可,那么就可以实现一人一单的需求了。
②通过添加锁:在用户购买订单之前,判断是否可以获取到锁,如果可以,那么就可以进行判断是否已经下过单了,如果没有下过单,那么就可以进行购买商品的操作。对应的代码如下所示(createOrder方法是IVoucherOrderService接口的方法,而当前的createOrder则是子类VoucherOrderService重写这个父类的方法):

public Result seckillVoucher(Long voucherId) {
    
    
     //1、获取优惠券
     SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
     //1.1 获取开始时间以及结束时间
     LocalDateTime beginTime = seckillVoucher.getBeginTime();
     LocalDateTime endTime = seckillVoucher.getEndTime();
     if(beginTime.isAfter(LocalDateTime.now())){
    
    
         return Result.fail("秒杀尚未开始");
     }
     if(endTime.isBefore(LocalDateTime.now())){
    
    
         return Result.fail("秒杀已经结束");
     }
     //2、获取这个优惠券的库存
     Integer stock = seckillVoucher.getStock();
     if(stock < 1){
    
    
         return Result.fail("优惠券剩余0张");
     }
     return createOrder(voucherId);
 }
 @Transactional
 public synchronized Result createOrder(Long voucherId) {
    
    
     //3、判断这个用户是否已经购买过这个商品了(只需要查询tb_voucher_order表中存在这个user_id,voucher_id的
     //行数即可,如果count>0,说明已经买过了,否则没有
     int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
     if(count > 0){
    
    
         return Result.fail("每个用户限购1次");
     }
     //4、更新库存,防止在库存变成负数的时候,先生成订单,然后在更新库存
     boolean isUpdate = seckillVoucherService.update(new UpdateWrapper<SeckillVoucher>().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0));

     /*
     这个代码和上面代码是一样的
     boolean isUpdate = seckillVoucherService.update()
                 .setSql("stock = stock - 1")
                 .eq("voucher_id", voucherId)
                 .gt("stock", 0) //只要stock大于0,就进行操作
                 .update();
     */
     if(!isUpdate){
    
    
         return Result.fail("优惠券剩余0张");
     }
     //4、进行秒杀操作,生成订单
     VoucherOrder voucherOrder = new VoucherOrder();
     Long orderId = redisWorker.nextId("order");
     voucherOrder.setId(orderId);
     voucherOrder.setVoucherId(voucherId);
     voucherOrder.setUserId(UserHolder.getUser().getId());
     voucherOrderService.save(voucherOrder);
     return Result.ok(orderId);
 }

尽管这个代码可以实现我们的需求,但是会存在性能的问题,因为是对createOrder整个方法添加锁,那么只要有一个用户正在下订单,那么其他所有的不同用户都不可以下单了,从而导致所有其他不同用户都需要等待当前的用户执行完毕之后才可以执行,从而影响了效率
因此我们锁的对象不应该是这个方法,而是createOrder中的代码,因此应该是一个同步代码块,那么而锁的对象则是不同的用户id,这样就可以保证了不同的用户之间是互不影响的,所以createOrder代码优化之后应该是下面的样子:

public Result createOrder(Long voucherId) {
    
    
   Long userId = UserHolder.getUser().getId();
   synchronized(userId.toString().intern()){
    
    
       //3、判断这个用户是否已经购买过这个商品了(只需要查询tb_voucher_order表中存在这个user_id,voucher_id的
        //行数即可,如果count>0,说明已经买过了,否则没有
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if(count > 0){
    
    
            return Result.fail("每个用户限购1次");
        }
        //4、更新库存,防止在库存变成负数的时候,先生成订单,然后在更新库存
        boolean isUpdate = seckillVoucherService.update(new UpdateWrapper<SeckillVoucher>().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0));

        /*
        这个代码和上面代码是一样的
        boolean isUpdate = seckillVoucherService.update()
                    .setSql("stock = stock - 1")
                    .eq("voucher_id", voucherId)
                    .gt("stock", 0) //只要stock大于0,就进行操作
                    .update();
        */
        if(!isUpdate){
    
    
            return Result.fail("优惠券剩余0张");
        }
        //4、进行秒杀操作,生成订单
        VoucherOrder voucherOrder = new VoucherOrder();
        Long orderId = redisWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setUserId(userId);
        voucherOrderService.save(voucherOrder);
        return Result.ok(orderId);
   } 
}

而为了保证用户id的值相同,就是同一个锁,所以锁对象是userId.toString().intern()

但是上面的方法依旧是存在问题的,因为如果同一个用户对同一个商品发送多个请求,所以就存在了多个线程t1, t2,那么这时候如果t1刚刚执行同步代码块之后,释放锁,但是还没有来得及提交事务,这时候t2因为可以获取到了锁,就会判断这个用户是否已经下单了,由于t1还没有提交事务,所以t2查询得知这个用户还没有下单,就会继续下单,最后导致了同一个用户下了多单。

所以我们还需要保证只有在提交了事务之后,才可以继续判断是否可以获取锁,因此需要在seckillOrder中调用方法createOrder的时候,添加同步锁。再次更新为:

public Result seckillVoucher(Long voucherId) {
    
    
     /**
     -------------
     代码同上
     -------------
     */
     Long userId = UserHolder.getUser().getId();
     synchronized(userId.toString().intern()){
    
    
         return createOrder(voucherId);
     }
 }
 @Transactional 
 public Result createOrder(Long voucherId) {
    
    
     //3、判断这个用户是否已经购买过这个商品了(只需要查询tb_voucher_order表中存在这个user_id,voucher_id的
     //行数即可,如果count>0,说明已经买过了,否则没有
     int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
     if(count > 0){
    
    
         return Result.fail("每个用户限购1次");
     }
     //4、更新库存,防止在库存变成负数的时候,先生成订单,然后在更新库存
     boolean isUpdate = seckillVoucherService.update(new UpdateWrapper<SeckillVoucher>().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0));
     if(!isUpdate){
    
    
         return Result.fail("优惠券剩余0张");
     }
     //5、进行秒杀操作,生成订单
     VoucherOrder voucherOrder = new VoucherOrder();
     Long orderId = redisWorker.nextId("order");
     voucherOrder.setId(orderId);
     voucherOrder.setVoucherId(voucherId);
     voucherOrder.setUserId(UserHolder.getUser().getId());
     voucherOrderService.save(voucherOrder);
     return Result.ok(orderId);
 }

但是这时候依旧没有办法实现如果在执行createOrder方法的时候,如果中间出现了异常,会执行事务回滚,所以需要利用代理对象,而要使用代理对象,首先需要导入依赖aspectjweaver,

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

然后再启动类中添加注解@EnableAspectJAutoProxy(exposeProxy = true),此时再seckillOrder中的同步代码块中,就可以通过代理对象调用方法createOrder方法了:

synchronized (userId.toString().intern()){
    
    
            IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
            return proxy.createOrder(voucherId);
}

集群中实现一人一单

如果在集群的环境中,如果通过上面第一种方式,可以解决集群中一人一单的问题,但是如果采用的是第二种方法则不可以,因为在不同的JVM中,就存在了不同的锁,尽管锁的名字是相同的,但是因为是在不同的JVM中,所以导致了两者是毫无关联的,因此再次导致同一个用户可能下多单的情况,而要解决这一种问题,就需要将不同虚拟机中的锁放到同一个地方管理,这样不管是在同一台虚拟机也好,还是不同虚拟机,都是从同一个锁监视器中获取的,这样就可以实现了我们的需求,这就出现了分布式锁。
分布式锁,是为了解决在集群环境下多线程互斥的问题,而要实现分布式锁,可以采用下面几种方式:
在这里插入图片描述
但是采用Redis中的setnx命令设置互斥锁的时候,需要考虑如果某一个线程在执行某一个任务,但是这时候服务器突然发生了宕机,那么就导致了这个线程不可以释放这个锁了,而其他的线程也就没有办法获取这个锁了,因此为了避免这种问题,就需要给这个锁添加一个过期时间,一旦到期就需要释放锁。而这个过期时间也是需要注意的,不可以过短,防止线程没有执行完任务就已经释放锁了,同时也不可以过长,防止线程执行完任务之后都不可以及时释放锁

所以对应的代码为:

public interface ILock {
    
    
   public boolean tryLock(long timeout);
   public void unlock();
}

public class RedisLock implements ILock {
    
    
   private StringRedisTemplate stringRedisTemplate;
   /**
    * key的前缀
    */
   private final String LOCK_PREFIX = "hm_dianping:lock:";
   /**
    * key的名字
    */
   private String name;
   public RedisLock(String name, StringRedisTemplate stringRedisTemplate) {
    
    
      this.stringRedisTemplate = stringRedisTemplate;
      this.name = name;
   }

   @Override
   public boolean tryLock(long timeout) {
    
    
       long threadId = Thread.currentThread().getId();//线程标识
       Boolean isTrue = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + name, threadId + "", timeout, TimeUnit.SECONDS);
       //因为上面setIfAbsent方法的返回值可能是null,所以如果自动拆箱的时候,
       // 就会发生报错,所以需要利用equals方法
       return Boolean.TRUE.equals(isTrue);
   }

   @Override
   public void unlock() {
    
    
       stringRedisTemplate.delete(LOCK_PREFIX + name);
   }
}

加锁之后,那么seckillOrder的代码就变成了:

@Override
   public Result seckillVoucher(Long voucherId) {
    
    
       //1、获取优惠券
       SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
       //1.1 获取开始时间以及结束时间
       LocalDateTime beginTime = seckillVoucher.getBeginTime();
       LocalDateTime endTime = seckillVoucher.getEndTime();
       if(beginTime.isAfter(LocalDateTime.now())){
    
    
           return Result.fail("秒杀尚未开始");
       }
       if(endTime.isBefore(LocalDateTime.now())){
    
    
           return Result.fail("秒杀已经结束");
       }
       //2、获取这个优惠券的库存
       Integer stock = seckillVoucher.getStock();
       if(stock < 1){
    
    
           return Result.fail("优惠券剩余0张");
       }
       Long userId = UserHolder.getUser().getId();
       //分布式锁,实现一人一单
       ILock lock = new RedisLock("vocherOrder:" + userId, stringRedisTemplate);
       boolean isLock = lock.tryLock(1200L);
       if(!isLock){
    
    
           //如果没有获取到互斥锁
           return Result.fail("一人限购一单,不可重复购买");
       }
       try {
    
    
           IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
           return proxy.createOrder(voucherId, userId);
       }finally {
    
    
           //释放锁
           lock.unlock();
       }
   }

但是这样还是存在问题,就是可能存在误删分布式锁的问题,如下所示:
在这里插入图片描述
而要解决误删分布式锁的问题,那么只需要在删除分布式锁的时候,判断当前这个线程是否可以进行删除操作,也即是每一个线程都有自己的锁,只有当前这个线程才有资格去进行删除操作,否则不可以进行删除操作。这就是为什么上面设置key得知是进程的idThread.currentThread().getId(),这样在删除key的时候,就先获取这个key中的值,然后再根据当前需要执行删除key操作的线程的id进行比较,如果相等,说明这个key就是当前这个线程创建的,所以当前的线程可以进行删除操作,否则不可以
所以RedisLock中的unlock方法修改之后应该是这样的:

public void unlock() {
    
    
        //获取key对应的值,也即创建锁的线程id
        String cache_id = stringRedisTemplate.opsForValue().get(LOCK_PREFIX + name);
        //获取当前线程的id
        String currentThreadId = Thread.currentThread().getId() + "";
        if(currentThreadId.equals(cache_id)){
    
    
            //当前的key就是当前的线程创建的,那么可以进行删除key操作
            stringRedisTemplate.delete(LOCK_PREFIX + name);
        }

    }

但是在每一台JVM中,线程ThreadId可能会相同,那么同一个用户依旧可能会存在误删分布式锁的情况,所以key的值就是UUID.random().toString(true) + ThreadId,从而再次降低了误删分布式锁的几率了,所以最终的RedisLock代码应该是:

private StringRedisTemplate stringRedisTemplate;
    /**
     * key的前缀
     */
    private final String LOCK_KEY_PREFIX = "hm_dianping:lock:";
    /**
     * key的名字
     */
    private String name;
    /**
     * key的值的前缀
     * toString为true,就会将产生的随机字符串的'-'符删除
     */
    private String LOCK_VALUE_PREFIX = UUID.randomUUID().toString(true);
    
    public RedisLock(String name, StringRedisTemplate stringRedisTemplate) {
    
    
       this.stringRedisTemplate = stringRedisTemplate;
       this.name = name;
    }

    @Override
    public boolean tryLock(long timeout) {
    
    
        String threadId = LOCK_VALUE_PREFIX + Thread.currentThread().getId();//线程标识
        Boolean isTrue = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY_PREFIX + name, threadId, timeout, TimeUnit.SECONDS);
        //因为上面setIfAbsent方法的返回值可能是null,所以如果自动拆箱的时候,
        // 就会发生报错,所以需要利用equals方法
        return Boolean.TRUE.equals(isTrue);
    }

    @Override
    public void unlock() {
    
    
        //获取key对应的值,也即创建锁的线程id
        String cache_id = stringRedisTemplate.opsForValue().get(LOCK_KEY_PREFIX + name);
        //获取当前线程的id
        String currentThreadId = LOCK_VALUE_PREFIX + Thread.currentThread().getId();
        if(currentThreadId.equals(cache_id)){
    
    
            //当前的key就是当前的线程创建的,那么可以进行删除key操作
            stringRedisTemplate.delete(LOCK_KEY_PREFIX + name);
        }

    }

但是这时候依旧存在分布式锁被误删的情况,如图所示:
在这里插入图片描述
所以需要保证释放锁时的原子性操作,也即判断是否有资格释放锁以及释放锁这2个动作是原子性的,要么全部执行失败,要么全部执行成功。所以这时候通过Lua脚本来保证操作的原子性的,其中在Redis中需要定义Lua脚本,是通过redis.call('命令', key),例如如果需要定义一个key,并且它的值为Jack,那么对应语句就是redis.call('set','name','Jack')。而如果Redis需要执行Lua脚本,则是通过eval lua脚本 key的个数 参数个数,如下所示:
在这里插入图片描述
而在Java 客户端中,如果需要通过RedisTemplate或者StringRedisTemplate来执行Lua脚本,那么我们需要先创建一个Lua文件,然后通过StringRedisTemplate调用execute方法来执行。
而我们释放锁的操作是如果当前线程标识就是key对应的值,那么当前的线程就有资格释放锁,否则没有。所以Lua脚本传递的参数就是当前线程的标识,然后在Lua中需要判断key中的值是否和传递的参数相同即可,如果相同,则释放锁,即执行redis.call('del',key)操作
所以上面释放锁的代码为:

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static{
    
    
    UNLOCK_SCRIPT = new DefaultRedisScript();
    //定义的Lua脚本
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
    
    
    //通过lua脚本来保证分布式锁释放的原子性
    stringRedisTemplate.execute(UNLOCK_SCRIPT,
            Collections.singletonList(LOCK_KEY_PREFIX + name), // 执行操作的key的名字
            LOCK_VALUE_PREFIX + Thread.currentThread().getId()); //参数
}

定义好的unlock.lua文件如下所示(如果没有看到lua后缀的文件,那么需要给IDEA添加插件):

 -- 利用Lua脚本来保证分布式锁释放的原子性
 -- 1、获取需要删除的锁的名字 ----> 传递进来的KEYS[1]
 -- 2、先获取缓存中锁对应的的进程标识(也即这个锁是属于哪一个进程的)
 -- 3、获取当前释放锁的进程标识 ----> 传递进来的ARGV[1]
 -- 4、比较2,3中得到的值是否相同,如果相同,可以进行删除操作,否则返回0
 if(redis.call('get', KEYS[1]) == ARGV[1]) then
      return redis.call('del', KEYS[1])
 end
 -- 没有执行删除操作,那么返回0,标识删除的key个数为0
 return 0

至此可以完成了集群环境中分布锁,此时就可以避免在集群环境中重复购买商品的情况,从而实现一人一单。

猜你喜欢

转载自blog.csdn.net/weixin_46544385/article/details/127777126