Lua脚本在redis分布式锁场景的运用  

锁和分布式锁

锁是什么?

锁是一种可以封锁资源的东西。这种资源通常是共享的,通常会发生使用竞争的。

为什么需要锁?

需要保护共享资源正常使用,不出乱子。

比方说,公司只有一间厕所,这是个共享资源,大家需要共同使用这个厕所,所以避免不了有时候会发生竞争。如果一个人正在使用,另外一个人进去了,咋办呢?如果两个人同时钻进了一个厕所,那该怎么办?结果如何?谁先用,还是一起使用?特别的,假如是一男一女同时钻进了厕所,事情会怎样呢?反正我是不懂……

如果这个时候厕所门前有个锁,每个人都没法随便进入,而是需要先得到锁,才能进去。而得到这个锁,就需要里边的人先出来。这样就可以保证同一时刻,只有一个人在使用厕所,这个人在上厕所的期间不会有不安全的事情发生,不会中途被人闯进来了。

Java中的锁

在 java 编码的时候,为了保护共享资源,使得多线程环境下,不会出现“不好的结果”。我们可以使用锁来进行线程同步。于是我们可以根据具体的情况使用synchronized 关键字来修饰一个方法,或者一段代码。这个方法或者代码就像是前文中提到的“受保护的厕所,加锁的厕所”。也可以使用 java 5以后的 Lock 来实现,与 synchronized 关键字相比,Lock 的使用更灵活,可以有加锁超时时间、公平性等优势。

分布式锁

上面我们所说的 synchronized 关键字也好,Lock 也好。其实他们的作用范围是啥,就是当前的应用啊。你的代码在这个 jar 包或者这个 war 包里边,被部署在 A 机器上。那么实际上我们写的 synchronized 关键字,就是在当前的机器的 JVM在执行代码的时候发生作用的。假设这个代码被部署到了三台机器上 A,B,C。那么 A 机器中的部署的代码中的synchronized 关键字并不能控制 B,C 中的内容。

假如我们需要在 A,B,C 三台机器上运行某段程序的时候,实现“原子操作”,synchronized 关键字或者 Lock 是不能满足的。很显然,这个时候我们需要的锁,是需要协同这三个节点的,于是,分布式锁就需要上场了,他就像是在A,B,C的外面加了一个层,通过它来实现锁的控制。

redis 如何实现加锁

在redis中,有一条命令,可以实现类似 “锁” 的语法是这样的:

SETNX key value

他的作用是,将 key 的值设为 value ,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。设置成功,返回 1 ;设置失败,返回 0

使用 redis 来实现锁的逻辑就是这样的

线程 1 获取锁  -- > setnx mylock lockvalue
              -- >  1  获取锁成功
线程 2 获取锁  -- > setnx mylock lockvalue 
              -- >  0  获取锁失败  (继续等待,或者其他逻辑)
线程 1 释放锁  -- > 
线程 2 获取锁  -- > setnx mylock lockvalue
              -- > 1 获取成功

锁超时

在这个例子中,我们梳理了使用 redis setnx 命令 来实现锁的逻辑。这里还需要考虑的是,锁超时的问题 ,因为当线程 1 获取了锁之后,如果业务逻辑执行很长很长时间,那么其他线程只能死等,这可不行。所以需要加上超时,结合这些考虑的情况,实际的 Java 代码可以这样写:

   public static boolean lock(String key,String lockValue,int expire){
                if(null == key){
                        return false;
                }
                try {
                        Jedis jedis = getJedisPool().getResource();
                        String res = jedis.set(key,lockValue,"NX","EX",expire);
                        jedis.close();
                        return res!=null && res.equals("OK");
                } catch (Exception e) {
                        return false;
                }
        }

retry

这里执行加锁,不一定能成功。当别人正在持有锁的时候,加锁的线程需要继续尝试。这个“继续尝试”通常是“忙等待”,实现代码如下:

