Redis服务端锁的实现原理

2022-03-31
641

为什么要有分布式锁?

目前Java最常用的是JUC提供的锁机制,它可以保证在同一个JVM进程中同一时刻只有一个线程执行操作逻辑;

目前实际的业务处理中,我们为了保证fu'wu可用性一般不会单容器部署,多服务多节点的情况下,就意味着有多个JVM进程,此时JUC能做到的只是在当前JVM下只有一个线程执行的唯一性,而无法保证多节点下的这话唯一。如果想要做到多节点的唯一,就需要有一个中间层,这个时候分布式锁就出现了;

分布式锁就是用来保证在同一时刻,仅有一个JVM进程中的一个线程在执行操作逻辑;

JUC的锁和分布式锁都是一种保护系统资源的措施。只是前者适用单服务单节点,后者更适合现在的分布式情况。

目前自己工作中用到之前还不够理解,我们假设我们自己要实现一套分布式锁。如何设计呢?

锁如何执行的?(设计思路)

我们自己先思考下思路,假如同时有很多很多线程去上锁,谁锁成功谁就有权利执行操作逻辑,那么这个时候其他线程的情况无非是两种:1.直接走抢锁失败的逻辑,2.自旋尝试抢锁;

如何使用锁?

比方说 A线程竞争到了锁,开始执行操作逻辑:

public static void doSomething() {
	redisLock = new RedisLock(jedis); // 创建jedis实例的代码省略,不是重点
	try {
 	redisLock.lock(); // 上锁

		// 处理业务
		System.out.println(Thread.currentThread().getName() + " 线程处理业务逻辑中...");
		Thread.sleep(2000);
		System.out.println(Thread.currentThread().getName() + " 线程处理业务逻辑完毕");
		redisLock.unlock(); // 释放锁
	} catch (Exception e) {
 	e.printStackTrace();
	}
}

如何释放锁?

正常情况下,A 线程执行完操作逻辑后,应该将锁释放。

如果说执行过程中抛出异常,程序不再继续走正常的释放锁流程,没有释放锁怎么办?

所以我们会想到的做法是,那我们把释放流程放在一定会执行的地方:

释放锁的流程一定要在 finally{} 块中执行(当然,上锁的流程一定要在 finally{} 对应的 try{} 块中,否则 finally{} 就没用了),如下:

public static void doSomething() {
	redisLock = new RedisLock(jedis); // 创建jedis实例的代码省略,不是重点
	try {
 	redisLock.lock(); // 上锁

		// 处理业务
		System.out.println(Thread.currentThread().getName() + " 线程处理业务逻辑中...");
		Thread.sleep(2000);
		System.out.println(Thread.currentThread().getName() + " 线程处理业务逻辑完毕");
		redisLock.unlock(); // 释放锁
	} catch (Exception e) {
 	e.printStackTrace();
	} finally {
redisLock.unlock(); // 在finally{} 中释放锁
	}
}

如果在执行 try{} 中逻辑的时候,程序出现了 异常,或者 finally{} 中执行异常,比方说连接不上 redis-server了;或者还未执行到 finally{}的时候,JVM进程挂掉了,服务宕机;

这些情况都会导致没有成功释放锁,别的线程一直拿不到锁,怎么办

这个情况我们就必须要将风险降低,为了比较快捷的实现,我们第一反应或许是给锁设置一个超时时间,比方说 1秒,即便发生了上边的情况,那我的锁也会在 1秒之后自动释放,其他线程就可以获取到锁,接班干活了;失败也不影响别的线程使用。

锁的超时时间如何设置?这个时间该设多少合适呢?

上锁的时候,设置key和设置超时时间这两个操作要是原子性的,要么都执行,要么都不执行

redis原生有支持:【https://redis.io/commands/set】 不要在代码里两地调用,中间存在出现问题的可能!

SET key value [EX seconds|PX milliseconds|EXAT unix-time-seconds|PXAT unix-time-milliseconds|KEEPTTL] [NX|XX] [GET]

