1. 分布式锁简介
1.1 线程锁、进程锁和分布式锁
在介绍分布式锁时,我们有必要介绍线程锁、进程锁和分布式锁三者的区别:
(1)线程锁
主要用来给方法、代码块加锁。当某个方法或者代码块使用锁时,那么在同一时刻至多仅有有一个线程在执行该段代码。当有多个线程访问同一对象的加锁方法/代码块时,同一时间只有一个线程在执行,其余线程必须要等待当前线程执行完之后才能执行该代码段。但是,其余线程是可以访问该对象中的非加锁代码块的。线程锁主要包括互斥量、读写锁、条件变量、自旋锁和屏障等,可以参考这篇博客。
(2)进程锁
也是为了控制同一操作系统中多个进程访问一个共享资源,只是因为程序的独立性,各个进程是无法控制其他进程对资源的访问的,但是可以使用本地系统的信号量控制。具体参考进程间通信方式这篇博客。
(3)分布式锁
线程锁是用于控制进程内多个线程对共享资源的互斥访问,进程锁则是用于控制同一个操作系统内多个进程对共享资源的互斥访问,而分布式锁则是用于控制不在同一个操作系统中的多个进程对共享资源的互斥访问,如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往需要互斥来防止彼此干扰保证一致性。
1.2 分布式锁实现方式
一个相对安全的分布式锁,一般需要具备以下特征:
- 互斥性:互斥是锁的基本特征,同一时刻锁只能被一个线程持有,执行临界区操作。
- 超时释放:通过超时释放,可以避免死锁,防止不必要的线程等待和资源浪费,类似于 MySQL 的 InnoDB 引擎中的 innodblockwait_timeout 参数配置。
- 可重入性:一个线程在持有锁的情况可以对其再次请求加锁,防止锁在线程执行完临界区操作之前释放。
- 高性能和高可用:加锁和释放锁的过程性能开销要尽可能的低,同时也要保证高可用,防止分布式锁意外失效。
目前实现分布式锁的方式有很多,常见的主要有:
(1)Memcached 分布式锁
利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。
(2)Zookeeper 分布式锁
利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。ZooKeeper 作为一个专门为分布式应用提供方案的框架,它提供了一些非常好的特性,如 ephemeral 类型的 znode 自动删除的功能,同时 ZooKeeper 还提供 watch 机制,可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。
(3)Chubby
Google 公司实现的粗粒度分布式锁服务,有点类似于 ZooKeeper,但也存在很多差异。Chubby 通过 sequencer 机制解决了请求延迟造成的锁失效的问题。
(4)Redis 分布式锁
基于 Redis 单机实现的分布式锁,其方式和 Memcached 的实现方式类似,利用 Redis 的 SETNX 命令,此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。而基于 Redis 多机实现的分布式锁 Redlock,是 Redis 的作者 antirez 为了规范 Redis 分布式锁的实现,提出的一个更安全有效的实现机制。
2. Redis分布式锁
2.1 SETNX命令
SETNX这个命令表示set if not exist,如果key不存在,才会设置key的值,否则什么也不会做。两个客户进程可以执行这样的命令,从而实现分布式锁:
客户端1申请加锁,加锁成功:
127.0.0.1:6379> SETNX lock 1
(integer) 1 // 客户端1,加锁成功
客户端2申请加锁,加锁失败:
127.0.0.1:6379> SETNX lock 1
(integer) 0 // 客户端2,加锁失败
此时,加锁成功的客户端,就可以去操作「共享资源」,例如,修改 MySQL 的某一行数据,或者调用一个 API 请求。操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?也很简单,直接使用 DEL 命令删除这个 key 即可:
127.0.0.1:6379> DEL lock // 释放锁
(integer) 1
这个逻辑非常简单,整体的路程就是这样:
2.2 如何避免死锁
但是上面的过程存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成死锁:
- 程序处理业务逻辑异常,没及时释放
- 进程挂了,没机会释放锁
这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了。如何解决这个问题呢?
我们很容易想到的方案是,在申请锁时,给这把锁设置一个「租期」。在 Redis 中实现时,就是给这个 key 设置一个「过期时间」。这里我们假设,操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可:
127.0.0.1:6379> SETNX lock 1 // 加锁
(integer) 1
127.0.0.1:6379> EXPIRE lock 10 // 10s后自动过期
(integer) 1
这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。
但这样真的没问题吗?还是有问题。现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如:
- SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
- SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
- SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行
这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。在 Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
这样就解决了死锁问题,也比较简单。
2.3 如何处理锁过期
试想这样一种场景:
- 客户端 1 加锁成功,开始操作共享资源
- 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
导致这个问题产生的原因可能是我们评估操作共享资源的时间不准确导致的。例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险。
一种妥协方案是,尽量「冗余」过期时间,降低锁提前过期的概率。这个方案其实也不能完美解决问题,那怎么办呢?
可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
如果是Java语言,已经有一个库把这些工作都封装好了:Redisson。Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。
2.4 如何避免释放别人的锁
在试想这样一种场景:
- 客户端 1 加锁成功,开始操作共享资源
- 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
- 客户端 2 加锁成功,开始操作共享资源
- 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)
客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁,这个问题的关键点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!如何解决这个问题呢?
解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。例如,可以是自己的线程 ID,也可以是一个 UUID(随机且唯一),这里我们以 UUID 举例:
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:
/* 锁是自己的,才释放 */
if redis.get("lock") == $uuid:
redis.del("lock")
这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了:
- 客户端 1 执行 GET,判断锁是自己的
- 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型)
- 客户端 1 执行 DEL,却释放了客户端 2 的锁
由此可见,这两个命令还是必须要原子执行才行。怎样原子执行呢?Lua 脚本。我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。
安全释放锁的 Lua 脚本如下:
/* 判断锁是自己的,才释放 */
if redis.call("GET",KEYS[1]) == ARGV[1]
then
return redis.call("DEL",KEYS[1])
else
return 0
end
2.5 主从发生切换时,分布式锁还安全吗
试想这样的场景:
- 客户端 1 在主库上执行 SET 命令,加锁成功
- 客户端 1 在主库上执行 SET 命令,加锁成功
- 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了
可见,当引入 Redis 副本后,分布锁还是可能会受到影响。怎么解决这个问题? // todo
参考:https://www.zhihu.com/question/300767410/answer/1931519430