/**
         * 获取一个分布式锁 , 超时则返回失败
         * @param key                   锁的key
         * @param lockValue             锁的value
         * @param timeout               获取锁的等待时间,单位为 秒
     * @return                          获锁成功 - true | 获锁失败 - false
     */
        public static boolean tryLock(String key,String lockValue,int timeout,int expire){
                final long start = System.currentTimeMillis();
                if(timeout > expiredNx) {
                        timeout = expiredNx;
                }
                final long end = start + timeout * 1000;
                boolean res = false; // 默认返回失败
                while(!(res = lock(key,lockValue,expire))){ // 调用了上面的 lock方法
                        if(System.currentTimeMillis() > end) {
                                break;
                        }
                }
                return res;
        }

  

redis 如何释放锁

根据上面所述,我们在加锁的时候执行了:setnx mylock lockvalue , 这种加锁的本质其实就是 “占座位”,我把一本书放在自习室第一排的第一个座位上,别人就不能坐了,就得等着我走了,把东西拿走了,他就可以使用这个座位了。所以很容易想到,在我们需要释放锁的时候,只需要调用 del mylock 就行了,这样别的线程想去执行加锁的时候执行就可以执行 setnx mylock lockvalue 了。

不该释放的锁

但是,直接执行del mylock 是有问题的,我们不能直接执行 del mylock 为什么?—— 会导致 “信号错误”,释放了不该释放的锁 。假设如下场景:

时间线线程1线程2线程3
时刻1执行 setnx mylock val1 加锁执行 setnx mylock val2 加锁执行 setnx mylock val2 加锁
时刻2加锁成功加锁失败加锁失败
时刻3执行任务...尝试加锁...尝试加锁...
时刻4任务继续(锁超时,自动释放了)setnx 获得了锁(因为线程1的锁超时释放了)仍然尝试加锁...
时刻5任务完毕,del mylock 释放锁执行任务中...获得了锁(因为线程1释放了线程2的)
...
上面的表格中,有两个维度,一个是纵向的时间线,一个是横线的线程并发竞争。我们可以发现线程 1 在开始的时候比较幸运,获得了锁,最先开始执行任务,但是,由于他比较耗时,最后锁超时自动释放了他都还没执行完。 因此,线程 2 和线程3 的机会来了。而这一轮,线程2 比较幸运,得到了锁。可是,当线程2正在执行任务期间,线程1 执行完了,还把线程2的锁给释放了。这就相当于,本来你锁着门在厕所里边尿尿,进行到一半的时候,别人进来了,因为他配了一把和你一模一样的钥匙!这就乱套了啊

因此,我们需要安全的释放锁——“不是我的锁,我不能瞎释放”。所以,我们在加锁的时候,就需要标记“这是我的锁”,在释放的时候在判断 “ 这是不是我的锁?”。这里就需要在释放锁的时候加上逻辑判断,合理的逻辑应该是这样的:

1. 线程1 准备释放锁 , 锁的key 为 mylock  锁的 value 为 thread1_magic_num
2. 查询当前锁 current_value = get mylock
3. 判断    if current_value == thread1_magic_num -- > 是  我(线程1)的锁
          else                                   -- >不是 我(线程1)的锁
4. 是我的锁就释放,否则不能释放(而是执行自己的其他逻辑)。   

  

为了实现上面这个逻辑,我们是无法通过 redis 自带的命令直接完成的。如果,再写复杂的代码去控制释放锁,则会让整体代码太过于复杂了。所以,我们引入了lua脚本。结合Lua 脚本实现释放锁的功能,更简单,redis 执行lua脚本也是原子的,所以更合适,让合适的人干合适的事,岂不更好。

通过Lua脚本实现锁释放

Lua是啥,Lua是一种功能强大,高效,轻量级,可嵌入的脚本语言。其官方的描述是:

Lua is a powerful, efficient, lightweight, embeddable scripting language. It supports procedural programming, object-oriented programming, functional programming, data-driven programming, and data description.

Lua 调用 redis 非常简单,并且 Lua 脚本语法也易学,对于有别的编程语言基础的程序员来说,在不学习Lua脚本语法的情况下,直接看 Lua 的代码 也是可以看懂的。例子如下:

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