锁中的业务逻辑的执行时间,不能瞎写,一般是我们在测试环境进行测试后,甚至在压测环境多轮压测之后,获取当前业务使用的平均的执行时间后,对齐放大3-5倍,即可作为锁的超时时间。

比如说计算出平均的执行时间是 200ms,锁的超时时间放大3-5倍,这里我们设置为 1s。

为啥要放大?

因为如果锁的操作逻辑中有网络 IO操作,线上的网络不会总一帆风顺,我们要给网络抖动留有缓冲时间。

这个时候有的同学有想法,那我设置的再大一些,给网络足够充裕的时间,我就设置 10s、1min不是更安全吗?

能否无限大?

请注意,不要觉得时间越长越好,如果是无穷大?那不等于不设置超时时间吗?

同时,假如你设置了 30s,真的发生了宕机的情况下,意味着这 30s中间,你的这个分布式锁的服务全部节点都是不可用的。

增加超时时间是否已经万无一失了?

不知道是否想过一种情况,在分布式的场景下,是可能存在A线程在执行操作逻辑的过程中,别的线程直接进行了释放锁的操作,那么这个是时候一定会出问题的。

这个时候我们需要怎么做?

这个时候我们尝试联想 ReentrantLock中的 isHeldByCurrentThread()方法,我们是否可以在锁上加个标记,只有上锁的线程 A线程知道,相当于是一个密语,也就是说释放锁的时候,首先先把密语和锁上的标记进行匹配,如果匹配不上,就没有权利释放锁。

我们可以简单的设置一个UUID的方式是不是可行呢?

上锁之前,在该线程代码中生成一个 UUID,将这个作为秘钥,存在锁键的 value中,释放锁的时候,用这个进行校验,因为只有上锁的线程知道这个秘钥,别的线程是不知道的,自然也就不会出现别人能释放锁的情况。

唯一标识的增加思路

增加UUID作为标识后是否还会出现别的问题?

这个时候上锁这件事情就变成了:上锁+设置过期时间+设置UUID。

释放锁则是:比对UUID+释放锁。

那么这个时候这些行为是否可能出现中断,我们是否要保证其原子性?我们能怎么做?

是否会出现第一步比对成功后,第二步还没来得及执行的时候,锁到期,然后紧接着别的线程获取到锁,里边的 uuid已经变了,也就是说持有锁的线程已经不是该线程了,此时再执行第二步的删除锁操作,肯定是错误的了。

观察发现目前使用lua脚本的更多,执行脚本就能保证脚本内的所有操作是原子操作。

如何保证可重入性?

作为一把锁,我们在使用 synchronized、ReentrantLock的时候是不是有可重入性?

那咱们这把分布式锁该如何实现可重入呢?如果 A线程的锁方法逻辑中调用了 xx方法,xx方法中也需要获取这把锁,这个时候xx方法中的锁应该重入进去,那就需要将刚才生成的这个 UUID秘钥传递给 xx方法?

怎么传递?参数?这就侵入业务代码了。

能不侵入业务代码且实现目的吗?

我们主要是想给上锁的线程设置一个只有它自己知道的秘钥,这个时候用UUID这种行为就会有这个新的问题,那我们是否可以尝试换个思路:

线程本身的 id(Thread.currentThread().getId())是不是就是一个唯一标识呢?我们把秘钥 value设置为线程的 id不就行了。

Thread-Id 真能行吗?

想想,我们说一个 Thread的id是唯一的,是在同一个 JVM进程中,是在一个操作系统中,也就是在一个机器中。

最开始就说过,我们的部署是集群部署,多个实例节点,那意味着会存在这样一种情况,S1机器上的线程上锁成功,此时锁中秘钥 value是线程id=1,如果说同一时间 S2机器中,正好线程id=1的线程尝试获得这把锁,比对秘钥发现成功,结果也重入了这把锁,也开始执行逻辑,此时,我们的分布式锁崩溃!

