单体架构锁到分布式锁过程分析
在单体架构中,要保证线程安全问题,Java语言一般是通过Synchronized关键字,通过Java对象进行加锁的隐式锁;或者JUC包下AQS原理的显示锁方案。
Synchronized、ReetrantLock是如何解决单体程序的线程安全问题的呢?
-
Synchronized:
-
依赖堆中对象的Monitor监视器
-
通过操作监视器中的_count字段实现互斥
-
-
ReetrantLock:
-
依赖于AQS同步器
-
通过操作AQS中volatile修饰的state成员实现互斥
-
它们有什么共同点呢?都存在一个互斥量,并且互斥量都是所有线程可见的。
分布式架构下Synchronized、ReetrantLock存在的问题
分布式环境中,会有多个应用程序在运行,会有多个JVM进程;互斥量所在的区域对于其他进程中的线程来说是不可见的。比如Synchronized关键字通过某个Class对象作为锁对象,一个堆空间中的Class对象对于当前进程中的所有线程来说是可见的,但是对于其他进程的线程是不可见的。ReetrantLock也是同理,volatile修饰的state变量,对于当前进程中的所有线程可见,但对于另外进程中的线程是不可见的。
那么此时想要解决分布式情况下的线程安全问题的思路是不是明了啦?
只需要找一个多个进程之间所有线程可见的区域实现这个互斥量即可。
分布式锁必备的条件
仅仅找一个集中式的地方存储互斥量肯定是不行的,因为一个优秀的分布式锁的实现方案还应该满足如下几个特性:
-
①在分布式环境中,可以保证不同进程之间的线程互斥
-
②在同一时刻,同时只允许一条线程成功获取到锁资源
-
③保存互斥量的地方需要保证高可用性
-
④要保证可以高性能的获取锁与释放锁
-
⑤可以支持同一线程的锁重入性
-
⑥具备合理的阻塞机制,竞争锁失败的线程也有处理方案
-
⑦支持非阻塞式获取锁,获取锁失败的线程可以直接返回
-
⑧具备合理的锁失效机制,如超时失效等,可以确保避免死锁情况出现
那么目前市面上对于分布式锁的成熟方案有哪些呢?
-
①基于DB实现
-
②基于Redis实现
-
③基于Zookeeper实现
基于数据库实现的,所以获取锁、释放锁等操作都要涉及到数据落盘、删盘等磁盘IO操作,性能方面不值得考虑。所以主要学习基于Redis和Zookeeper的方式实现的分布式锁。
无锁的原子操作:Redis如何应对并发访问?
在使用 Redis 时,不可避免地会遇到并发访问的问题;为了保证并发访问的正确性,Redis 提供了两种方法,分别是加锁和****原子操作。 加锁是一种常用的方法,在读取数据前,客户端需要先获得锁,否则就无法进行操作。当一个客户端获得锁后,就会一直持有这把锁,直到客户端完成数据更新,才释放这把锁。看上去好像是一种很好的方案,但是,其实这里会有两个问题:一个是,如果加锁操作多,会降低系统的并发访问性能;第二个是,Redis 客户端要加锁时,需要用到分布式锁,而分布式锁实现复杂,需要用额外的存储系统来提供加解锁操作。
**原子操作是另一种提供并发访问控制的方法。**原子操作是指执行过程保持原子性的操作,而且原子操作执行时并不需要再加锁,实现了无锁操作。这样一来,既能保证并发控制,还能减少对系统并发性能的影响。
并发访问中需要对什么进行控制?
并发访问控制,是指对多个客户端访问操作同一份数据的过程进行控制,以保证任何一个客户端发送的操作在 Redis 实例上执行时具有互斥性。例如,客户端 A 的访问操作在执行时,客户端 B 的操作不能执行,需要等到 A 的操作结束后,才能执行。 并发访问控制对应的操作主要是数据修改操作。当客户端需要修改数据时,基本流程分成两步:
-
客户端先把数据读取到本地,在本地进行修改;
-
客户端修改完数据后,再写回 Redis。
把这个流程叫做"读取 - 修改 - 写回"操作(Read-Modify-Write,简称为 RMW 操作)。当有多个客户端对同一份数据执行 RMW 操作,就需要让 RMW 操作涉及的代码以原子性方式执行。访问同一份数据的 RMW 操作代码叫做临界区代码。 当有多个客户端并发执行临界区代码时,会存在一些潜在问题:
客户端 A 在 t1 时读取库存值 10 并扣减 1,在 t2 时,客户端 A 还没有把扣减后的库存值 9 写回 Redis,而此时,客户端 B 读到库存值 10,也扣减了 1,B 记录的库存值也为 9 了。等到 t3 时,A 往 Redis 写回了库存值 9,到 t4 时,B 也写回了库存值 9。 如果按正确的逻辑处理,客户端 A 和 B 对库存值各做了一次扣减,库存值应该为 8;这里的库存值明显更新错了。
出现这个现象的原因是,临界区代码中的客户端读取数据、更新数据、再写回数据涉及了三个操作,而这三个操作在执行时并不具有互斥性,多个客户端基于相同的初始值进行修改,而不是基于前一个客户端修改后的值再修改。为了保证数据并发修改的正确性,我以用锁把并行操作变成串行操作,串行操作就具有互斥性。一个客户端持有锁后,其他客户端只能等到锁释放,才能拿锁再进行修改。 虽然加锁保证了互斥性,但是加锁也会导致系统并发性能降低。 和加锁类似,原子操作也能实现并发控制,但是原子操作对系统并发性能的影响较小。
Redis 的两种原子操作方法
为了实现并发控制要求的临界区代码互斥执行,Redis 的原子操作采用了两种方法:
-
把多个操作在 Redis 中实现成一个操作,也就是单命令操作;
-
把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。
Redis 本身的单命令操作
**Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。**当然,Redis 的快照生成、AOF 重写这些操作,可以使用后台线程或者是子进程执行,也就是和主线程的操作并行执行。不过,这些操作只是读取数据,不会修改数据,所以,并不需要对它们做并发控制。 虽然 Redis 的单个命令操作可以原子性地执行,但是在实际应用中,数据修改时可能包含多个操作,至少包括读数据、数据增减、写回数据三个操作,这显然就不是单个命令操作了,那该怎么办呢?
Redis 提供了 INCR/DECR 命令,把这三个操作转变为一个原子操作了。INCR/DECR 命令可以对数据进行增值 / 减值操作,而且它们本身就是单个命令操作,Redis 在执行它们时,本身就具有互斥性。
eg: 对商品 id 的库存值减 1 操作
bash
DECR id
如果执行的 RMW 操作是对数据进行增减值,Redis 提供的原子操作 INCR 和 DECR 可以直接进行并发控制。但是,如果要执行的操作不是简单地增减数据,而是有更加复杂的判断逻辑或者是其他操作,Redis 的单命令操作已经无法保证多个操作的互斥执行了。这时需要使用第二个方法:Lua 脚本。
Lua脚本
Redis 把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。如果有多个操作要执行,但是又无法用 INCR/DECR 这种命令操作来实现,就可以把这些要执行的操作编写到一个 Lua 脚本中。然后,使用 Redis 的 EVAL 命令来执行脚本。这样,这些操作在执行时就具有了互斥性。
案例: 当一个业务应用的访问用户增加时,有时需要限制某个客户端在一定时间范围内的访问次数,比如爆款商品的购买限流、社交网络中的每分钟点赞次数限制等。
可以把客户端 IP 作为 key,把客户端的访问次数作为 value,保存到 Redis 中。客户端每访问一次后,就用 INCR 增加访问次数。不过,在这种场景下,客户端限流其实同时包含了对访问次数和时间范围的限制,例如每分钟的访问次数不能超过 20。所以,可以在客户端第一次访问时,给对应键值对设置过期时间,例如设置为 60s 后过期。同时,在客户端每次访问时,读取客户端当前的访问次数,如果次数超过阈值,就报错,限制客户端再次访问。
在这个例子中,已经使用了 INCR 来原子性地增加计数。但是,客户端限流的逻辑不只有计数,还包括访问次数判断和过期时间设置。 对于这些操作,同样需要保证它们的原子性。否则,如果客户端使用多线程访问,访问次数初始值为 0,第一个线程执行了 INCR(ip) 操作后,第二个线程紧接着也执行了 INCR(ip),此时,ip 对应的访问次数就被增加到了 2,就无法再对这个 ip 设置过期时间了。这样就会导致,这个 ip 对应的客户端访问次数达到 20 次之后,就无法再进行访问了。即使过了 60s,也不能再继续访问,显然不符合业务要求。所以,这个例子中的操作无法用 Redis 单个命令来实现,此时,就可以使用 Lua 脚本来保证并发控制。可以把访问次数加 1、判断访问次数是否为 1,以及设置过期时间这三个操作写入一个 Lua 脚本,如下所示:
假设编写的脚本名称为 lua.script,我们接着就可以使用 Redis 客户端,带上 eval 选项,来执行该脚本。脚本所需的参数将通过以下命令中的 keys 和 args 进行传递。
perl
redis-cli --eval lua.script keys , args
这样,访问次数加 1、判断访问次数是否为 1,以及设置过期时间这三个操作就可以原子性地执行了。即使客户端有多个线程同时执行这个脚本,Redis 也会依次串行执行脚本代码,避免了并发操作带来的数据错误。
Lua脚本使用注意:
-
Redis 的 Lua 脚本可以包含多个操作,这些操作都会以原子性的方式执行,绕开了单命令操作的限制。不过,如果把很多操作都放在 Lua 脚本中原子执行,会导致 Redis 执行脚本的时间增加,同样也会降低 Redis 的并发性能。建议:在编写 Lua 脚本时,要避免把不需要做并发控制的操作写入脚本中。
-
lua 脚本尽量只编写通用的逻辑代码,避免直接写死变量。变量通过外部调用方传递进来,这样 lua 脚本的可复用度更高。
-
建议先使用SCRIPT LOAD命令把 lua 脚本加载到 Redis 中,得到一个脚本唯一摘要值,再通过EVALSHA命令 + 脚本摘要值来执行脚本,这样可以避免每次发送脚本内容到 Redis,减少网络开销。
如何使用Redis实现分布式锁?
在应对并发问题时,除了原子操作,Redis 客户端还可以通过加锁的方式,来控制并发写操作对共享数据的修改,从而保证数据的正确性。但是,Redis 属于分布式系统,当有多个客户端需要争抢锁时,必须要保证,**这把锁不能是某个客户端本地的锁。**否则其它客户端是无法访问这把锁的,当然也就不能获取这把锁了。在分布式系统中,当有多个客户端需要获取锁时,需要分布式锁。此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁。而且 Redis 的读写性能高,可以应对高并发的锁操作场景。
单机上的锁和分布式锁的联系与区别
对于在单机上运行的多线程程序来说,锁本身可以用一个变量表示:
-
变量值为 0 时,表示没有线程获取锁;
-
变量值为 1 时,表示已经有线程获取到锁了。
实际上,一个线程调用加锁操作,其实就是检查锁变量值是否为 0。如果是 0,就把锁的变量值设置为 1,表示获取到锁,如果不是 0,就返回错误信息,表示加锁失败,已经有别的线程获取到锁了。而一个线程调用释放锁操作,其实就是将锁变量的值置为 0,以便其它线程可以来获取锁。
和单机上的锁类似,分布式锁****同样可以用一个变量来实现 。客户端加锁和释放锁的操作逻辑,也和单机上的加锁和释放锁操作逻辑一致:**加锁时同样需要判断锁变量的值,根据锁变量值来判断能否加锁成功;释放锁时需要把锁变量值设置为 0,表明客户端不再持有锁。但是,和线程在单机上操作锁不同的是,在 分布式****场景下,锁变量需要由一个共享存储系统来维护,**只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。(Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁。而且 Redis 的读写性能高,可以应对高并发的锁操作场景 )相应的,加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值。
这样就可以得出实现分布式锁的两个要求。
**要求一:**分布式锁的加锁和释放锁的过程,涉及多个操作。在实现分布式锁时,需要保证这些锁操作的原子性;
**要求二:**共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。在实现分布式锁时,需要考虑保证共享存储系统的可靠性,进而保证锁的可靠性。
基于单个 Redis 节点实现分布式锁
作为分布式锁实现过程中的共享存储系统,Redis 可以使用键值对来保存锁变量,再接收和处理不同客户端发送的加锁和释放锁的操作请求。 赋予锁变量一个变量名,把这个变量名作为键值对的键,而锁变量的值,则是键值对的值,这样Redis 就能保存锁变量了,客户端也就可以通过 Redis 的命令操作来实现锁操作。假设客户端 A 和 C 同时请求加锁。因为 Redis 使用单线程处理请求,所以,即使客户端 A 和 C 同时把加锁请求发给了 Redis,Redis 也会串行处理它们的请求。 设 Redis 先处理客户端 A 的请求,读取 lock_key 的值,发现 lock_key 为 0,所以,Redis 就把 lock_key 的 value 置为 1,表示已经加锁了。紧接着,Redis 处理客户端 C 的请求,此时,Redis 会发现 lock_key 的值已经为 1 了,所以就返回加锁失败的信息。刚刚说的是加锁的操作,那释放锁该怎么操作呢?其实,释放锁就是直接把锁变量值设置为 0。
加锁包含了三个操作(读取锁变量、判断锁变量值以及把锁变量值设置为 1),而**这三个操作在执行时需要保证原子性。要想保证操作的原子性,有两种通用的方法,分别是使用 Redis 的单命令操作和使用 Lua 脚本。**那么,在分布式加锁场景下,该怎么应用这两个方法呢?
Redis 用单命令操作实现加锁操作
SETNX 命令,它用于设置键值对的值。命令在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。eg: 如果执行下面的命令时,key 不存在,那么 key 会被创建,并且值会被设置为 value;如果 key 已经存在,SETNX 不做任何赋值操作。
vbnet
SETNX key value
对于释放锁操作来说,可以在执行完业务逻辑后,使用 DEL 命令删除锁变量。不用担心锁变量被删除后,其他客户端无法请求加锁了。因为 SETNX 命令在执行时,如果要设置的键值对(也就是锁变量)不存在,SETNX 命令会先创建键值对,然后设置它的值。所以,释放锁之后,再有客户端请求加锁时,SETNX 命令会创建保存锁变量的键值对,并设置锁变量的值,完成加锁。总结来说,可以用 SETNX 和 DEL 命令组合来实现加锁和释放锁操作。
less
// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key
使用 SETNX 和 DEL 命令组合实现分布锁,存在两个潜在的风险。
第一个风险:宕机/重启死锁
假如某个客户端在执行了 SETNX 命令、加锁之后,紧接着却在操作共享数据时发生了异常(所在服务器宕机/重启),结果一直没有执行最后的 DEL 命令释放锁。因此,锁就一直被这个客户端持有,其它客户端无法拿到锁,也无法访问共享数据和执行后续操作,这会给业务应用带来影响。
针对这个问题,一个有效的解决方法是,给锁变量设置一个过期时间。这样即使持有锁的客户端发生了异常,无法主动地释放锁,Redis 也会根据锁变量的过期时间,在锁变量过期后,把它删除。其它客户端在锁变量过期后,就可以重新请求加锁,这就不会出现其它客户端无法加锁的问题了。
第二个风险:
如果客户端 A 执行了 SETNX 命令加锁后,假设客户端 B 执行了 DEL 命令释放锁,此时,客户端 A 的锁就被误释放了。如果客户端 C 正好也在申请加锁,就可以成功获得锁,进而开始操作共享数据。这样一来,客户端 A 和 C 同时在对共享数据进行操作,数据就会被修改错误,这也是业务层不能接受的。
应对这个问题,需要能区分来自不同客户端的锁操作。 在加锁操作时,可以让每个客户端给锁变量设置一个唯一值,这里的唯一值就可以用来标识当前操作的客户端。在释放锁操作时,客户端需要判断,当前锁变量的值是否和自己的唯一标识相等,只有在相等的情况下,才能释放锁。这样一来,就不会出现误释放锁的问题了。
刚刚在说 SETNX 命令的时候提到,对于不存在的键值对,它会先创建再设置值(也就是"不存在即设置"),为了能达到和 SETNX 命令一样的效果,Redis 给 SET 命令提供了类似的选项 NX **,用来实现"不存在即设置"。如果使用了 NX 选项,SET 命令只有在键值对不存在时,才会进行设置,否则不做赋值操作。此外,SET 命令在执行时还可以带上 EX 或** PX **选项,用来设置键值对的过期时间。**eg: 执行下面的命令时,只有 key 不存在时,SET 才会创建 key,并对 key 进行赋值。另外,key 的存活时间由 seconds 或者 milliseconds 选项值来决定。
css
SET key value [EX seconds | PX milliseconds] [NX]
有了 SET 命令的 NX 和 EX/PX 选项后,就可以用下面的命令来实现加锁操作了。
arduino
// 加锁, unique_value作为客户端唯一性的标识SET
lock_key unique_value NX PX 10000
那在Java程序中又该如何修改代码呢?实则非常简单,在SpringBoot
整合Redis
的模板中,只需要把如上代码稍微修改一下即可。如下:
ini
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "zx-ting",timeOut,TimeUnit.SECONDS);
u**nique_value 是客户端的唯一标识,可以用一个随机生成的字符串来表示,**PX 10000 则表示 lock_key 会在 10s 后过期,以免客户端在这期间发生异常而无法释放锁。因为在加锁操作中,每个客户端都使用了一个唯一标识,所以在释放锁操作时,需要判断锁变量的值,是否等于执行释放锁操作的客户端的唯一标识,如下所示:
kotlin
//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
使用 Lua 脚本(unlock.script)实现释放锁操作的伪代码;KEYS[1]表示 lock_key,ARGV[1]是当前客户端的唯一标识,这两个值都是在执行 Lua 脚本时作为参数传入的。最后执行下面的命令,就完成锁释放操作了。
bash
redis-cli --eval unlock.script lock_key , unique_value
在释放锁操作中,使用了 Lua 脚本,这是因为,释放锁操作的逻辑也包含了读取锁变量、判断值、删除锁变量的多个操作,而 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
- 锁过期时间不好评估怎么办?
加锁时,先设置一个过期时间,然后开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
Java 技术栈已经有一个库把这些工作都封装好了:Redisson。
Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。
现在只用了一个 Redis 实例来保存锁变量,如果这个 Redis 实例发生故障宕机了,那么锁变量就没有了。此时,客户端也无法进行锁操作了,这就会影响到业务的正常执行。所以,在实现分布式锁时,还需要保证锁的可靠性 。那怎么提高呢?基于多个 Redis 节点实现分布式锁的方式。
基于多个 Redis 节点实现高可靠的分布式锁
要实现高可靠的分布式锁时,就不能只依赖单个的命令操作了,需要按照一定的步骤和规则进行加解锁操作,否则,就可能会出现锁无法工作的情况。"一定的步骤和规则"是指啥呢?其实就是分布式锁的算法。为了避免 Redis 实例故障而导致的锁无法工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。
Redlock 的方案基于 2 个前提:
-
不再需要部署从库 和哨兵 实例,只部署主库
-
但主库要部署多个,官方推荐至少 5 个实例
也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。
**注意:**不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。
Redlock 算法的基本思路,是让客户端和**多个独立的 Redis 实例依次请求加锁,**如果客户端能够和半数以上的实例成功地完成加锁操作,就认为,客户端成功地获得分布式锁了,否则加锁失败。这样即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。Redlock 算法的实现需要有 N 个独立的 Redis 实例;可以分成 3 步来完成加锁操作。
第一步是,客户端获取当前时间。
第二步是,客户端按顺序依次向 N 个 Redis 实例执行加锁操作。
这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。当然,如果某个 Redis 实例发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,需要给加锁操作设置一个超时时间。如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
第三步是,一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。
客户端只有在满足下面的这两个条件时,才能认为是加锁成功。
-
条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;
-
条件二:客户端获取锁的总耗时没有超过锁的有效时间。
在满足了这两个条件后,需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。当然,如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端向所有 Redis 节点发起释放锁的操作。 在 Redlock 算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。这样一来,只要 N 个 Redis 实例中的半数以上实例能正常工作,就能保证分布式锁的正常工作了。所以,在实际的业务应用中,如果想要提升分布式锁的可靠性,就可以通过 Redlock 算法来实现。
- Redlock 为什么要这么做?
- 为什么要在多个实例上加锁?
本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。
- 为什么这些实例都是独立部署的,没有主从关系?
RedLock作者指出,之所以要用独立的,是避免了redis异步复制造成的锁丢失,比如:主节点没来的及把刚刚set进来这条数据给从节点,就挂了。
- 为什么大多数加锁成功,才算成功?
多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。
在分布式系统中,总会出现「异常节点」,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。
这是一个分布式系统「容错」问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。
- 为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。
所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。
- 为什么释放锁,要操作所有节点?
在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。
例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。
所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。
小结
分布式锁是由共享存储系统维护的变量,多个客户端可以向共享存储系统发送命令进行加锁或释放锁操作。Redis 作为一个共享存储系统,可以用来实现分布式锁。在基于单个 Redis 实例实现分布式锁时,对于加锁操作,需要满足三个条件。
-
加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,使用 SET 命令带上 NX 选项来实现加锁;
-
锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
-
锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端。
和加锁类似,释放锁也包含了读取锁变量值、判断锁变量值和删除锁变量三个操作,不过,无法使用单个命令来实现;可以采用 Lua 脚本执行释放锁操作,通过 Redis 原子性地执行 Lua 脚本,来保证释放锁操作的原子性。不过,基于单个 Redis 实例实现分布式锁时,会面临实例异常或崩溃的情况,这会导致实例无法提供锁操作,正因为此,Redis 也提供了 Redlock 算法,用来实现基于多个实例的分布式锁。这样一来,锁变量由多个实例维护,即使有实例发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。
Redis 分布式锁可靠性的问题
使用单个 Redis 节点(只有一个master)使用分布锁,如果实例宕机,那么无法进行锁操作了。那么采用主从集群模式部署是否可以保证锁的可靠性? 也很难保证。如果在 master 上加锁成功,此时 master 宕机,由于主从复制是异步的,加锁操作的命令还未同步到 slave,此时主从切换,新 master 节点依旧会丢失该锁,对业务来说相当于锁失效了。
因为redis主从架构中的数据不是实时复制的,而是定时/定量复制。也就代表着一条数据写入了redis主机后,并不会同时同步给所有的从机,写入的指令只要在主机上写入成功会立即返回写入成功,数据同步则是在一定时间或一定量之后才同步给从机。
如上描述的便是Redis主从架构导致的分布式锁失效问题,此时这个问题又该如何解决呢?方案如下:
-
①红锁算法:多台独立的Redis同时写入数据,锁失效时间之内,一半以上的机器写成功则返回获取锁成功,否则返回获取锁失败,失败时会释放掉那些成功的机器上的锁。
-
优点:可以完美解决掉主从架构带来的锁失效问题
-
缺点:成本高,需要线上部署多台独立的Redis节点
-
这种算法是Redis官方提出的解决方案:红锁算法
-
-
②额外记录锁状态:再通过额外的中间件等独立部署的节点记录锁状态,比如在DB中记录锁状态,在尝试获取分布式锁之前需先查询DB中的锁持有记录,如果还在持有则继续阻塞,只有当状态为未持有时再尝试获取分布式锁。
-
优点:可以依赖于项目中现有的节点实现,节约部署成本
-
缺点:
-
实现需要配合定时器实现过期失效,保证锁的合理失效机制
-
获取锁的性能方面堪忧,会大大增加获取锁的性能开销
-
所有过程都需自己实现,实现难度比较复杂
-
-
总结:这种方式类似于两把分布式锁叠加实现,先获取一把后再获取另一把
-
-
③Zookeeper实现:使用Zookeeper代替Redis实现,因为Zookeeper追求的是高稳定,所以Zookeeper实现分布式锁时,不会出现这个问题(稍后分析)
Redis 作者才提出基于多个 Redis 节点(master节点)的 Redlock 算法,但这个算法涉及的细节很多,作者在提出这个算法时,业界的分布式系统专家还与 Redis 作者发生过一场争论,来评估这个算法的可靠性,争论的细节都是关于异常情况可能导致 Redlock 失效的场景,例如加锁过程中客户端发生了阻塞、机器时钟发生跳跃等等。 感兴趣的可以看下这篇文章,详细介绍了争论的细节,以及 Redis 分布式锁在各种异常情况是否安全的分析,收益会非常大:zhangtielei.com/posts/blog-...(没看)
简单总结,基于 Redis 使用分布锁的注意点:
1、使用 SET <math xmlns="http://www.w3.org/1998/Math/MathML"> l o c k _ k e y lock\_key </math>lock_keyunique_val EX $second NX 命令保证加锁原子性,并为锁设置过期时间
2、锁的过期时间要提前评估好,要大于操作共享资源的时间
3、每个线程加锁时设置随机值,释放锁时判断是否和加锁设置的值一致,防止自己的锁被别人释放
4、释放锁时使用 Lua 脚本,保证操作的原子性
5、基于多个节点的 Redlock,加锁时超过半数节点操作成功,并且获取锁的耗时没有超过锁的有效时间才算加锁成功
6、Redlock 释放锁时,要对所有节点释放(即使某个节点加锁失败了),因为加锁时可能发生服务端加锁成功,由于网络问题,给客户端回复网络包失败的情况,所以需要把所有节点可能存的锁都释放掉
7、使用 Redlock 时要避免机器时钟发生跳跃,需要运维来保证,对运维有一定要求,否则可能会导致 Redlock 失效。例如共 3 个节点,线程 A 操作 2 个节点加锁成功,但其中 1 个节点机器时钟发生跳跃,锁提前过期,线程 B 正好在另外 2 个节点也加锁成功,此时 Redlock 相当于失效了(Redis 作者和分布式系统专家争论的重要点就在这)
8、如果为了效率,使用基于单个 Redis 节点的分布式锁即可,此方案缺点是允许锁偶尔失效,优点是简单效率高
9、如果是为了正确性,业务对于结果要求非常严格,建议使用 Redlock,但缺点是使用比较重,部署成本高
课代表文章: mp.weixin.qq.com/s/s8xjm1ZCK...
评论区推荐: mp.weixin.qq.com/s/2P2-ujcde...
Redisson框架中的分布式锁
分布式锁在工作中实际已经有框架封装了,比如:Redisson框架,其内部已经基于redis为我们封装好了分布式锁,开发过程中屏蔽了底层处理,让我们能够像使用ReetrantLock
一样使用分布式锁。
Redisson 是一个 Java 语言实现的 Redis SDK 客户端,是Redis官方推荐的客户端;提供了一个RLock的锁,RLock继承自juc的Lock接口,提供了中断,超时,尝试获取锁等操作,支持可重入,互斥等特性。
RLock底层使用Redis的Hash作为存储结构,其中Hash的key用于存储锁的名字,Hash的filed用于存储客户端id,filed对应的value是线程重入次数。
除此之外,这个 SDK 还封装了很多易用的功能:
-
可重入锁
-
乐观锁
-
公平锁
-
读写锁
-
Redlock(红锁,下面会详细讲)
这个 SDK 提供的 API 非常友好,它可以像操作本地锁的方式,操作分布式锁。如果你是 Java 技术栈,可以直接把它用起来。
这里不重点介绍 Redisson 的使用,大家可以看官方 Github 学习如何使用,比较简单。
xml
/* ---------pom.xml文件-------- */
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.8.2</version>
</dependency>
使用redission对分布式锁的使用:
csharp
// 注入redisson的客户端
@Autowiredprivate RedissonClient redisson;
// 写入redis的key值
String lockKey = "lock-" + inventory.getInventoryId();
// 获取一个Rlock锁对象
RLock lock = redisson.getLock(lockKey);
// 获取锁,并为其设置过期时间为10s
lock.lock(10,TimeUnit.SECONDS);
try{
// 执行业务逻辑....
} finally {
// 释放锁
lock.unlock();
}
除开最基本的加锁方法外,还支持其他形式的获取锁:
-
**lock.tryLock(20,10,TimeUnit.SECONDS):**非阻塞式获取锁,在获取锁失败后的20s内一直尝试重新获取锁,超出20s则直接返回获取锁失败
-
**lock.lockAsync(10,TimeUnit.SECONDS):**异步阻塞式获取锁,可以支持异步获取加锁的结果,该方法会返回一个Future对象,可通过Future对象异步获取加锁结果
-
**lock.tryLockAsync(20,10,TimeUnit.SECONDS):**异步非阻塞式获取锁,比上面那个多了一个超时时间
同时Redisson框架中的锁实现还不仅仅只有一种,如下:
-
**FairLock公平锁:**与ReetrantLock一样,支持创建公平锁,即先到的线程一定优化获取锁
-
**MultiLock连锁:**多个RLock对象组成一把锁,也就是几把锁组成的一把锁,可以用来实现红锁算法,因为RLock对象可以不是一个Redisson创建出来的,也就是可以使用多个Redis客户端的连接对象获取多把锁组成连锁,只有当所有个锁获取成功后,才能返回获取锁成功,如果获取一把个锁失败,则返回获取锁失败
-
**RedLock红锁:**和前面分析的Redis官方给出的红锁算法实现一致,继承了连锁,主要用于解决主从架构锁失效的问题
Redission中的续期机制
客户端 1 加锁的锁 key 默认生存时间才 30 秒,如果超过了 30 秒,客户端 1 还想一直持有这把锁,怎么办呢?就要使用redis的续期机制。
Redisson 通过 看门狗机制解决分布式锁的续期问题; Redisson看门狗机制, 只要客户端加锁成功,就会启动一个 Watch Dog。 看门狗机制是一个后台定时任务线程, 会每隔10秒检查一下,如果客户端还持有锁key,那么就会不断的延长锁key的生存时间。 续期原理源码底层就是用Lua脚本,将锁的时间重置为30s; 如果主动设定了锁的过期时间,Redisson 将不会开启看门狗机制。
利用了一条后台定时任务线程为分布式锁续命,同时确保了主线程意外死亡等问题造成一直续命的问题;主线程死亡那么后台定时任务线程也会跟着死亡,可以有效避免"长生锁"的现象出现。