Redis分布式锁

基于Redis的分布式锁

一、实现原理

1.1基本原理

JDK原生的锁可以让不同线程之间以互斥的方式来访问共享资源,但如果想要在不同进程之间以互斥的方式来访问共享资源,JDK原生的锁就无能为力了。此时可以使用Redis来实现分布式锁。

Redis实现分布式锁的核心命令如下:

SETNX key value

SETNX命令的作用是:如果指定的key不存在,则创建并为其设置值,然后返回状态码1;如果指定的key存在,则直接返回0。如果返回值为1,代表获得该锁;此时其他进程再次尝试创建时,由于key已经存在,则都会返回0 ,代表锁已经被占用。

当获得锁的进程处理完成业务后,再通过 del 命令将该key删除,其他进程就可以再次竞争性地进行创建,获得该锁。

通常为了避免死锁,我们会为锁设置一个超时时间,在Redis中可以通过 expire 命令来进行实现:

EXPIRE key seconds

这里我们将两者结合起来,并使用Jedis客户端来进行实现,其代码如下:

Long result = jedis.setnx("lockKey", "lockValue");
if (result == 1) {
    // 如果此处程序被异常终止(如直接kill -9进程),则设置超时的操作就无法进行,该锁就会出现死锁
    jedis.expire("lockKey", 3);
}

上面的代码存在原子性问题,即setnx + expire操作是非原子性的,如果在设置超时时间前,程序被异常终止,则程序就会出现死锁。此时可以将SETNXEXPIRE两个命令写在同一个Lua脚本中,然后通过调用Jediseval() 方法来执行,并由Redis来保证整个Lua脚本操作的原子性。这种方式实现比较繁琐,因此官方文档中推荐了另外一种更加优雅的实现方法:

1.2官方推荐

[官方文档]( Distributed locks with Redis)中推荐直接使用set命令来进行实现:

SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]

这里我们主要关注以下四个参数:

  • EX :设置超时时间,单位是秒;
  • PX :设置超时时间,单位是毫秒;
  • NX :当且仅当对应的Key不存在时才进行设置;
  • XX:当且仅当对应的Key存在时才进行设置。

这四个参数从Redis 2.6.12版本开始支持,因为当前大多数在用的Redis都已经高于这个版本,所以推荐直接使用该命令来实现分布式锁。对应的Jedis代码如下:

jedis.set("lockKey", "lockValue", SetParams.setParams().nx().ex(3));