怎么解决不通进程的同一Thread-Id 问题

我们只需要在每个节点中维护不同的标识即可,应用启动的时候,使用 UUID生成一个唯一标识 APP_ID,放在内存中(或者使用zookeeper去分配机器id等等)。此时,我们的秘钥 value这样存即可:APP_ID+ThreadId。这样就可以保证唯一。

APP_ID + ThreadId 还是 UUID 好呢?

如果 A线程执行逻辑中间开启了一个子线程执行任务,这个子线程任务中也需要重入这把锁,因为子线程获取到的线程 id不一样,导致重入失败。那意味着需要将这个秘钥继续传递给子线程,JUC中 InheritableThreadLocal 派上用场,但是感觉怪怪的,因为线程间传递的是父线程的 id。

微服务中多服务间调用的话可以借用系统自身有的 traceId作为秘钥即可。

至于选择哪种 value的方式,根据实际的系统设计 + 业务场景,选择最合适的即可,没有最好,只有最合适。

全局考虑是否有新的问题?

我们这是分布式锁,要考虑的事情还有很多,重入进去后,超时时间随便设吗?有门道吗?

比方说 A线程在锁方法中调用了 xx方法,而 xx方法中也有获取锁的逻辑,如果 A线程获取锁后,执行过程中,到 xx方法时,这把锁是要重入进去的,但是请注意,这把锁的超时时间如果小于第一次上锁的时间,比方说 A线程设置的超时时间是 1s,在 100ms的时候执行到 xx方法中,而 xx方法中设置的超时时间是 100ms,那么意味着 100ms之后锁就释放了,而这个时候我的 A线程的主方法还没有执行完呢!却被重入锁设置的时间搞坏了!

重入后的超时时间新问题,这个怎么搞?

如果说我在内存中设置一个这把锁设置过的最大的超时时间,重入的时候判断下传进来的时间,我重入时 expire的时候始终设置成最大的时间,而不是由重入锁随意降低锁时间导致上一步的主锁出现问题。

放在内存中行吗?

我们上边举例中,调用的 xx方法是在一个 JVM中,如果是调用远程的一个 RPC服务呢(像这种调用的话就需要将秘钥value通过 RpcContext传递过去了)到另一个节点的服务中进行锁重入,这个时间依然是要用当前设置过锁的最大时间的,所以这个最大的时间要存在 redis中而非 JVM内存中。

还有别的办法吗?

我们能否采用一个更简单的方式:锁的超时时间=第一次上锁的时间+后面所有重入锁的时间。(也就是expire = 主ttl + 重入exipre),这种方案是放大的思想,一放大就又有上边提到过的一个问题:expire太大怎么办,显然这样也不是最优解。

重入锁的释放有没有问题?

A线程执行一共需要500ms,执行中需要调用 xx方法,xx方法中有一个重入锁,执行用了 50ms,然后执行完后,xx方法的 finally{} 块中将锁进行释放。

为啥能释放掉?因为秘钥我有,匹配成功了我就直接释放了。

新的问题就来了!!!这个时候是不是问题很大!!!那么怎么解决?

如何对重入锁的释放?

如何直到此时能否释放呢?思路打开,我们想到了最简单的计数法。

我们要通过锁重入次数来进行释放锁时候的判断,也就是说上锁的时候需要多维护一个 key来保存当前锁的重入次数,如果执行释放锁时,先进行重入次数 -1,-1后如果是0,可以直接 del,如果>0,说明还有重入的锁在,不能直接 del。 OK,听起来这样就可以正确的释放了。

总结下目前需要的数据,还有无问题?

1.最大超时时间的key

2.重入次数的key

3.加上锁本身的key(含有过期时间)

已经有3个key,需要注意的事情是,这三个key是否都需要设置超时时间呢?如果不设置有什么问题?