上面的代码,逻辑很简单,if 中的比较如果是true , 那么 执行 del 并返回del结果;如果 if 结果为false 直接返回 0 。这不就满足了我们释放锁的要求吗?——“ 是我的锁,我就释放,不是我的锁,我不能瞎释放”。

其中的KEYS[1] , ARGV[1] 是参数,我们只调用 jedis 执行脚本的时候,传递这两个参数就可以了。

使用redis + lua 来实现释放锁的代码如下:

private static final Long lockReleaseOK = 1L;
static String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";// lua脚本,用来释放分布式锁

public static boolean releaseLock(String key ,String lockValue){
        if(key == null || lockValue == null) {
                return false;
        }
        try {
                Jedis jedis = getJedisPool().getResource();
                Object res =jedis.eval(luaScript,Collections.singletonList(key),Collections.singletonList(lockValue));
                jedis.close();
                return res!=null && res.equals(lockReleaseOK);
        } catch (Exception e) {
                return false;
        }
}

如此,我们便实现了锁的安全释放。同时,我们还需要结合业务逻辑,进行具体健壮性的保证,比如如果结束了一定不能忘记释放锁,异常了也要释放锁,某种情况下是否需要回滚事务等。总结这个分布式锁使用的过程便是:

  • 加锁时 key 同,value 不同。
  • 释放锁时,根据value判断,是不是我的锁,不能释放别人的锁。
  • 及时释放锁,而不是利用自动超时。
  • 锁超时时间一定要结合业务情况权衡,过长,过短都不行。
  • 程序异常之处,要捕获,并释放锁。如果需要回滚的,主动做回滚、补偿。保证整体的健壮性,一致性。

用redis做分布式锁真的靠谱吗

上面的文字中,我们讨论如何使用redis作为分布式锁,并讨论了一些细节问题,如锁超时的问题、安全释放锁的问题。目前为止,似乎很完美的解决的我们想要的分布式锁功能。然而事情并没有这么简单,用redis做分布式锁并不“靠谱”。

不靠谱的情况

上面我们说的是redis,是单点的情况。如果是在redis sentinel集群中情况就有所不同了。关于redis sentinel 集群可以看这里。在redis sentinel集群中,我们具有多台redis,他们之间有着主从的关系,例如一主二从。我们的set命令对应的数据写到主库,然后同步到从库。当我们申请一个锁的时候,对应就是一条命令 setnx mykey myvalue ,在redis sentinel集群中,这条命令先是落到了主库。假设这时主库down了,而这条数据还没来得及同步到从库,sentinel将从库中的一台选举为主库了。这时,我们的新主库中并没有mykey这条数据,若此时另外一个client执行 setnx mykey hisvalue , 也会成功,即也能得到锁。这就意味着,此时有两个client获得了锁。这不是我们希望看到的,虽然这个情况发生的记录很小,只会在主从failover的时候才会发生,大多数情况下、大多数系统都可以容忍,但是不是所有的系统都能容忍这种瑕疵。

redlock

为了解决故障转移情况下的缺陷,Antirez 发明了 Redlock 算法,使用redlock算法,需要多个redis实例,加锁的时候,它会想多半节点发送 setex mykey myvalue 命令,只要过半节点成功了,那么就算加锁成功了。释放锁的时候需要想所有节点发送del命令。这是一种基于【大多数都同意】的一种机制。感兴趣的可以查询相关资料。在实际工作中使用的时候,我们可以选择已有的开源实现,python有redlock-py,java 中有Redisson redlock。

redlock确实解决了上面所说的“不靠谱的情况”。但是,它解决问题的同时,也带来了代价。你需要多个redis实例,你需要引入新的库 代码也得调整,性能上也会有损。所以,果然是不存在“完美的解决方案”,我们更需要的是能够根据实际的情况和条件把问题解决了就好。

至此,我大致讲清楚了redis分布式锁方面的问题(日后如果有新的领悟就继续更新)。