此时一条命令就可以完成值和超时时间的设置,并且因为只有一条命令,因此其原子性也得到了保证。但因为引入了超时时间来避免死锁,同时也引出了其它两个问题:

  • 问题一:当业务处理的时间超过过期时间后(图中进程A,由于锁已经被释放,此时其他进程就可以获得该锁(图中进程B,这意味着有两个进程(AB)同时进入了临界区,此时分布式锁就失效了;
  • 问题二:如上图所示,当进程A业务处理完成后,此时删除的是进程B的锁,进而导致分布式锁又一次失效,让进程B和 进程C同时进入了临界区。

针对问题二,我们可以在创建锁时为其指定一个唯一的标识作为KeyValue,这里假设我们采用 UUID + 线程ID 来作为唯一标识:

String identifier = UUID.randomUUID() + ":" + Thread.currentThread().getId();
jedis.set("LockKey", identifier, SetParams.setParams().nx().ex(3));

然后在删除锁前,先将该唯一标识与锁的Value值进行比较,如果不相等,证明该锁不属于当前的操作对象,此时不执行删除操作。为保证判断操作和删除操作整体的原子性,这里需要使用Lua脚本来执行:

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

这段脚本的意思是如果value的值与给定的值相同,则执行删除命令,否则直接返回状态码0 。对应使用Jedis实现的代码如下:

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script,
           Collections.singletonList("LockKey"),  // keys的集合
           Collections.singletonList(identifier)  // args的集合
          );

接着再看问题一,问题一最简单的解决方法是:你可以估计业务的最大处理时间,然后保证设置的过期时间大于最大处理时间。但是由于业务会面临各种复杂的情况,因此可能无法保证业务每一次都能在规定的过期时间内处理完成,此时可以使用延长锁时效的策略。

1.3延长锁时效

延长锁时效的方案如下:假设锁超时时间是30秒,此时程序需要每隔一段时间去扫描一下该锁是否还存在,扫描时间需要小于超时时间,通常可以设置为超时时间的1/3,在这里也就是10秒扫描一次。如果锁还存在,则重置其超时时间恢复到30秒。通过这种方案,只要业务还没有处理完成,锁就会一直有效;而当业务一旦处理完成,程序也会马上删除该锁。

RedisJava客户端Redisson提供的分布式锁就支持类似的延长锁时效的策略,称为WatchDog,直译过来就是 “看门狗” 机制。

以上讨论的都是单机环境下的Redis分布式锁,而想要保证Redis分布式锁是高可用,首先Redis得是高可用的,Redis的高可用模式主要有两种:哨兵模式和集群模式。以下分别进行讨论:

二、哨兵模式与分布式锁

哨兵模式是主从模式的升级版,能够在故障发生时自动进行故障切换,选举出新的主节点。但由于Redis的复制机制是异步的,因此在哨兵模式下实现的分布式锁是不可靠的,原因如下:

  • 由于主从之间的复制操作是异步的,当主节点上创建好锁后,此时从节点上的锁可能尚未创建。而如果此时主节点发生了宕机,从节点上将不会创建该分布式锁;
  • 从节点晋升为主节点后,其他进程(或线程)仍然可以在该新主节点创建分布式锁,此时就存在多个进程(或线程)同时进入了临界区,分布式锁就失效了。

因此在哨兵模式下,无法避免锁失效的问题。因此想要实现高可用的分布式锁,可以采取Redis的另一个高可用方案 —— Redis集群模式。

三、集群模式与分布式锁

3.1 RedLock方案

想要在集群模式下实现分布式锁,Redis提供了一种称为RedLock的方案,假设我们有NRedis实例,此时客户端的执行过程如下:

  • 以毫秒为单位记录当前的时间,作为开始时间;
  • 接着采用和单机版相同的方式,依次尝试在每个实例上创建锁。为了避免客户端长时间与某个故障的Redis节点通讯而导致阻塞,这里采用快速轮询的方式:假设创建锁时设置的超时时间为10秒,则访问每个Redis实例的超时时间可能在550毫秒之间,如果在这个时间内还没有建立通信,则尝试连接下一个实例;
  • 如果在至少N/2+1个实例上都成功创建了锁。并且 当前时间 - 开始时间 < 锁的超时时间 ,则认为已经获取了锁,锁的有效时间等于 超时时间 - 花费时间(如果考虑不同Redis实例所在服务器的时钟漂移,则还需要减去时钟漂移
  • 如果少于N/2+1个实例,则认为创建分布式锁失败,此时需要删除这些实例上已创建的锁,以便其他客户端进行创建。
  • 该客户端在失败后,可以等待一个随机的时间后,再次进行重试。

以上就是RedLock的实现方案,可以看到主要是由客户端来实现的,并不真正涉及到Redis集群相关的功能。因此这里的NRedis实例并不要求是一个真正的Redis集群,它们彼此之间可以是完全独立的,但由于只需要半数节点获得锁就能真正获得锁,因此其仍然具备容错性和高可用性。后面使用Redisson来演示RedLock时会再次验证这一点。

3.2低延迟通讯

另外实现RedLock方案的客户端与所有Redis实例进行通讯时,必须要保证低延迟,而且最好能使用多路复用技术来保证一次性将SET命令发送到所有Redis节点上,并获取到对应的执行结果。如果网络延迟较高,假设客户端AB都在尝试创建锁:

SET key 随机数A EX 3 NX  #A客户端
SET key 随机数B EX 3 NX  #B客户端

此时可能客户端A在一半节点上创建了锁,而客户端B在另外一半节点上创建了锁,那么两个客户端都将无法获取到锁。如果并发很高,则可能存在多个客户端分别在部分节点上创建了锁,而没有一个客户端的数量超过N/2+1。这也就是上面过程的最后一步中,强调一旦客户端失败后,需要等待一个随机时间后再进行重试的原因,如果是一个固定时间,则所有失败的客户端又同时发起重试,情况就还是一样。

因此最佳的实现就是客户端的SET命令能几乎同时到达所有节点,并几乎同时接受到所有执行结果。 想要保证这一点,低延迟的网络通信极为关键,下文介绍的Redisson就采用Netty框架来保证这一功能的实现。

3.3持久化与高可用

为了保证高可用,所有Redis节点还需要开启持久化。假设不开启持久化,假设进程A获得锁后正在处理业务逻辑,此时节点宕机重启,因为锁数据丢失了,其他进程便可以再次创建该锁,因此所有Redis节点都需要开启AOF的持久化方式。

AOF默认的同步机制为 everysec,即每秒进程一次持久化,此时能够兼顾性能与数据安全,发生意外宕机的时,最多会丢失一秒的数据。但如果碰巧就是在这一秒的时间内进程A创建了锁,并由于宕机而导致数据丢失。此时其他进程还可以创建该锁,锁的互斥性也就失效了。想要解决这个问题有两种方式:

  • 方式一:修改Redis.confappendfsync 的值为 always,即每次命令后都进行持久化,此时会降低Redis性能,进而也会降低分布式锁的性能,但锁的互斥性得到了绝对的保证;
  • 方式二:一旦节点宕机了,需要等到锁的超时时间过了之后才进行重启,此时相当于原有锁自然失效(但你首先需要保证业务能在设定的超时时间内完成,这种方案也称为延时重启。

Links

下一页