假如说重入次数的 key没有设置超时时间,服务A节点中在一个JVM中重入了4次后,调用一次 RPC服务,RPC服务中同样重入锁,此时,锁重入次数是 5,这个时候A服务宕机,就意味着无论怎样,这把锁不可能释放了,这个分布式锁提供的完整能力,全线不可用了!

所以,这几个 key是要设置超时时间的!

怎么设置?我上一个锁要维护这么多 key的超时时间?怎么办?

我们想一下,是不是最大超时时间的 key和重入次数的 key,都附属于锁,它们都是锁的属性,如果锁不在了,谈它们就毫无意义,这个时候用什么存储呢?

redis的 hash数据结构,就可以做,key是锁,里边的 hashKey分别是锁的属性, hashValue是属性值,超时时间只设置锁本身 key就可以了。这个时候,我们的锁的数据结构改变一下即可!

重新思索考下全过程后,我们再次思考每一步可能出现问题的地方。

超时时间设置过短?

设置超时时间那里,我们预估锁方法执行时间是 200ms,我们放大 5倍后,设置超时时间是 1s,假想一下,如果生产环境中,锁方法中的 IO操作,极端情况下超时严重,比方说 IO就消耗了 2s,那就意味着,在这次 IO还没有结束的时候,我这把锁已经到期释放掉了,就意味着别的线程趁虚而入,分布式锁崩溃。

这个时候有无解决方案?

锁监控

我们要做的是一把分布式锁,想要的目的是同一时刻只有一个线程持有锁,作为服务而言,这个锁现在不管是被哪个线程上锁成功了,我服务应该保证这个线程执行的安全性,怎么办?

思路打开,跟生命周期有关的,最常用的还有一个东西是token。我们在会话的有效期内,为了提高用户体验感,一般都会做的一个事情是token续期、token刷新。同样的我们是不是可以对锁进行续期?

一旦这把锁出现了上锁操作,就意味着这把锁开始投入使用,这时我的服务中专门搞一个线程定时去守护我的锁的安全性。

比如说锁超时时间设置的是 1s,那么我这个定时任务是每隔 300ms去 redis服务端做一次检查,如果我还持有,你就给我续期。

这就行吗?还有没有其他的问题?

如果每个服务中都像这样去续期锁,假如说A服务还在执行过程中的时候,还没有执行完,就是说还没有手动释放锁的时候,宕机,此时 redis中锁还在有效期。

服务B 也一直在续期这把锁,此时这把锁一直在续期,但是 B的这个续期一直续的是 A当时设的锁,这合理吗?我自己在不断尝试,导致我的服务上一直获取不到锁,实际上A已经宕机了呀!B的续期毫无意义。

所以,续期的前提是,得判断是不是当前进程持有的锁,也就是我们的 APP_ID,如果不是就不进行续期。

redis本身的业务场景下的问题

哨兵主从部署的时候,会存在一个风险问题,因为 Redis默认的主从复制是异步的,那很自然可以想到一个问题,极端情况下,如果刚往 master节点写入一个分布式锁,而这个指令流还没有来得及同步给任意一个 slave节点,此时,master节点宕机,其中一个 slave被哨兵选举为 master,此时是没有这个锁的,别的线程再次来获取锁,又获取锁成功了。结果很有节能残生脏数据等等。

当然,这个概率极低,但是我们必须得承认这个风险的存在。其实,即使 redis部署是单节点的话也会存在问题,如果 redis.conf的相关持久化机制不合理,另外操作系统再配置一些影响参数,都会造成未持久化到磁盘中时,发生宕机且数据丢失等等问题。

我们还能做什么?

整个思路下来,是否觉得还有自己能做的事情呢?

即便我设置了一个很合理的 expire,比如 10s,但是线上如果真出现了A节点刚拿到锁就宕机了,那其他节点也只能干等着 10s之后再去干活了。而如果是 To C的业务中,大部分场景无法接受这种情况的。

