分布式锁实现

分布式锁实现

分布式锁主流的实现方案:

  1. 基于数据库实现分布式锁
  2. 基于缓存( Redis等)
  3. 基于Zookeeper

每一种分布式锁解决方案都有各自的优缺点:

  1. 高性能:Redis最高
  2. 可靠性:zookeeper最高

分布式锁的关键是多进程共享的内存标记(锁),因此只要我们在Redis中放置一个这样的标记(数据)就可以了。不过在实现过程中,不要忘了我们需要实现下列目标:

  • 多进程可见:多进程可见,否则就无法实现分布式效果
  • 避免死锁:死锁的情况有很多,我们要思考各种异常导致死锁的情况,保证锁可以被释放
  • 排它:同一时刻,只能有一个进程获得锁
  • 高可用:避免锁服务宕机或处理好宕机的补救措施(redis集群架构:1.主从复制 2.哨兵 3.cluster集群)

分布式锁使用的逻辑如下:

尝试获取锁
	成功:执行业务代码    
		执行业务  
			try{
				获取锁
				业务代码-宕机
			} catch(){
			
			}finally{ 
				释放锁
			}
 	失败:等待;

使用Redis实现分布式锁

image-20241029210846880

  1. 多个客户端同时获取锁(setnx)
  2. 获取成功,执行业务逻辑:从db获取数据,放入缓存,执行完成释放锁(del)
  3. 其他客户端等待重试

1、分布式锁初版

/**
 * 采用SpringDataRedis实现分布式锁
 * 原理:执行业务方法前先尝试获取锁(setnx存入key val),如果获取锁成功再执行业务代码,业务执行完毕后将锁释放(del key)
 */
