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分布式锁方面的问题(日后如果有新的领悟就继续更新)。

posted @ 2021-12-15 15:14  qtyy  阅读(666)  评论(0编辑  收藏  举报