所以我们需要另外一个监控服务,定时去监控 redis中锁的获得者的健康状态,如果获取者超过n次无法通信,由监控服务负责将锁摘除掉,让别的线程继续去获取到锁去干活。

当然,这又引入了通信保证性的问题,如果监控服务和服务节点之间通信出现问题,那将导致很严重的后果。具体业务具体应用吧。目前监控这边我了解还不够。

实践和总结

基于以上思路,再去看目前的实现方式就很好理解了。

原理简介

redis 获取分布式锁使用lua脚本的命令

●setnx

●pexpire(提供了毫秒的过期时间,expire提供了基于秒的过期时间)

●lua脚本(保证脚本中的命令被一起执行 不间断)

redis删除锁使用lua脚本的命令

●先执行get

●判断获取的值是否是自己设置的

●如果是的话 则执行del操作

●lua脚本(保证脚本中的命令被一起执行 不间断)

lua脚本

加锁

if (redis.call('exists', KEYS[1]) == 0)
then
	redis.call('hset', KEYS[1], ARGV[2], 1);
	redis.call('pexpire', KEYS[1], ARGV[1]);
	return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) >= 1) 
then
	redis.call('hincrby', KEYS[1], ARGV[2], 1);
	redis.call('pexpire', KEYS[1], ARGV[1]);
	return nil;
end;
return redis.call('pttl', KEYS[1]);

那么,这段lua脚本是什么意思呢?

KEYS[1]代表的是你加锁的那个key,比如说:redisLock.getLock("myLock");

这里你自己设置了加锁的那个锁key就是“myLock”。

ARGV[1]代表的就是锁key的默认生存时间,默认30秒。

ARGV[2]代表的是加锁的客户端的ID,类似于:8743c9c0-0795-4907-87fd-6c719a6b4586:1

第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁。用命令:hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1 即可实现加锁。

通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:

myLock : { 
  "8743c9c0-0795-4907-87fd-6c719a6b4586:1" : 1 
}

上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。

接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。

好了,到此为止,ok,加锁完成了。

锁互斥机制

在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?

第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。

接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。

所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。

此时客户端2会进入一个while循环,不停的尝试加锁。

watch dog自动延期机制

客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?

只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。

可重入加锁机制

那如果客户端1都已经持有了这把锁了,结果可重入的加锁会怎么样呢?

Lock lock = redisLock.getLock("myLock");
lock.lock();
//业务代码
lock.lock();
//业务代码
lock.unlock();
lock.unlock();

这时我们来分析一下上面那段lua脚本。

第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。

第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”

此时就会执行可重入加锁的逻辑:incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1

通过这个命令,对客户端1的加锁次数,累加1。

此时myLock数据结构变为下面这样:

myLock : { 
  "8743c9c0-0795-4907-87fd-6c719a6b4586:1" : 2
}

释放锁

local exists = redis.call('hget', KEYS[1], ARGV[1]); 
if (exists == nil or exists == 0) 
then 
 return 0; 
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
if (counter <= 0) 
then
	redis.call('del', KEYS[1]);
	return 0;
else
	return counter; 
end; 
return nil

锁释放其实就是每次都对myLock数据结构中的那个加锁次数减1。

如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:

“del myLock”命令,从redis里删除这个key。

这个时候另外的客户端2就可以尝试完成加锁了。

重点:释放锁操作是需要向所有的节点发起释放锁操作。

Redisson(Red Lock-红锁 实现分布式锁)

一个单点的redis就可以实现分布式锁了,但是,为了保证redis的高可用,不可能使用单点的redis,一般会使用集群模式

Redis集群方式共有三种:主从模式,哨兵模式,cluster(集群)模式

但是,在集群模式下也会出现一些问题,由于节点之间是采用异步通信.

如果你刚才在master节点上加了锁,但数据又没有同步到slaver节点,而此时的master节点正好挂掉了,它上面的锁就木得了,等到新的master起来的时候,(主从模式的手动切换或者哨兵模式的一次 failover 的过程),就有可能再起获取到同样的锁,出现了一个锁被拿了2次的情况

