您的当前位置:首页正文

Redis分布式锁原理

2024-11-10 来源:个人技术集锦

分布式锁的关键是多进程共享的内存标记,因此只要我们在Redis中放置一个这样的标记就可以了 .

  • 多进程可见:多进程可见,否则就无法实现分布式效果

  • 避免死锁:死锁的情况有很多,我们要思考各种异常导致死锁的情况,保证锁可以被释放

  • 排它:同一时刻,只能有一个进程获得锁

  • 高可用:避免锁服务宕机或处理好宕机的补救措施

多进程可见:多进程可见,否则就无法实现分布式效果

  • redis本身就是多服务共享的,因此自然满足

排它:同一时刻,只能有一个进程获得锁

  • 我们需要利用Redis的setnx命令来实现,setnx是set when not exits的意思。当多次执行setnx命令时,只有第一次执行的才会成功并返回1,其它情况返回0:

  • 我们定义一个固定的key,多个进程都执行setnx,设置这个key的值,返回1的服务获取锁,0则没有获取

避免死锁:死锁的情况有很多,我们要思考各种异常导致死锁的情况

  • 比如服务宕机后的锁释放问题,我们设置锁时最好设置锁的有效期,如果服务宕机,有效期到时自动删除锁。

高可用:避免锁服务宕机或处理好宕机的补救措施

  • 利用Redis的主从、哨兵、集群,保证高可用

 

Redis分布式锁的发展:

(一)

流程:

  • 1、通过set命令设置锁

  • 2、判断返回结果是否是OK

    • 1)Nil,获取失败,结束或重试(自旋锁)

    • 2)OK,获取锁成功

      • 执行业务

      • 释放锁,DEL 删除key即可

  • 3、异常情况,服务宕机。超时时间EX结束,会自动释放锁。

