Redis分布式锁原理
发布日期:2021-05-08 14:12:43 浏览次数:7 分类:原创文章

本文共 6087 字,大约阅读时间需要 20 分钟。

分布式锁的关键是多进程共享的内存标记,因此只要我们在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@Componentpublic 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给删除,有没有这么一种可能性:

  1. 多个进程:A和B和C,在执行任务,并争抢锁,此时A获取了锁,并设置自动过期时间为10s

  2. A开始执行业务,因为某种原因,业务阻塞,耗时超过了10秒,此时锁自动释放了

  3. B恰好此时开始尝试获取锁,因为锁已经自动释放,成功获取锁

  4. A此时业务执行完毕,执行释放锁逻辑(删除key),于是B的锁被释放了,而B其实还在执行业务

  5. 此时进程C尝试获取锁,也成功了,因为A把B的锁删除了。

问题出现了: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:

  • script:脚本内容,或者脚本地址

  • 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,方便下次使用。

参数script就是脚本内容或地址。

EVALSHA 命令:

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

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

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

redis.call()和redis.pcall()

这两个函数是调用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实例。

 


。。。

 

 

 

 

 

 

 

 

 

 

上一篇:SVN翻译
下一篇:定时任务Spring Schedule

发表评论

最新留言

路过按个爪印,很不错,赞一个!
[***.219.124.196]2025年03月21日 03时01分58秒