锁和分布式锁

锁是什么?

锁是一种可以封锁资源的东西。这种资源通常是共享的,通常会发生使用竞争的。

为什么需要锁?

需要保护共享资源正常使用,不出乱子。

比方说,公司只有一间厕所,这是个共享资源,大家需要共同使用这个厕所,所以避免不了有时候会发生竞争。如果一个人正在使用,另外一个人进去了,咋办呢?如果两个人同时钻进了一个厕所,那该怎么办?结果如何?谁先用,还是一起使用?特别的,假如是一男一女同时钻进了厕所,事情会怎样呢?反正我是不懂……

如果这个时候厕所门前有个锁,每个人都没法随便进入,而是需要先得到锁,才能进去。而得到这个锁,就需要里边的人先出来。这样就可以保证同一时刻,只有一个人在使用厕所,这个人在上厕所的期间不会有不安全的事情发生,不会中途被人闯进来了。

Java中的锁

在 java 编码的时候,为了保护共享资源,使得多线程环境下,不会出现“不好的结果”。我们可以使用锁来进行线程同步。于是我们可以根据具体的情况使用synchronized 关键字来修饰一个方法,或者一段代码。这个方法或者代码就像是前文中提到的“受保护的厕所,加锁的厕所”。也可以使用 java 5以后的 Lock 来实现,与 synchronized 关键字相比,Lock 的使用更灵活,可以有加锁超时时间、公平性等优势。

分布式锁

上面我们所说的 synchronized 关键字也好,Lock 也好。其实他们的作用范围是啥,就是当前的应用啊。你的代码在这个 jar 包或者这个 war 包里边,被部署在 A 机器上。那么实际上我们写的 synchronized 关键字,就是在当前的机器的 JVM在执行代码的时候发生作用的。假设这个代码被部署到了三台机器上 A,B,C。那么 A 机器中的部署的代码中的synchronized 关键字并不能控制 B,C 中的内容。

假如我们需要在 A,B,C 三台机器上运行某段程序的时候,实现“原子操作”,synchronized 关键字或者 Lock 是不能满足的。很显然,这个时候我们需要的锁,是需要协同这三个节点的,于是,分布式锁就需要上场了,他就像是在A,B,C的外面加了一个层,通过它来实现锁的控制。

redis 如何实现加锁

在redis中,有一条命令,可以实现类似 “锁” 的语法是这样的:

SETNX key value

他的作用是,将 key 的值设为 value ,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。设置成功,返回 1 ;设置失败,返回 0

使用 redis 来实现锁的逻辑就是这样的

线程 1 获取锁  -- > setnx mylock lockvalue
              -- >  1  获取锁成功
线程 2 获取锁  -- > setnx mylock lockvalue 
              -- >  0  获取锁失败  (继续等待,或者其他逻辑)
线程 1 释放锁  -- > 
线程 2 获取锁  -- > setnx mylock lockvalue
              -- > 1 获取成功

锁超时

在这个例子中,我们梳理了使用 redis setnx 命令 来实现锁的逻辑。这里还需要考虑的是,锁超时的问题 ,因为当线程 1 获取了锁之后,如果业务逻辑执行很长很长时间,那么其他线程只能死等,这可不行。所以需要加上超时,结合这些考虑的情况,实际的 Java 代码可以这样写:

   public static boolean lock(String key,String lockValue,int expire){
                if(null == key){
                        return false;
                }
                try {
                        Jedis jedis = getJedisPool().getResource();
                        String res = jedis.set(key,lockValue,"NX","EX",expire);
                        jedis.close();
                        return res!=null && res.equals("OK");
                } catch (Exception e) {
                        return false;
                }
        }

retry

这里执行加锁,不一定能成功。当别人正在持有锁的时候,加锁的线程需要继续尝试。这个“继续尝试”通常是“忙等待”,实现代码如下:

/**
         * 获取一个分布式锁 , 超时则返回失败
         * @param key                   锁的key
         * @param lockValue             锁的value
         * @param timeout               获取锁的等待时间,单位为 秒
     * @return                          获锁成功 - true | 获锁失败 - false
     */
        public static boolean tryLock(String key,String lockValue,int timeout,int expire){
                final long start = System.currentTimeMillis();
                if(timeout > expiredNx) {
                        timeout = expiredNx;
                }
                final long end = start + timeout * 1000;
                boolean res = false; // 默认返回失败
                while(!(res = lock(key,lockValue,expire))){ // 调用了上面的 lock方法
                        if(System.currentTimeMillis() > end) {
                                break;
                        }
                }
                return res;
        }

  

redis 如何释放锁

根据上面所述,我们在加锁的时候执行了:setnx mylock lockvalue , 这种加锁的本质其实就是 “占座位”,我把一本书放在自习室第一排的第一个座位上,别人就不能坐了,就得等着我走了,把东西拿走了,他就可以使用这个座位了。所以很容易想到,在我们需要释放锁的时候,只需要调用 del mylock 就行了,这样别的线程想去执行加锁的时候执行就可以执行 setnx mylock lockvalue 了。

不该释放的锁

但是,直接执行del mylock 是有问题的,我们不能直接执行 del mylock 为什么?—— 会导致 “信号错误”,释放了不该释放的锁 。假设如下场景:

时间线线程1线程2线程3
时刻1执行 setnx mylock val1 加锁执行 setnx mylock val2 加锁执行 setnx mylock val2 加锁
时刻2加锁成功加锁失败加锁失败
时刻3执行任务...尝试加锁...尝试加锁...
时刻4任务继续(锁超时,自动释放了)setnx 获得了锁(因为线程1的锁超时释放了)仍然尝试加锁...
时刻5任务完毕,del mylock 释放锁执行任务中...获得了锁(因为线程1释放了线程2的)
...
上面的表格中,有两个维度,一个是纵向的时间线,一个是横线的线程并发竞争。我们可以发现线程 1 在开始的时候比较幸运,获得了锁,最先开始执行任务,但是,由于他比较耗时,最后锁超时自动释放了他都还没执行完。 因此,线程 2 和线程3 的机会来了。而这一轮,线程2 比较幸运,得到了锁。可是,当线程2正在执行任务期间,线程1 执行完了,还把线程2的锁给释放了。这就相当于,本来你锁着门在厕所里边尿尿,进行到一半的时候,别人进来了,因为他配了一把和你一模一样的钥匙!这就乱套了啊

因此,我们需要安全的释放锁——“不是我的锁,我不能瞎释放”。所以,我们在加锁的时候,就需要标记“这是我的锁”,在释放的时候在判断 “ 这是不是我的锁?”。这里就需要在释放锁的时候加上逻辑判断,合理的逻辑应该是这样的:

1. 线程1 准备释放锁 , 锁的key 为 mylock  锁的 value 为 thread1_magic_num
2. 查询当前锁 current_value = get mylock
3. 判断    if current_value == thread1_magic_num -- > 是  我(线程1)的锁
          else                                   -- >不是 我(线程1)的锁
4. 是我的锁就释放,否则不能释放(而是执行自己的其他逻辑)。   

  

为了实现上面这个逻辑,我们是无法通过 redis 自带的命令直接完成的。如果,再写复杂的代码去控制释放锁,则会让整体代码太过于复杂了。所以,我们引入了lua脚本。结合Lua 脚本实现释放锁的功能,更简单,redis 执行lua脚本也是原子的,所以更合适,让合适的人干合适的事,岂不更好。

通过Lua脚本实现锁释放

Lua是啥,Lua是一种功能强大,高效,轻量级,可嵌入的脚本语言。其官方的描述是:

Lua is a powerful, efficient, lightweight, embeddable scripting language. It supports procedural programming, object-oriented programming, functional programming, data-driven programming, and data description.

Lua 调用 redis 非常简单,并且 Lua 脚本语法也易学,对于有别的编程语言基础的程序员来说,在不学习Lua脚本语法的情况下,直接看 Lua 的代码 也是可以看懂的。例子如下:

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

