Redis[3] 面试题:设计分布式锁+代码实现原生分布式锁

本文整理自小d课堂笔记和java进阶仓库,如有雷同,大部分是人家写的

java进阶仓库:https://doocs.github.io/advanced-java/#/

Redis[3] 面试题:设计分布式锁+代码实现原生分布式锁

分布式锁的用途就是保证同一时间只有一个客户端可以对共享资源进行操作

可以运用到优惠券领劵限制张数、商品库存超卖这些场景

为了防止分布式系统中的多个进程之间相互干扰,我们需要一种分布式协调技术来对这些进程进行调度

利用互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题

可以通过加锁的方式来实现:

  • 本地锁:synchronize、lock等,锁在当前进程内,集群部署下依旧存在问题
  • 分布式锁:redis、zookeeper等实现,虽然还是锁,但是多个进程共用的锁标记,可以用Redis、Zookeeper、Mysql等都可以

问题一 确保锁一定能释放

锁需要保证一定能得到释放,比如客户端奔溃或者网络中断

可以基于redis实现分布式锁

  • 加锁 SETNX key value
setnx 的含义就是 SET if Not Exists,有两个参数 setnx(key, value),该方法是原子性操作

如果 key 不存在,则设置当前 key 成功,返回 1;

如果当前 key 已经存在,则设置当前 key 失败,返回 0
  • 解锁 del (key)
得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入,调用 del(key)
  • 配置锁超时 expire (key,30s)
客户端奔溃或者网络中断,资源将会永远被锁住,即死锁,因此需要给key配置过期时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放

  • 综合伪代码
methodA(){
  String key = "coupon_66"

  if(setnx(key,1) == 1){ //1
      expire(key,30,TimeUnit.MILLISECONDS)//2 
      try {
          //做对应的业务逻辑
          //查询用户是否已经领券
          //如果没有则扣减库存
          //新增领劵记录
      } finally {
          del(key)
      }
  }else{

    //睡眠100毫秒,然后自旋调用本方法
    methodA()
  }
}

对于上面的代码

若1处的语句已完成,此时宕机,则无法设置过期时间,这个资源就会一直占有锁,锁无法得到释放

所以需要在加锁时就设置过期时间,要么加锁且设置过期时间,要么不加锁

解决方案为:
使用原子命令:设置和配置过期时间 setnx / setex

如: set key 1 ex 30 nx
java里面 redisTemplate.opsForValue().
setIfAbsent("seckill_1","success",30,TimeUnit.MILLISECONDS)

问题二:业务超时,存在其他线程误删

某项业务花费时间较长,当前线程a 未完成操作,线程b拿到了锁,过了一会线程a执行完毕,线程a会删除线程b加的锁。

解决方案:

可以在 del 释放锁之前做一个判断,验证当前的锁是不是自己加的锁, 那 value 应该是存当前线程的标识或者uuid


String key = "coupon_66"
String value = Thread.currentThread().getId()

if(setnx(key,value) == 1){
    expire(key,30,TimeUnit.MILLISECONDS)
    try {
        //做对应的业务逻辑
    } finally {
      //删除锁,判断是否是当前线程加的
      if(get(key).equals(value)){
          //还存在时间间隔  ------------- 1 
          del(key)
        }
    }
}else{
  
  //睡眠100毫秒,然后自旋调用本方法

}
  • 进一步细化误删
    • 当线程A获取到正常值时,返回带代码中判断期间锁过期了,线程B刚好重新设置了新值,线程A那边有判断value是自己的标识,然后调用del方法,结果就是删除了新设置的线程B的值
    • 核心还是判断和删除命令 不是原子性操作导致
  • 总结
    • 加锁+配置过期时间:保证原子性操作
    • 解锁: 防止误删除、也要保证原子性操作

问题三 :判断锁是否为自己加的锁和 删除锁之间存在时间间隔

解决方案:

多个命令的原子性:采用 lua脚本+redis, 由于【判断和删除】是lua脚本执行,所以要么全成功,要么全失败

  • 全部代码
/**
* 原生分布式锁 开始
* 1、原子加锁 设置过期时间,防止宕机死锁
* 2、原子解锁:需要判断是不是自己的锁
*/
@RestController
@RequestMapping("/api/v1/coupon")
public class CouponController {
    
    

    @Autowired
    private StringRedisTemplate redisTemplate;


    @GetMapping("add")
    public JsonData saveCoupon(@RequestParam(value = "coupon_id",required = true) int couponId){
    
    

        //防止其他线程误删
        String uuid = UUID.randomUUID().toString();

        String lockKey = "lock:coupon:"+couponId;

        lock(couponId,uuid,lockKey);

        return JsonData.buildSuccess();

    }


    private void lock(int couponId,String uuid,String lockKey){
    
    


        //lua脚本
        String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";

        Boolean nativeLock = redisTemplate.opsForValue().setIfAbsent(lockKey,uuid,Duration.ofSeconds(30));
        System.out.println(uuid+"加锁状态:"+nativeLock);
        if(nativeLock){
    
    
            //加锁成功

            try{
    
    
                //TODO 做相关业务逻辑
                TimeUnit.SECONDS.sleep(10L);

            } catch (InterruptedException e) {
    
    

            } finally {
    
    
                //解锁
                Long result = redisTemplate.execute( new DefaultRedisScript<>(script,Long.class),Arrays.asList(lockKey),uuid);
                System.out.println("解锁状态:"+result);

            }

        }else {
    
    
            //自旋操作
            try {
    
    
                System.out.println("加锁失败,睡眠5秒 进行自旋");
                TimeUnit.MILLISECONDS.sleep(5000);
            } catch (InterruptedException e) {
    
     }

            //睡眠一会再尝试获取锁
            lock(couponId,uuid,lockKey);
        }
    }


}

问题四:如何避免业务执行时间过长,锁过期了

锁的过期时间,如何实现锁的自动续期 或者 避免业务执行时间过长,锁过期了?

  • 原生方式的话,一般把锁的过期时间设置久一点,比如10分钟时间

实际运行效果截图

访问接口三次,三个线程都能拿到锁,且释放锁
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_41852212/article/details/121205868