面试之-分布式锁-01

目录

SERNX命令

为什么要设置超时时间?

设置了超时时间还是有问题?

解锁?

锁过期了,业务还没执行完

锁被别人释放怎么办?

集群「主从发生切换」时,分布锁会依旧安全吗?

红锁(RedLock)

Redisson分布式锁

Zookeeper分布式锁

几种分布式锁区别


分布式锁面试真的非常之常见;

这里整理下一些常见的问题或者心得;

分布式锁一般有三种实现方式:

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. 客户端 1 在主库上执行 SET 命令,加锁成功

  2. 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)

  3. 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!

怎么解决这个问题?

为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)

红锁(RedLock)

红锁并非是一个工具,而是redis官方提出的一种分布式锁的算法

Redlock 的方案基于 2 个前提:

  1. 不再需要部署从库哨兵实例,只部署主库

  2. 但主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

注意:不是部署 Redis Cluster,这里就是部署 5 个简单的 Redis 实例。

整体的流程是这样的,一共分为 5 步:

  1. 客户端先获取「当前时间戳T1」

  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁

  3. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败

  4. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)

  5. 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

上面的流程有 4 个重点:

  1. 客户端在多个 Redis 实例上申请加锁

  2. 必须保证大多数节点加锁成功

  3. 大多数节点加锁的总耗时,要小于锁设置的过期时间

  4. 释放锁,要向全部节点发起释放锁请求

针对上面的步骤,可能有以下一些问题: 

问题一:为什么要在多个实例上加锁?

本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

 

问题二:为什么大多数加锁成功,才算成功?

多个 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. 客户端 1 和 2 都尝试创建「临时节点」,例如 /lock

  2. 假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败

  3. 客户端 1 操作共享资源

  4. 客户端 1 删除 /lock 节点,释放锁

Zookeeper 不像 Redis 那样,需要考虑锁的过期时间问题,它是采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。

而且,如果客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放。

没有锁过期的烦恼

客户端 1 创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?

原因就在于,客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持连接。

如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。

同样地,基于此问题,我们也讨论一下 GC 问题对 Zookeeper 的锁有何影响:

  1. 客户端 1 创建临时节点 /lock 成功,拿到了锁

  2. 客户端 1 发生长时间 GC

  3. 客户端 1 无法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」

  4. 客户端 2 创建临时节点 /lock 成功,拿到了锁

  5. 客户端 1 GC 结束,它仍然认为自己持有锁(冲突)

可见,即使是使用 Zookeeper,也无法保证进程 GC、网络延迟异常场景下的安全性。

如果客户端已经拿到了锁,但客户端与锁服务器发生「失联」(例如 GC),那不止 Redlock 有问题,其它锁服务都有类似的问题,Zookeeper 也是一样!

所以,这里我们就能得出结论了:一个分布式锁,在极端情况下,不一定是安全的。

总结一下 Zookeeper 在使用分布式锁时优劣:

Zookeeper 的优点:

  1. 不需要考虑锁的过期时间

  2. watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁

但它的劣势是:

  1. 性能不如 Redis

  2. 部署和运维成本高

  3. 客户端与 Zookeeper 的长时间失联,锁被释放问题

几种分布式锁区别

对于redis的分布式锁而言,它有以下缺点:

它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能

另外来说的话,redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。

即便使用redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题;

redis分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。

对于zk分布式锁而言:

zookeeper天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。

如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。

但是zk也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。

redis 分布式锁和 zk 分布式锁的对比

  • redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
  • zk 分布式锁,获取不到锁,注册个监听器即可,不需要不断主动尝试获取锁,性能开销较小。

另外一点就是,如果是 redis 获取锁的那个客户端 出现 bug 挂了,那么只能等待超时时间之后才能释放锁

而 zk 的话,因为创建的是临时 znode,只要客户端挂了,znode 就没了,此时就自动释放锁。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

猜你喜欢

转载自blog.csdn.net/u010953880/article/details/117985564