代码{

//定义锁接口
public interface RedisLock {
    boolean lock(long releaseTime);
    void unlock();
}
//定义锁工具

public class SimpleRedisLock implements RedisLock{

    private StringRedisTemplate redisTemplate;
    /**
     * 设定好锁对应的 key
     */
    private String key;
    /**
     * 锁对应的值,无意义,写为1
     */
    private static final String value = "1";

    public SimpleRedisLock(StringRedisTemplate redisTemplate, String key) {
        this.redisTemplate = redisTemplate;
        this.key = key;
    }

    public boolean lock(long releaseTime) {
        // 尝试获取锁
        Boolean boo = redisTemplate.opsForValue().setIfAbsent(key, value, releaseTime, TimeUnit.SECONDS);
        // 判断结果
        return boo != null && boo;
    }

    public void unlock(){
        // 删除key即可释放锁
        redisTemplate.delete(key);
    }
}
//在定时任务中使用锁

@Slf4j
@Component
public class HelloJob {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Scheduled(cron = "0/10 * * * * ?")
    public void hello() {
        // 创建锁对象
        RedisLock lock = new SimpleRedisLock(redisTemplate, "lock");
        // 获取锁,设置自动失效时间为50s
        boolean isLock = lock.lock(50);
        // 判断是否获取锁
        if (!isLock) {
            // 获取失败
            log.info("获取锁失败,停止定时任务");
            return;
        }
        try {
            // 执行业务
            log.info("获取锁成功,执行定时任务。");
            // 模拟任务耗时
            Thread.sleep(500);
        } catch (InterruptedException e) {
            log.error("任务执行异常", e);
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}


(二)

问题:

释放锁就是用DEL语句把锁对应的key给删除,有没有这么一种可能性:

问题出现了:B和C同时获取了锁,违反了排它性!

如何得知当前获取锁的是不是自己呢?

对了,我们可以在set 锁时,存入自己的信息!删除锁前,判断下里面的值是不是与自己相等,如果不等,就不要删除了。

代码:

public class SimpleRedisLock implements RedisLock{

    private StringRedisTemplate redisTemplate;
    /**
     * 设定好锁对应的 key
     */
    private String key;
    /**
     * 存入的线程信息的前缀,防止与其它JVM中线程信息冲突
     */
    private final String ID_PREFIX = UUID.randomUUID().toString();

    public SimpleRedisLock(StringRedisTemplate redisTemplate, String key) {
        this.redisTemplate = redisTemplate;
        this.key = key;
    }

    public boolean lock(long releaseTime) {
        // 获取线程信息作为值,方便判断是否是自己的锁
        String value = ID_PREFIX + Thread.currentThread().getId();
        // 尝试获取锁
        Boolean boo = redisTemplate.opsForValue().setIfAbsent(key, value, releaseTime, TimeUnit.SECONDS);
        // 判断结果
        return boo != null && boo;
    }

    public void unlock(){
        // 获取线程信息作为值,方便判断是否是自己的锁
        String value = ID_PREFIX + Thread.currentThread().getId();
        // 获取现在的锁的值
        String val = redisTemplate.opsForValue().get(key);
        // 判断是否是自己
        if(value.equals(val)) {
            // 删除key即可释放锁
            redisTemplate.delete(key);
        }
    }
}

(三)

如果我们在获取锁以后,执行代码的过程中,再次尝试获取锁,执行setnx肯定会失败,因为锁已经存在了。这样就是不可重入锁,有可能导致死锁。

如何解决呢?

当然是想办法改造成可重入锁。让自己可以复用自己的锁

可重入锁,也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。换一种说法:同一个线程再次进入同步代码时,可以使用自己已获取到的锁。

其中的关键,就是在锁已经被使用时,判断这个锁是否是自己的,如果是则再次获取

我们可以在set锁的值是,存入获取锁的线程的信息,这样下次再来时,就能知道当前持有锁的是不是自己,如果是就允许再次获取锁。

要注意,因为锁的获取是可重入的,因此必须记录重入的次数,这样不至于在释放锁时一下就释放掉,而是逐层释放。

因此,不能再使用简单的key-value结构,这里推荐使用hash结构:

  • key:lock

  • hashKey:线程信息

  • hashValue:重入次数,默认1

释放锁时,每次都把重入次数减一,减到0说明多次获取锁的逻辑都执行完毕,才可以删除key,释放锁

获取锁的步骤:

  • 1、判断lock是否存在 EXISTS lock

    • 存在,说明有人获取锁了,下面判断是不是自己的锁

      • 判断当前线程id作为hashKey是否存在:HEXISTS lock threadId

        • 不存在,说明锁已经有了,且不是自己获取的,锁获取失败,end

        • 存在,说明是自己获取的锁,重入次数+1:HINCRBY lock threadId 1,去到步骤3

    • 2、不存在,说明可以获取锁,HSET key threadId 1

    • 3、设置锁自动释放时间,EXPIRE lock 20

释放锁的步骤:

  • 1、判断当前线程id作为hashKey是否存在:HEXISTS lock threadId

    • 不存在,说明锁已经失效,不用管了

    • 存在,说明锁还在,重入次数减1:HINCRBY lock threadId -1,获取新的重入次数

  • 2、判断重入次数是否为0:

    • 为0,说明锁全部释放,删除key:DEL lock

    • 大于0,说明锁还在使用,重置有效时间:EXPIRE lock 20

上述流程有一个最大的问题,就是有大量的判断,这样在多线程运行时,会有线程安全问题,除非能保证执行

Redis支持一种特殊的执行方式:lua脚本执行,lua脚本中可以定义多条语句,语句执行具备原子性。

(三-------一) Redis脚本 LUA

实现Redis的原子操作有多种方式,比如Redis事务,但是相比而言,使用Redis的Lua脚本更加优秀,具有不可替代的好处:

  • 原子性:redis会将整个脚本作为一个整体执行,不会被其他命令插入。

  • 复用:客户端发送的脚本会永久存在redis中,以后可以重复使用,而且各个Redis客户端可以共用。

  • 高效:Lua脚本解析后会形成缓存,不用每次执行都解析。

  • 减少网络开销:Lua脚步缓存后,可以形成SHA值,作为缓存的key,以后调用可以直接根据SHA值来调用脚本,不用每次发送完整脚本,较少网络占用和时延

help @scripting:

  • numkeys:脚本中用到的key的数量,接下来的numkeys个参数会作为key参数,剩下的作为arg参数

  • key:作为key的参数,会被存入脚本环境中的KEYS数组,角标从1开始

  • arg:其它参数,会被存入脚本环境中的ARGV数组,角标从1开始

示例:EVAL "return 'hello world!'" 0,其中:

  • "return 'hello world!'":就是脚本的内容,直接返回字符串,没有别的命令

  • 0:就是说没有用key参数,直接返回

SCRIPT LOAD命令 :

将一段脚本编译并缓存起来,生成一个SHA1值并返回,作为脚本字典的key,方便下次使用。

EVALSHA 命令:

与EVAL类似,执行一段脚本,区别是通过脚本的sha1值,去脚本缓存中查找,然后执行,参数:

  • sha1:就是脚本对应的sha1值

Lua脚本遵循Lua的基本语法,这里我们简单介绍几个常用的:

这两个函数是调用redis命令的函数,区别在于call执行过程中出现错误会直接返回错误;pcall则在遇到错误后,会继续向下执行。基本语法类似:

redis.call("命令名称", 参数1, 参数2 ...)

例如这样的脚本:return redis.call('set', KEYS[1], ARGV[1])

  • 'set':就是执行set 命令

  • KEYS[1]:从脚本环境中KEYS数组里取第一个key参数

  • ARGV[1]:从脚本环境中ARGV数组里取第一个arg参数

 

条件判断和变量

条件判断语法:if (条件语句) then ...; else ...; end;

变量接收语法:local num = 123;

示例:

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

基本逻辑:获取指定key的值,判断是否大于指定参数,如果大于则返回1,否则返回0



(三-------二)Java执行Lua脚本

  • RedisScript<T> script:封装了Lua脚本的对象

  • List<K> keys:脚本中的key的值

  • Object ... args:脚本中的参数的值

要执行Lua脚本,我们需要先把脚本封装到RedisScript对象中,有两种方式来构建RedisScript对象:

方式1:通过RedisScript中的静态方法:

  • String script:Lua脚本

  • Class<T> resultType:返回值类型

方式二 :自己去创建RedisScript的实现类DefaultRedisScript的对象

可以把脚本文件写到classpath下的某个位置,然后通过加载这个文件来获取脚本内容,并设置给DefaultRedisScript实例。

 


。。。

 

 

 

 

 

 

 

 

 

 

显示全文