上面的代码,逻辑很简单,if 中的比较如果是true , 那么 执行 del 并返回del结果;如果 if 结果为false 直接返回 0 。这不就满足了我们释放锁的要求吗?——“ 是我的锁,我就释放,不是我的锁,我不能瞎释放”。

其中的KEYS[1] , ARGV[1] 是参数,我们只调用 jedis 执行脚本的时候,传递这两个参数就可以了。

使用redis + lua 来实现释放锁的代码如下:

private static final Long lockReleaseOK = 1L;
static String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";// lua脚本,用来释放分布式锁

public static boolean releaseLock(String key ,String lockValue){
        if(key == null || lockValue == null) {
                return false;
        }
        try {
                Jedis jedis = getJedisPool().getResource();
                Object res =jedis.eval(luaScript,Collections.singletonList(key),Collections.singletonList(lockValue));
                jedis.close();
                return res!=null && res.equals(lockReleaseOK);
        } catch (Exception e) {
                return false;
        }
}

如此,我们便实现了锁的安全释放。同时,我们还需要结合业务逻辑,进行具体健壮性的保证,比如如果结束了一定不能忘记释放锁,异常了也要释放锁,某种情况下是否需要回滚事务等。总结这个分布式锁使用的过程便是:

  • 加锁时 key 同,value 不同。
  • 释放锁时,根据value判断,是不是我的锁,不能释放别人的锁。
  • 及时释放锁,而不是利用自动超时。
  • 锁超时时间一定要结合业务情况权衡,过长,过短都不行。
  • 程序异常之处,要捕获,并释放锁。如果需要回滚的,主动做回滚、补偿。保证整体的健壮性,一致性。

用redis做分布式锁真的靠谱吗

上面的文字中,我们讨论如何使用redis作为分布式锁,并讨论了一些细节问题,如锁超时的问题、安全释放锁的问题。目前为止,似乎很完美的解决的我们想要的分布式锁功能。然而事情并没有这么简单,用redis做分布式锁并不“靠谱”。

不靠谱的情况

上面我们说的是redis,是单点的情况。如果是在redis sentinel集群中情况就有所不同了。关于redis sentinel 集群可以看这里。在redis sentinel集群中,我们具有多台redis,他们之间有着主从的关系,例如一主二从。我们的set命令对应的数据写到主库,然后同步到从库。当我们申请一个锁的时候,对应就是一条命令 setnx mykey myvalue ,在redis sentinel集群中,这条命令先是落到了主库。假设这时主库down了,而这条数据还没来得及同步到从库,sentinel将从库中的一台选举为主库了。这时,我们的新主库中并没有mykey这条数据,若此时另外一个client执行 setnx mykey hisvalue , 也会成功,即也能得到锁。这就意味着,此时有两个client获得了锁。这不是我们希望看到的,虽然这个情况发生的记录很小,只会在主从failover的时候才会发生,大多数情况下、大多数系统都可以容忍,但是不是所有的系统都能容忍这种瑕疵。

redlock

为了解决故障转移情况下的缺陷,Antirez 发明了 Redlock 算法,使用redlock算法,需要多个redis实例,加锁的时候,它会想多半节点发送 setex mykey myvalue 命令,只要过半节点成功了,那么就算加锁成功了。释放锁的时候需要想所有节点发送del命令。这是一种基于【大多数都同意】的一种机制。感兴趣的可以查询相关资料。在实际工作中使用的时候,我们可以选择已有的开源实现,python有redlock-py,java 中有Redisson redlock。

redlock确实解决了上面所说的“不靠谱的情况”。但是,它解决问题的同时,也带来了代价。你需要多个redis实例,你需要引入新的库 代码也得调整,性能上也会有损。所以,果然是不存在“完美的解决方案”,我们更需要的是能够根据实际的情况和条件把问题解决了就好。

至此,我大致讲清楚了redis分布式锁方面的问题(日后如果有新的领悟就继续更新)。