漫谈分布式锁之ZooKeeper实现
Posted Onebyte
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了漫谈分布式锁之ZooKeeper实现相关的知识,希望对你有一定的参考价值。
-
为什么ZooKeeper可以实现分布式锁 基于ZooKeeper实现排他锁
基于ZooKeeper实现共享锁
Curator对分布式锁的支持
-
如何选择合适的分布式锁
本篇文章的大多内容,来自《从Paxos到ZooKeeper分布式一致性原理与实战》,所以它更像一篇笔记,但是在此基础上进行了整合和补充说明。
ZooKeeper是什么?ZooKeeper是典型的分布式数据一致性的解决方案。本文中,我们关注下其在分布式锁方面的实战。
在分布式系统中,应用程序可以基于ZooKeeper实现诸如配置中心、命名服务、同步工具(分布式锁和分布式队列)、负载均衡、数据发布/订阅、分布式协调/通知、集群管理和Master选举等功能。
1. ZooKeeper可以保证如下分布式一致性特性
顺序一致性:从同一个客户端发起的事务请求,最终将会严格地按照其发起顺序被应用到ZooKeeper中去。
原子性:所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群所有机器都成功应用了某一个事务,要么都没有应用,一定不会出现集群中部分机器应用了该事务,而另外一部分没有应用的情况。
单一视图:无论客户端连接的是哪个ZooKeeper服务器,其看到的服务端数据模型都是一致的。
可靠性:一旦服务端成功地应用了一个事务,并完成对客户端的响应,那么该事务所引起的服务端状态变更将会被一直保留下来,除非有另一个事务又对其进行了变更。
实时性:ZooKeeper保证在一定时间段内,客户端最终一定能够从服务端上读取到最新的数据状态。(最终一致性)
在ZooKeeper中,主要依赖ZAB协议来实现分布式数据一致性,基于该协议,ZooKeeper实现了一种主备模式的系统架构来保持集群中各副本之间数据的一致性。这也在一定程度上保证了分布式锁的可靠性。
ZooKeeper节点:
节点类型 |
描述 |
PERSISTENT | 持久节点 |
PERSISTENT_SEQUENTIAL | 持久顺序节点 |
EPHEMERAL | 临时节点(不可在拥有子节点) |
EPHEMERAL_SEQUENTIAL | 临时顺序节点(不可在拥有子节点) |
基于ZooKeeper作为分布式协调器的一致性特点和znode临时顺序节点的特性,满足我们对分布式锁实现的条件。
排他锁(Exclusive Locks,简称X锁),又称为写锁或独占锁,是一种基本的锁类型。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取和更新操作,其他任何事务都不能再对这个数据对象进行任何类型的操作——直到T1释放了排他锁。
在ZooKeeper中,我们用一个节点来表示锁,如:/exclusive_lock/lock节点,就可以被定义为一个锁。
在需要获取排他锁的时候,所有的客户端都会试图通过调用create()接口,在/exclusive_lock节点下创建临时子节点/exclusive_lock/lock;
ZooKeeper会保证在所有的客户端中,最终只有一个客户端能够创建成功,那么就认为其获取到锁。
在定义锁的部分,我们提到,/exclusive_lock/lock是一个临时节点,因此在以下两种情况下,都有可能释放锁。
当前获取锁的客户端机器发生宕机,那么ZooKeeper上的这个临时节点就会被移除;
正常执行业务逻辑后,客户端会主动将自己创建的临时节点删除。
无论在什么情况下移除了/lock节点,ZooKeeper都会通知所有在/exclusive_lock节点上注册了子节点变更Watcher监听的客户端。这些客户端在接收到通知后,再次重新发起分布式锁获取,即重复“获取锁”的过程。整个排他锁的获取和释放过程如下图所示:
上述加锁和释放锁的流程存在羊群效应:无论在什么情况下移除了/lock节点,ZooKeeper都会通知所有在/exclusive_lock节点上注册了子节点变更Watcher监听的客户端,假如客户端多了的话,将会有大量的“Watcher通知”和“尝试获取锁”的操作,这带来了巨大的性能消耗。
使用临时序号节点优化;只有当自己所创建的节点是最小的时候才能获取锁,如此,只需要监听比自己次小的节点即可。如下:
上述方案,就是优化后的排他锁,也是常用的一种解决方案。
共享锁(Shared Locks,简称S锁),又称为读锁,同样是一种基本的锁类型。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁——直到该数据对象上的所有共享锁都被释放。
共享锁和排他锁的根本区别:加上排他锁后,数据对象只对一个事务可见,而加上共享锁后,数据对象对所有事务都可见。
和排他锁类似,共享锁依然是ZooKeeper上的一个节点,共享锁的节点设计类似“/shared_lock/[host]-请求类型-序号”的临时序号节点,例如/shared_lock/host1-R-000001,那么这个节点就代表一个共享锁,如下图所示:
在需要获取共享锁时候,所有客户端都会到/shared_lock这个节点下面创建一个临时顺序节点,如果当前请求是读请求,那么就创建例如/shared_lock/host1-R-000001的节点;如果是写请求,那么就创建如/shared_lock/host1-W-000001的节点。
说明:在同一个持久节点下面创建的多个临时顺序节点,,ZooKeeper会维持一个递增顺序,如下:
根据共享锁的定义,不同的事务都可以同时对同一个数据对象进行读取操作,而更新操作必须在当前没有任何事务进行读写操作的情况下进行。基于这个原则,我们通过zk的节点来确定分布式读写顺序,大致如下5个步骤:
客户端创建一个类似于“/shared_lock/[host]-请求类型-序号”的临时顺序节点;
客户端获取/shared_lock节点下所有已创建的子节点列表,注意这里不注册任何Watcher;
确定自己的节点序号在所有子节点中的顺序;
如果无法获取共享锁(下面注释说明该情况),那么就对比自己序号小的那个子节点注册监听Watcher。注意,这里“比自己序号小的子节点”只是一个笼统的说法,具体需要区分读请求和写请求:
对于读请求:向比自己序号小的最后一个写请求节点注册Watcher监听;
对于写请求:向比自己序号小的最后一个节点注册Watcher监听。
等待Watcher通知,继续进入步骤2。
对于第4点,有如下说明:
什么是无法获取共享锁?
对于读请求:
如果没有比自己序号小的子节点,或是所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑;
如果比自己序号小的子节点中有写请求,那么就需要进入等待;
对于写请求:
如果自己不是序号最小的子节点,那么就需要进入等待。
释放锁的逻辑和排他锁一样,这里不再赘述。整个共享锁的获取和释放(考虑了羊群效应)如下图所示:
共享锁的实现较为复杂。实际业务场景中往往根据业务场景来选择,对于写操作比较频繁的场景,建议使用排他锁;对于读多写少的场景,可以使用共享锁。
上面,我们了解了基于ZooKeeper实现分布式锁的原理,但是实际上基于ZooKeeper自己实现一个生产级别的分布式锁,还是比较麻烦的,下面,我们可以借助Curator来使用其提供的分布式锁,它的原理大致和上面所讨论的类似,其实现考虑了可重入性、Session expiration和失败容错等问题。
Apache Curator是用于Apache ZooKeeper(一种分布式协调服务)的Java / JVM客户端库。
Apache Curator is a Java/JVM client library for Apache ZooKeeper, a distributed coordination service.
InterProcessMutex:可重入排它锁,公平锁
InterProcessSemaphoreMutex:不可重入互斥锁
InterProcessReadWriteLock:分布式读写锁
InterProcessMultiLock:分布式联锁
其使用比较简单,可以参考GitHub:
https://github.com/apache/curator/tree/master/curator-examples/src/main/java/locking
限于篇幅和可读性考虑,将在另外的篇章分析Curator的源码及其细节内容。
优点:
相对高效率,高性能(相对于数据库的实现);
劣势:
Redis的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题,锁的模型不够健壮;
即便使用Redlock算法来实现,在某些复杂场景下,也无法保证其实现100%没有问题;
有人提及Redis在等待获取锁的时候进行自旋,消耗性能;其实这个问题在Redisson中得到了解决:如果当前线程无法获取锁,将会阻塞一段时间(锁过期的时间)后,继续去尝试获取锁,这在一定程度上优化了自旋带来的性能消耗。
优点:
zookeeper天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁;
如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
劣势:
如果有较多的客户端频繁的申请加锁、释放锁,对于zk集群的压力会比较大。
ZooKeeper分布式锁实现简单,由于其基于CP模型,集群部署时,其内部数据同步需要消耗一定资源,因此一般适合于并发量小的场景使用,例如定时任务的运行等;
Redis分布式锁(非Redlock)由于Redis自己的高性能原因,会有很好的性能,但是极端情况下会存在两个客户端获取锁(可以通过监控leader故障和运维措施来缓解和解决该问题),因此适用于高并发的场景;
Database分布式锁由于数据库本身的限制:性能不高且不满足高可用(即使存在备份,也会导致数据不一致),因此,工作中很难见到真正使用数据库来作为分布式锁的解决方案,这里使用数据库实现主要是为了理解分布式锁的实现原理。
在实际开发过程中,建议采用Redisson实现分布式锁。
往期回顾:基于数据库和基于缓存的分布式锁实现
[2] https://dl.acm.org/doi/10.1145/1529974.1529978
[3] https://curator.apache.org/
[4] https://cwiki.apache.org/confluence/display/CURATOR
[5] https://github.com/apache/curator/tree/master/curator-examples/src/main/java/locking
[6] 倪超,《从Paxos到ZooKeeper分布式一致性原理与实战》,电子工业出版社
以上是关于漫谈分布式锁之ZooKeeper实现的主要内容,如果未能解决你的问题,请参考以下文章