为什么需要分布式锁
讲分布式锁之前,有必要介绍下为什么需要分布式锁?
与分布式锁相对的是单机锁也叫本地锁。
在单机情况下(单JVM),线程之间的共享内存,使用本地锁来互斥,以保证共享变量的正确性。
常见的单机锁:
synchronized
和lock
。
但是随着分布式的快速发展,本地加锁往往不能满足我们的需求,在分布式环境上面加本地锁就会失去作用。
所以,在分布式系统中当有多个客户端获取锁时,我们需要引入分布式锁来解决这个问题。
想实现分布式锁,需要一个外部系统,由这个外部系统实现加锁操作。
这个外部系统可以是 MySQL
,也可以是 Redis
或 Zookeeper
。
本篇以Redis展开分布式锁的讨论。
分布式锁原理
其实单机锁和分布式锁的基本原理是一样的,只不过因为分布式锁是用在分布式场景中,所以又具有一些特殊的要求。
单机锁和分布式锁的联系与区别
对于在单机上运行的多线程程序来说,锁本身可以用一个变量表示。
- 变量为0,表示没有锁没有线程获取;
- 变量为1,表示锁已经被获取。
接下来再说一下单机锁的加锁和释放锁。
加锁:加锁前判断变量是否为0,为0表示没有线程获取到锁,该线程可以立即将变量置为1,表示获取到锁;如果不是 0,表示有其他线程已经获取到锁了,则返回失败。
释放锁:将锁变量的值置为 0。
和单机上的锁类似,分布式锁同样可以用一个变量来实现。
客户端加锁和释放锁的操作逻辑,也基本和单机上的加锁和释放锁操作逻辑一致。
但是,和单机上操作锁不同的是,在分布式场景下,锁变量是保存在共享存储系统中的。
相应的加锁和释放锁就变成了:读取、判断、设置共享存储中的锁的变量值。
这样一来实现分布式锁需要满足两个特性:
- 原子性:分布式锁的加锁和释放锁的过程,涉及多个操作。所以,在实现分布式锁时,我们需要保证这些锁操作的原子性;
- 可靠性:共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。
在实现分布式锁时,我们需要考虑保证共享存储系统的可靠性,进而保证锁的可靠性。
接下来我们就来学习下 Redis 是怎么实现分布式锁的。
Redis分布式锁的演进史
第一版 SETNX
Setnx
(SET if Not Exists) 命令:key 不存在的话,那么会创建key,并且会设置值为value,如果key存在,不做任何赋值操作。
命令:
SETNX key value
释放锁也很简单,我们可以用DEL命令删除这个Key就可以了。
伪代码:
// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key
总结来说,我们就可以用 SETNX 和 DEL 命令组合来实现加锁和释放锁操作。
但是,这个方式其实是有问题的。
当客户端执行了SETNX
命令,加锁成功,此时持有锁的客户端由于某些原因发生异常,一直没有执行最后的 DEL 命令释放锁。
这个时候就会导致其他客户端无法获取到锁,出现死锁的问题。
怎么解决这个问题呢?
如何避免死锁
针对这个问题的解决方式是:设置超时时间。
SET key value [EX seconds | PX milliseconds] [NX]
这条命令保证 SETNX
和 EXPIRE
原子性执行,解决了死锁的问题。
我们再来分析下,还有没有其他问题?
思考下这个场景:
- 客户端 A 执行了
SETNX
命令加锁后,同时设置了过期时间; - 由于客户端A操作共享资源时间较长,超过了设定的过期时间,锁自动被释放;
- 假设此时客户端 B 执行了
SETNX
命令成功加锁后; - 客户端 A 的锁操作完成,执行释放锁逻辑(这个时候释放的其实是客户端B的锁)。
这里就存在两个问题:
- 锁过期时间不好评估
- 锁被别人释放
锁过期的问题主要原因就是我们评估Redis执行时间不准确导致的。
有一个妥协的方案,就是尽量冗余过期时间,从而降低提前过期的概率。但是我们想用一种完美方案来解决,怎么办呢?
这个问题,我们后面详细介绍下。
下面我们看下锁被错误释放的问题。
解决锁被别人释放
导致这个问题的原因就是客户端在释放锁的时候并没有去检查这把锁是否还归自己所持有。
为了解决这个问题,我们需要做的就是区分来自不同的客户端。
具体的操作就我们在加锁操作时,可以让每个客户端给锁变量设置一个唯一值,这里的唯一值就可以用来标识当前操作的客户端。
在释放锁操作时,客户端需要判断,当前锁变量的值是否和自己的唯一标识相等,只有在相等的情况下,才能释放锁。
这样一来,就不会出现误释放锁的问题了。
// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000
其中,unique_value
是客户端的唯一标识,可以用一个随机生成的字符串来表示,PX 10000
则表示 lock_key
会在 10s 后过期,以免客户端在这期间发生异常而无法释放锁。
因为在加锁操作中,每个客户端都使用了一个唯一标识,所以在释放锁操作时,我们需要判断锁变量的值,是否等于执行释放锁操作的客户端的唯一标识,如下所示:
String velue= String.valueOf(System.currentTimeMillis())
String result = jedis.set("lock-key",velue, 5);
if ("OK".equals(result)) {
try {
// do something
} finally {
//非原子操作
if(jedis.get("lock-key")==value){
jedis.del("lock-key");
}
}
}
这里又有一个问题了。GET和DEL是两个分开的操作,在GET执行之后且在DEL执行之前的间隙是可能会发生异常的。
所以我们需要保证解锁的代码的原子性。
如何实现呢?Lua脚本。
//释放锁 比较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 脚本时作为参数传入的。
由于Lua脚本的原子性,在Redis执行该脚本的过程中,其他客户端的命令都需要等待该Lua脚本执行完才能执行。
好了,通过一路的见招拆招,我们来小结一下,基于 Redis 实现的分布式锁,一个严谨的的流程如下:
- 加锁:
SET key value [EX seconds | PX milliseconds] [NX]
; - 操作共享资源;
- 释放锁,配合Lua脚本(GET+DEL)使用。
锁过期时间不好评估
这个问题,目前有一种比较好的解决方案:加锁时,先设置一个过期时间,然后我们开启一个守护线程,定时去检查这个锁的失效时间,如果锁快过期了,同时操作共享资源还没结束,那么这个线程就会自动给过期时间续期。
有一个库把已经这些工作都封装好了:Redssion
。
Redisson
是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了自动续期的方案来避免锁过期,这个守护线程我们一般也把它叫做看门狗线程。
除此之外呢,这个SDK还封装了很多易用的功能:
- 可重入锁
- 乐观锁
- 公平锁
- 读写锁
- RedLock
这个 SDK 提供的 API 非常友好,它可以像操作本地锁的方式,操作分布式锁。这里不重点介绍Redisson的使用,感兴趣的同学可以看下官方的Github。
以上我们分析的场景,都是基于锁在单个Redis实例中可能遇到的问题,并没有涉及到正式环境的部署架构。
我们在正式环境部署Redis一般都是主从集群+哨兵的模式。
这样即使主库发生宕机,哨兵机制可以通过主从切换,把从库切换为主库,继续提供服务。
我们考虑一个问题,当发生主从切换的时候,这个分布式锁是否安全?
考虑下这个场景:
- 客户端A申请了一把锁,加锁成功;
- 如果主库宕机,从库并没有同步到这一把锁;
- 从库升级为主库之后,这个锁在新主库上就丢失了。
为了解决这一问题,Redis 的开发者 Antirez 提出了分布式锁算法
Redlock
。
我们看下Redlock是如何解决主从切换后,锁失效问题的。
Redlock
Redlock的两个前提:
- 不需要部署从库,和哨兵实例,只部署主库;
- 需要部署多个主库实例,官方推荐至少5个;
我们来具体看下 Redlock 算法的执行步骤:
- 客户端获取当前时间;
- 让客户端依次对N个实例发起加锁请求,这里的加锁操作和在单实例上的加锁操作是一样的:使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识,同时我们要给加锁操作设置超时时间;
- 客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;且获得锁的总耗时没有超过锁的有效时间,此时,认为客户端加锁成功;
- 如果加锁失败,客户端向所有 Redis 节点发起释放锁的操作。
好了,基于Redis实现分布式锁就介绍到这了,如果你还想看更多优质原创文章,欢迎关注我的公众号「ShawnBlog」。