目录
分布式锁面试真的非常之常见;
这里整理下一些常见的问题或者心得;
分布式锁一般有三种实现方式:
1. 数据库乐观锁;
2. 基于Redis的分布式锁;
3. 基于ZooKeeper的分布式锁。
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
互斥性。在任意时刻,只有一个客户端能持有锁。
不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
SERNX命令
setnx:它的 Key 不存在才能 Set 成功的特性;进程 A 拿到锁,在没有删除锁的 Key 时,进程 B 自然获取锁就失败了。
这个命令表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做。
为什么要设置超时时间?
是怕进程 A 不讲道理啊,锁没等释放呢,万一崩了,直接原地把锁带走了,导致系统中谁也拿不到锁,一直不释放。
设置了超时时间还是有问题?
万一进程A操作资源的时间超过了设置的超时时间,
导致,
此时锁已经释放了,
进程B此时已经获取到了锁,
然后A执行完,就把B的锁释放了;
结果就是:等进程 A 回来了,回手就是把其他进程的锁删了;
当进程B操作完成,去释放锁的时候,懵逼了,我的锁呢?
所以在用 Setnx 的时候,Key 虽然是主要作用,但是 Value 也不能闲着,可以设置一个唯一的客户端 ID,或者用 UUID 这种随机数。
解锁?
当解锁的时候,先获取 Value 判断是否是当前进程加的锁,再去删除。
问题更大了?
在 Finally 代码块中,Get 和 Del 并非原子操作,还是有进程安全问题。(先获取锁,再删除)
所以正确的做法是:使用 Lua 脚本删除锁
通过 Lua 脚本能保证原子性的原因说的通俗一点: 就算你在 Lua 里写出花,执行也是一个命令(eval/evalsha)去执行的,一条命令没执行完,其他客户端是看不到的。
锁过期了,业务还没执行完
Redis锁的过期时间 小于 业务的执行时间该如何 续期?
默认情况下,加锁的时间是30秒.如果加锁的业务没有执行完,那么到 30-10 = 20秒的时候,就会进行一次续期,把锁重置成30秒.
那这个时候可能又有同学问了,那业务的机器万一宕机了呢?宕机了定时任务跑不了,就续不了期,那自然30秒之后锁就解开了呗.
加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
watch dog自动延期机制
客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?
简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
这确实一种比较好的方案。
如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson。
锁被别人释放怎么办?
解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。
例如,可以是自己的线程 ID,也可以是一个 UUID(随机且唯一)
在释放锁时,要先判断这把锁是否是自己持有的;
集群「主从发生切换」时,分布锁会依旧安全吗?
之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。
而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,
这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。
客户端 1 在主库上执行 SET 命令,加锁成功
此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!
怎么解决这个问题?
为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)。
红锁(RedLock)
红锁并非是一个工具,而是redis官方提出的一种分布式锁的算法。
Redlock 的方案基于 2 个前提:
-
不再需要部署从库和哨兵实例,只部署主库
-
但主库要部署多个,官方推荐至少 5 个实例
也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。
注意:不是部署 Redis Cluster,这里就是部署 5 个简单的 Redis 实例。
整体的流程是这样的,一共分为 5 步:
客户端先获取「当前时间戳T1」
客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
上面的流程有 4 个重点:
客户端在多个 Redis 实例上申请加锁
必须保证大多数节点加锁成功
大多数节点加锁的总耗时,要小于锁设置的过期时间
释放锁,要向全部节点发起释放锁请求
针对上面的步骤,可能有以下一些问题:
问题一:为什么要在多个实例上加锁?
本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。
问题二:为什么大多数加锁成功,才算成功?
多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。
在分布式系统中,总会出现「异常节点」,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。
这是一个分布式系统「容错」问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。
问题三:为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,
而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。
所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。
问题四:为什么释放锁,要操作所有节点?
在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。
例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。
所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。
问题五:如果有节点发生崩溃重启
假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:
客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。 节点C重启后,客户端2锁住了C, D, E,获取锁成功。 客户端1和客户端2同时获得了锁。
为了应对这一问题,提出了延迟重启(delayed restarts)的概念。
也就是说,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间(lock validity time)。
这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。
问题六:如果客户端长期阻塞导致锁过期
客户端1在获得锁之后发生了很长时间的GC pause,在此期间,它获得的锁过期了,而客户端2获得了锁。
当客户端1从GC pause中恢复过来的时候,它不知道自己持有的锁已经过期了,它依然向共享资源(上图中是一个存储服务)发起了写数据请求,
而这时锁实际上被客户端2持有,因此两个客户端的写请求就有可能冲突(锁的互斥作用失效了)。
如何解决这个问题呢?引入了fencing token的概念:
首先:RedLock根据随机字符串来作为单次锁服务的token,这就意味着对于资源而言,无法根据锁token来区分client持有的锁所获取的先后顺序。
fencing token可以理解成采用全局递增的序列替代随机字符串,作为锁token来使用
流程:
客户端1先获取到的锁,因此有一个较小的fencing token,等于33,
而客户端2后获取到的锁,有一个较大的fencing token,等于34。
客户端1从GC pause中恢复过来之后,依然是向存储服务发送访问请求,但是带了fencing token = 33。
存储服务发现它之前已经处理过34的请求,所以会拒绝掉这次33的请求。这样就避免了冲突。
但是其实这已经超出了Redis实现分布式锁的范围,单纯用Redis没有命令来实现生成Token。
问题七:时钟跳跃问题
假设有5个Redis节点A, B, C, D, E。
客户端1从Redis节点A, B, C成功获取了锁(多数节点)。由于网络问题,与D和E通信失败。 节点C上的时钟发生了向前跳跃,导致它上面维护的锁快速过期。 客户端2从Redis节点C, D, E成功获取了同一个资源的锁(多数节点)。 客户端1和客户端2现在都认为自己持有了锁。 这个问题用Redis实现分布式锁暂时无解。而生产环境这种情况是存在的。
时钟跳跃是可以避免的,取决于基础设施和运维;
毕竟redis是保持的AP而非CP,如果要追求强一致性可以使用zookeeper分布式锁
Redisson分布式锁
Redisson普通的锁实现源码主要是RedissonLock这个类,还没有看过它源码的盆友,不妨去瞧一瞧。
源码中加锁/释放锁操作都是用lua脚本完成的,封装的非常完善,开箱即用。
Zookeeper分布式锁
基于它实现的分布式锁是这样的:
客户端 1 和 2 都尝试创建「临时节点」,例如 /lock
假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败
客户端 1 操作共享资源
客户端 1 删除 /lock 节点,释放锁
Zookeeper 不像 Redis 那样,需要考虑锁的过期时间问题,它是采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。
而且,如果客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放。
没有锁过期的烦恼
客户端 1 创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?
原因就在于,客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持连接。
如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。
同样地,基于此问题,我们也讨论一下 GC 问题对 Zookeeper 的锁有何影响:
客户端 1 创建临时节点 /lock 成功,拿到了锁
客户端 1 发生长时间 GC
客户端 1 无法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」
客户端 2 创建临时节点 /lock 成功,拿到了锁
客户端 1 GC 结束,它仍然认为自己持有锁(冲突)
可见,即使是使用 Zookeeper,也无法保证进程 GC、网络延迟异常场景下的安全性。
如果客户端已经拿到了锁,但客户端与锁服务器发生「失联」(例如 GC),那不止 Redlock 有问题,其它锁服务都有类似的问题,Zookeeper 也是一样!
所以,这里我们就能得出结论了:一个分布式锁,在极端情况下,不一定是安全的。
总结一下 Zookeeper 在使用分布式锁时优劣:
Zookeeper 的优点:
不需要考虑锁的过期时间
watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁
但它的劣势是:
性能不如 Redis
部署和运维成本高
客户端与 Zookeeper 的长时间失联,锁被释放问题
几种分布式锁区别
对于redis的分布式锁而言,它有以下缺点:
它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
另外来说的话,redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。
即便使用redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题;
redis分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
对于zk分布式锁而言:
zookeeper天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
但是zk也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。
redis 分布式锁和 zk 分布式锁的对比
- redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
- zk 分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。
另外一点就是,如果是 redis 获取锁的那个客户端 出现 bug 挂了,那么只能等待超时时间之后才能释放锁;
而 zk 的话,因为创建的是临时 znode,只要客户端挂了,znode 就没了,此时就自动释放锁。