分布式锁

在单机场景下,可以使用语言的内置锁(如JDK中的ReentrantLcok或者synchronized)来实现进程同步。但是在分布式场景下,需要同步的进程可能位于不同的节点上,那么就需要使用分布式锁。

lock

分布式锁性质

  1. 获取锁和释放锁的性能要好
  2. 判断是否获得锁必须是原子性的,否则可能导致多个请求都获取到锁
  3. 网络中断或宕机无法释放锁时,锁必须被清除,不然会发生死锁
  4. 可重入获取锁
  5. 可以为阻塞锁和非阻塞锁,阻塞锁即没有获取到锁,则继续等待获取锁;非阻塞锁即没有获取到锁后,不继续等待,直接返回锁失败。

分布式锁实现方式

数据库锁

基于MySQL锁表

该实现方式完全依靠数据库唯一索引来实现。当想要获得锁时,即向数据库中插入一条记录,释放锁时就删除这条记录。这种方式存在以下几个问题:

  1. 锁没有失效时间,解锁失败会导致死锁,其他线程无法再获取到锁,因为唯一索引insert都会返回失败。
  2. 只能是非阻塞锁,insert失败直接就报错了,无法进入队列进行重试。
  3. 不可重入,同一线程在没有释放锁之前无法再获取到锁。

采用乐观锁增加版本号

根据版本号字段来判断更新之前有没有其他线程更新过,如果被更新过,则获取锁失败。

缓存锁

平时使用的最多的就是redis,这里介绍两种redis的分布式锁。

基于setnx、expire两个命令来实现

基于setnx(set if not exist)的特点,当缓存里key不存在时,才会去set,否则直接返回false。如果返回true则获取到锁,否则获取锁失败,为了防止死锁,我们再用expire命令对这个key设置一个超时时间来避免。

但是这里看似完美,实则有缺陷,当我们setnx成功后,线程发生异常中断,expire还没来的及设置,那么就会产生死锁。

为了解决上诉问题,我们可以采用redis2.6.12版本以后的set,它提供了一系列选项:

  • EX seconds – 设置键key的过期时间,单位时秒
  • PX milliseconds – 设置键key的过期时间,单位时毫秒
  • NX – 只有键key不存在的时候才会设置key的值
  • XX – 只有键key存在的时候才会设置key的值

RedLock算法

redlock算法是redis作者推荐的一种分布式锁实现方式,算法的内容如下:

  1. 获取当前时间;
  2. 尝试从N个相互独立redis客户端获取锁;
  3. 计算获取所有锁消耗的时间,当且仅当客户端从多数节点(N/2+1)获取锁,并且获取锁的时间小于锁的有效时间,认为获得锁;
  4. 重新计算有效期时间,原有效时间减去获取锁消耗的时间;
  5. 删除所有实例的锁

redlock算法相对于单节点redis锁可靠性要更高,但是实现起来条件也较为苛刻。

  1. 必须部署5个节点才能让Redlock的可靠性更强。
  2. 需要请求5个节点才能获取到锁,通过Future的方式,先并发向5个节点请求,再一起获得响应结果,能缩短响应时间,不过还是比单节点redis锁要耗费更多时间。

然后由于必须获取到5个节点中的3个以上,所以可能出现获取锁冲突,即大家都获得了1-2把锁,结果谁也不能获取到锁,这个问题,redis作者借鉴了raft算法的精髓,通过冲突后在随机时间开始,可以大大降低冲突时间,但是这问题并不能很好的避免,特别是在第一次获取锁的时候,所以获取锁的时间成本增加了。

如果5个节点有2个宕机,此时锁的可用性会极大降低,首先必须等待这两个宕机节点的结果超时才能返回,另外只有3个节点,客户端必须获取到这全部3个节点的锁才能拥有锁,难度也加大了。

如果出现网络分区,那么可能出现客户端永远也无法获取锁的情况,介于这种情况,下面我们来看一种更可靠的分布式锁zookeeper锁。

zookeeper锁

Zookeeper抽象模型

Zookeeper 提供了一种树形结构级的命名空间,/app1/p_1 节点的父节点为 /app1。
zookeeper

节点类型

  • 永久节点:不会因为会话结束或者超时而消失;
  • 临时节点:如何会话结束或者超时就会消失;
  • 有序节点:会在节点名的后面加一个数字后缀,并且是有序的,例如生成的有序节点为 /lock/node-0000000000,它的下一个有序节点则为 /lock/node-0000000001,以此类推。

监听器

为一个节点注册监听器,在节点状态发生改变时,会给客户端发送消息。当前zookeeper有如下四种事件:1)节点创建;2)节点删除;3)节点数据修改;4)子节点变更。

分布式锁实现

  1. 创建一个锁目录 /lock;
  2. 当一个客户端需要获取锁时,在 /lock 下创建临时的且有序的子节点;
    客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁;否则监听自己的前一个子节点,获得子节点的变更通知后重复此步骤直至获得锁;
  3. 执行业务代码
  4. 业务代码执行完成后,删除对应的子节点。

有些人会在步骤2中获取子节点列表与设置监听这两步操作的存在原子性问题,考虑这么个场景:客户端a对应子节点为/lock/lock-0000000000,客户端b对应子节点为/lock/lock-0000000001,客户端b获取子节点列表时发现自己不是序号最小的,但是在设置监听器前客户端a完成业务流程删除了子节点/lock/lock-0000000000,客户端b设置的监听器岂不是丢失了这个事件从而导致永远等待了?这个问题不存在的。因为zookeeper提供的API中设置监听器的操作与读操作是原子执行的,也就是说在读子节点列表时同时设置监听器,保证不会丢失事件。

羊群效应

一个节点未获得锁,只需要监听自己的前一个子节点,这是因为如果监听所有的子节点,那么任意一个子节点状态改变,其它所有子节点都会收到通知(羊群效应),而我们只希望它的后一个子节点收到通知。

参考

  1. 浅谈分布式锁
  2. 基于Zookeeper的分布式锁
  3. 基于redis的分布式锁实现