@Override
public void testLock() {

    //0.先尝试获取锁 setnx key val
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock", "lock");
    if(flag){
        //获取锁成功,执行业务代码
        //1.先从redis中通过key num获取值  key提前手动设置 num 初始值:0
        String value = stringRedisTemplate.opsForValue().get("num");
        //2.如果值为空则非法直接返回即可
        if (StringUtils.isBlank(value)) {
            return;
        }
        //3.对num值进行自增加一
        int num = Integer.parseInt(value);
        stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));

        //4.将锁释放
        stringRedisTemplate.delete("lock");

    }else{
        try {
            Thread.sleep(100);
            this.testLock();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

问题:setnx刚好获取到锁,业务逻辑出现异常,导致锁无法释放

解决:设置过期时间,自动释放锁。

2、优化之设置锁的过期时间

设置过期时间有两种方式:

  1. 首先想到通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)
  2. 在set时指定过期时间(推荐)

image-20241029211018144

设置过期时间:

//0.先尝试获取锁 setnx key val
Boolean flag = redisTemplate.opsForValue().setIfAbsent("lock", "lock", 3, TimeUnit.SECONDS); 

问题1:设置过期时间可能会出现业务未执行完,key就过期了,其他线程就可以获取到锁,此时就出现了并发安全问题。

解决:自动续期

当前线程加锁成功后,创建了一个子线程,当锁的ttl时间还剩1/3时,自动将锁的ttl恢复到目标时间,这个操作就是锁的自动续期。

public void test(){
	new Thread(
    ()->{
        while(true){
            if(ttl==ttl/3){
                redisTemplate.opsForValue().set(key,value,time, TimeUnit.SECONDS);
            }
        }
    }
    ).start()
        
   //业务代码...
}

问题2:可能会释放其他服务器的锁。

场景:如果业务逻辑的执行时间是7s。执行流程如下

  1. index1业务逻辑没执行完,3秒后锁被自动释放。
  2. index2获取到锁,执行业务逻辑,3秒后锁被自动释放。
  3. index3获取到锁,执行业务逻辑,index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁, 导致index3的业务只执行1s就被别人释放。最终等于没锁的情况。

解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁

3、优化之UUID防误删

image-20241029211444085

@Override
public void testLock() {
    //0.先尝试获取锁 setnx key val
    String uuid = UUID.randomUUID().toString();
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
    if(flag){
        //获取锁成功,执行业务代码
        //1.先从redis中通过key num获取值  key提前手动设置 num 初始值:0
        String value = stringRedisTemplate.opsForValue().get("num");
        //2.如果值为空则非法直接返回即可
        if (StringUtils.isBlank(value)) {
            return;
        }
        //3.对num值进行自增加一
        int num = Integer.parseInt(value);
        stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));

        //4.将锁释放,判断当前持有锁的对象=》是否《=跟当初获取锁的对象的值 一致
        if(uuid.equals((String)stringRedisTemplate.opsForValue().get("lock"))) {
            stringRedisTemplate.delete("lock");
        }
    }else{
        try {
            Thread.sleep(100);
            this.testLock();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
} 

问题:删除操作缺乏原子性。

场景:

​ 1、index1执行删除时,查询到的lock值确实和uuid相等

​ 2、index1执行删除前,lock刚好过期时间已到,被Redis自动释放,在Redis中没有了锁。

​ 3、index2获取了lock,index2线程获取到了cpu的资源,开始执行方法

​ 4、index1执行删除,此时会把index2的lock删除。

index1 因为已经在方法中了,所以不需要重新上锁。index1有执行的权限。index1已经比较完成了,这个时候,开始执行删除了index2的锁!

解决:使用lua脚本保证原子性

4、优化之LUA脚本保证删除的原子性

释放锁的LUA脚本:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

redis java客户端使用LUA脚本:

//通过execute可以执行LUA脚本,参数1:脚本字符串,参数2:脚本返回值类型,参数3:keys列表,参数4:argv列表
stringRedisTemplate.execute(new DefaultRedisScript<>(script , Boolean.class),list,args...)

使用LUA脚本优化分布式锁

/**
 * 采用SpringDataRedis实现分布式锁
 * 原理:执行业务方法前先尝试获取锁(setnx存入key val),如果获取锁成功再执行业务代码,业务执行完毕后将锁释放(del key)
 */
@Override
public void testLock() {

    //0.先尝试获取锁 setnx key val
    //问题:锁可能存在线程间相互释放
    //Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock", "lock", 10, TimeUnit.SECONDS);
    //解决:锁值设置为uuid
    String uuid = UUID.randomUUID().toString();
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);

    if(flag){
        //获取锁成功,执行业务代码
        //1.先从redis中通过key num获取值  key提前手动设置 num 初始值:0
        String value = stringRedisTemplate.opsForValue().get("num");
        //2.如果值为空则非法直接返回即可
        if (StringUtils.isBlank(value)) {
            return;
        }
        //3.对num值进行自增加一
        int num = Integer.parseInt(value);
        stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));

        //4.将锁释放 判断uuid
        //问题:删除操作缺乏原子性。
        //if(uuid.equals(stringRedisTemplate.opsForValue().get("lock"))){ //线程一:判断是满足是当前线程锁的值
        //    //条件满足,此时锁正好到期,redis锁自动释放了线程2获取锁成功,线程1将线程2的锁删除
        //    stringRedisTemplate.delete("lock");
        //}
        //解决:redis执行lua脚本保证原子,lua脚本执行会作为一个整体执行

        //执行脚本参数 参数1:脚本对象封装lua脚本,参数二:lua脚本中需要key参数(KEYS[i])  参数三:lua脚本中需要参数值 ARGV[i]
        //4.1 先创建脚本对象 DefaultRedisScript泛型脚本语言返回值类型 Long 0:失败 1:成功
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        //4.2设置脚本文本
        String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
                "then\n" +
                "    return redis.call(\"del\",KEYS[1])\n" +
                "else\n" +
                "    return 0\n" +
                "end";
        redisScript.setScriptText(script);
        //4.3 设置响应类型
        redisScript.setResultType(Long.class);
        stringRedisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);
    }else{
        try {
            //睡眠
            Thread.sleep(100);
            //自旋重试
            this.testLock();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

DefaultRedisScript

DefaultRedisScript 是 Spring Data Redis 提供的一个类,用于简化 Redis Lua 脚本的使用。

类构造方法

  • DefaultRedisScript():创建一个新的 DefaultRedisScript 实例。

主要方法

  • setScriptText(String script):设置要执行的脚本内容。
    • 参数:一个字符串,表示 Lua 脚本的内容。
  • setResultType(Class resultType):设置脚本执行后返回值的类型。
    • 参数:指定返回值的类型,通常是 String.classLong.classObject.class
  • getScriptText():获取当前设置的脚本文本。
  • getResultType():获取当前脚本的返回类型。

执行脚本

  • redisTemplate.execute(redisScript对象, 实参1,实参2);

分布式锁总结

为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下几个条件:

  • 互斥性。在任意时刻,只有一个客户端能持有锁。
  • 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
  • 加锁和解锁必须具有原子性。
posted @ 2024-11-05 21:24  CH_song  阅读(59)  评论(0)    收藏  举报