redis并发访问的问题之实现分布式锁
在分布式系统中,当有多个客户端需要获取锁时,就需要分布式锁,此时,锁时保存在一个共享存储系统中等,可以被多个客户端共享访问和获取
Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁。
在介绍分布式锁之前要先介绍一下单机上的锁
单机上的锁:
对于在单机上运行的多线程程序来说,锁本身可以用一个变量表示。变量值为 0 时,表示没有线程获取锁;变量值为 1 时,表示已经有线程获取到锁了。实际上一个线程加锁操作就是检查锁变量的值是否为 0。如果是 0,
就把锁的变量值设置为 1,表示获取到锁,如果不是 0,就返回错误信息,表示加锁失败,已经有别的线程获取到锁了。而一个线程调用释放锁操作,其实就是将锁变量的值置为 0,以便其它线程可以来获取锁。
分布式锁同样可以用一个变量来实现。客户端加锁和释放锁的操作逻辑,也和单机上的加锁和释放锁操作逻辑一致:加锁时同样需要判断锁变量的值,根据锁变量值来判断能否加锁成功;释放锁时需要把锁变量值设置为 0,表明客户端不再持有锁。只不过这个变量需要多个redis实例共同维护。
在分布式场景下,锁变量需要由一个共享存储系统来维护,只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。相应的,加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值。
对分布式锁的两个要求:
分布式锁的加锁和释放锁的过程,涉及多个操作。所以,在实现分布式锁时,我们需要保证这些锁操作的原子性(因为释放锁的过程涉及到 读取锁,修改变量,删除锁,所以需要保证操作的原子性);
要求二:共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。在实现分布式锁时,我们需要考虑保证共享存储系统的可靠性,进而保证锁的可靠性。
基于单个redis节点实现分布式锁:
作为分布式锁实现过程中的共享存储系统,Redis 可以使用键值对来保存锁变量,再接收和处理不同客户端发送的加锁和释放锁的操作请求。Redis 可以使用一个键值对 lock_key:0 来保存锁变量,其中,键是 lock_key,也是锁变量的名称,锁变量的初始值是 0。如果一个客户端申请加锁,就将其值设为1,释放锁时在将其值置为
加锁包含了三个操作(读取锁变量、判断锁变量值以及把锁变量值设置为 1),而这三个操作在执行时需要保证原子性。--这三个操作都需要原子性,否则的话在第一步,如果两个线程并发读取到锁变量,然后同时进行修改,那么锁变量的值就会错误,会有两个线程访问到临界区代码。
想要保证原子性的方式有来两种,单命令操作和使用lua脚本
但命令操作:setnx 如果键不存在就设置,存在的话就不做任何设置
// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key
但是这样会出现问题,
1:客户端加锁后,执行完业务逻辑后,因为网络原因没有执行del,造成锁一直被一个客户端所有,这样的话,锁就无法被其他客户端获得
解决方法:
给锁变量设置一个过期时间。这样一来,即使持有锁的客户端发生了异常,无法主动地释放锁,Redis 也会根据锁变量的过期时间,在锁变量过期后,把它删除。
其它客户端在锁变量过期后,就可以重新请求加锁,这就不会出现无法加锁的问题了。
2:一个客户端执行加锁后,而另一个客户端执行del命令,造成临界资源暴露
解决方案:
在加锁操作时,可以让每个客户端给锁变量设置一个唯一值,这里的唯一值就可以用来标识当前操作的客户端。在释放锁操作时,客户端需要判断,当前锁变量的值是否和自己的唯一标识相等,只有在相等的情况下,才能释放锁。这样一来,就不会出现误释放锁的问题了。
Redis 给 SET 命令提供了类似的选项 NX,用来实现“不存在即设置”。如果使用了 NX 选项,SET 命令只有在键值对不存在时,才会进行设置,否则不做赋值操作。此外,
SET 命令在执行时还可以带上 EX 或 PX 选项,用来设置键值对的过期时间。
key 的存活时间由 seconds 或者 milliseconds 选项值来决定。
SET key value [EX seconds | PX milliseconds] [NX]
// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000
释放锁过程
//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else return 0
end
在释放锁操作中,使用了 Lua 脚本,这是因为,释放锁操作的逻辑也包含了读取锁变量、判断值、删除锁变量的多个操作,而 Redis 在执行 Lua 脚本时,
可以以原子性的方式执行,从而保证了锁释放操作的原子性。
基于多个redis实例实现分布式锁方式:
只用了一个 Redis 实例来保存锁变量,如果这个 Redis 实例发生故障宕机了,那么锁变量就没有了。此时,客户端也无法进行锁操作了,这就会影响到业务的正常执行。所以,我们在实现分布式锁时,还需要保证锁的可靠性。这就要提到基于多个 Redis 节点实现分布式锁的方式了。
如果用多实例实现分布锁就要用到分布式锁算法 Redlock。
Redlock算法基本思想是让客户端依次向所有实例请求加锁,如果客户端能够和半数以上的实例成功完成加锁操作,那么我们就认为客户端成功获得分布式锁,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。因为向多个redis实例申请加锁,必须要规定一个加锁超时时间,否则如果一直阻塞在加锁步骤时,算法就会失效
Redlock 算法的执行步骤:
第一步是,客户端获取当前时间。
第二步是,客户端按顺序依次向 N 个 Redis 实例执行加锁操作。
这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。当然,如果某个 Redis 实例发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给加锁操作设置一个超时时间。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
第三步是,一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。
客户端只有在满足下面的这两个条件时,才能认为是加锁成功。
条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;
条件二:客户端获取锁的总耗时没有超过锁的有效时间。在满足了这两个条件后,我们需要重新计算这把锁
在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
相当于如果第一个实例加锁成功,且最后投票成功的话,那么锁的有效时间为第一个申请加锁的有效时间-加锁总耗时(执行redlock算法时间)当然,如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端向所有 Redis 节点发起释放锁的操作。
总结:分布式锁得注意事项:
1、使用 SET $lock_key $unique_val EX $second NX 命令保证加锁原子性,并为锁设置过期时间
2、锁的过期时间要提前评估好,要大于操作共享资源的时间
3、每个线程加锁时设置随机值,释放锁时判断是否和加锁设置的值一致,防止自己的锁被别人释放
4、释放锁时使用 Lua 脚本,保证操作的原子性
5、基于多个节点的 Redlock,加锁时超过半数节点操作成功,并且获取锁的耗时没有超过锁的有效时间才算加锁成功
6、Redlock 释放锁时,要对所有节点释放(即使某个节点加锁失败了),因为加锁时可能发生服务端加锁成功,由于网络问题,给客户端回复网络包失败的情况,所以需要把所有节点可能存的锁都释放掉
7、使用
Redlock 时要避免机器时钟发生跳跃,需要运维来保证,对运维有一定要求,否则可能会导致 Redlock 失效。例如共 3 个节点,线程 A
操作 M,N 节点加锁成功,但其中 N个节点机器时钟发生跳跃,锁提前过期,线程 B 正好在另外 N,O节点也加锁成功,此时 Redlock
相当于失效了(Redis 作者和分布式系统专家争论的重要点就在这)相当于一个线程误判成功了,一个线程成功了造成两个线程访问了临界资源。
8、如果为了效率,使用基于单个 Redis 节点的分布式锁即可,此方案缺点是允许锁偶尔失效,优点是简单效率高
9、如果是为了正确性,业务对于结果要求非常严格,建议使用 Redlock,但缺点是使用比较重,部署成本高