锁都被拿了两次了,也就不满足安全性了。一个安全的锁,不管是不是分布式的,在任意一个时刻,都只有一个客户端持有。

Red Lock(红锁)介绍

为了解决上面的问题,Redis 的作者提出了名为 Redlock 的算法。

在 Redis 的分布式环境中,我们假设有 N 个 RedisMaster。这些节点完全互相独立不存在主从复制或者其他集群协调机制

我们假设有 5 个完全独立的 Redis Master 节点,他们分别运行在 5 台服务器中,可以保证他们不会同时宕机。

从官网上我们可以知道,一个客户端如果要获得锁,必须经过下面的五个步骤【http://redis.cn/topics/distlock.html】:

1.获取当前unix时间,已毫秒为单位。此为时间1

  1. 依次从N个实例(这例子是5个)中,使用相同的key和随机的value去获取锁.

注意:在这个步骤里,为redis设置锁时,要设置一个网络连接和响应超时时间。

例如:你的锁的失效时间是10秒,则响应超时时间应该设置在5-50毫秒(ms)之间。这样的话可以避免服务器已经挂了,但是客户端还傻傻地在等服务器响应。如果服务器没有在规定的时间内响应,客户端就应该尽快向下一个redis实例进行尝试。

3.客户端使用当前时间减去开始获取锁的时间1就可以得到获取锁的时间2,当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。

4.注意(重点):如果取得了锁,key的真正有效时间是有效时间减去获取锁用去的时间2

5.如果由于某些原因导致我们获取锁失败,(没有从至少 (n/2)+1个redis实例取得锁或者取锁的时间已经超过了有效时间),客户端应该在所有的实例上面进行解锁(即使这些实例并没有成功获取到锁)

通过上面的步骤我们可以知道,只要大多数的节点可以正常工作,就可以保证 Redlock 的正常工作。这样就可以解决前面单点 Redis 的情况下我们讨论的节点挂掉,由于异步通信,导致锁失效的问题。

但是,这还是不能解决故障重启后带来的安全性问题.如下面的场景:

我们有A,B,C 3个节点

1.客户端1在 A , B上加锁成功了,但在C上失败

2.这个时候节点B挂了,而且由于持久化策略,导致客户端1在B上加的锁没有被持久化下来.而当节点B重新上线后,客户端2此时申请同一把锁,而且在节点B,C上加锁成功(此时它向A申请锁是必然失败的)

3.这个时候就出现了同一把锁同时被客户端1和客户端2同时持有

为何持久化策略无法避免重启的数据丢失?

在redis中,如果是采用aof默认情况下是每秒写一次磁盘,即fsync操作,因此最坏的情况下可能会丢失一秒的数据

当然你也可以用(fsync=always)即每次操作都写入一次磁盘,但这样的话会严重影响redis的性能,违反了原来的设计理念(不会真有人用fsync=always 吧???)

另外,即使你使用了fsync=always,由于实际的系统环境是极其复杂的(如网络延迟等),这都已经脱离 Redis 的范畴了。上升到服务器、系统问题了。

所以总的来说:由于节点重启引发的锁失效问题,总是有可能出现的(墨菲定律)。

延迟重启(delayed restarts)

为了解决这一问题,Redis 的作者又提出了延迟重启(delayed restarts)的概念。

意思就是当一个节点挂了,不要立即重启它,而是要等待一定的时间(等它凉透了?)再重启,而且等待时间应该大于锁的过期时间(TTL).这样的目的是保证这个节点在重启之前参与过的所有锁都已经过期了.

但是有个问题:在等待期间,该节点是不对外工作的,所以如果大多数的节点都挂掉了,进入了等待,就会导致了系统不可用,因为在此期间,任何锁都没有办法成功被加锁。

转载时必须以链接形式注明原始出处及本声明

扫描关注公众号