为什么使用分布式锁
分布式锁时控制分布式系统之间同步访问共享资源的一种方式。
如果不同的系统或者同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,就需要通过互斥的手段来防止彼此之间的干扰(即同一时间有多个同时访问资源),以保持一致性。
在这种情况下,就需要使用分布式锁。
关系型数据库本身对数据的处理上就有排他性,可以通过这个特性来实现不同进程之间的互斥。
但是,因为目前绝大多数的分布式系统的性能瓶颈上都是集中在数据库的操作中的。所以,如果上层业务再给数据库添加一些额外的锁,例如行锁,表锁甚至是繁重的事务处理,那么对于数据库的性能影响是很大的。
所以,排除以上的技术方案,可以使用Zookeeper去实现分布式锁。
下面通过两种锁:排他锁和共享锁,了解Zookeeper是如何实现分布式锁的。
Zookeeper分布式锁实现原理
- Zookeeper规定,在同一时刻,不能有多个客户端创建同一个节点。
- Zookeeper的临时节点只在session生命周期存在,session一结束就会自动销毁。
- watcher机制,在代表锁资源的节点被删除,即可以触发watcher接触阻塞去重新获取锁。
Zookeeper实现排他锁
什么是排他锁
排他锁(简称X锁),又称为写锁或独占锁,是一种基本的锁类型。
如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取和更新的操作,其他任何事务都不能再对这个数据对象进行任何类型的操作,直到事务T1释放了排他锁。
排他锁的核心:就是如何保证当前有且仅有一个事务得到锁,并且锁被释放后,所有正在等待获取锁的事务都能被通知到。
生活场景:类似一个人去上洗手间,那么这个人就独占了这个洗手间的使用权,此时,其他人如果也需要使用到洗手间的话,就只能等待上一个人使用完之后才能使用。
Zookeeper如何实现排他锁
定义锁
在Java并发编程中,有两种常见的方式用来定义锁,分别是 synchronized
机制和JDK5提供的 ReentrantLock
。然后在Zookeeper中,没有类似这样的API可以使用,而是通过Zookeeper上的数据节点来表示一个锁。例如:
获取锁
在需要获取排他锁的时候,所有的客户端都会试图调用 create()
接口在 /exclusive_lock
节点下创建临时子节点 /exclusive_lock/lock
。Zookeeper会保证所有客户端中,最终只有一个客户端能够创建成功。创建临时节点成功所对应的客户端,就可以认为是该客户端获取了锁。
同时,所有没有获取到锁的客户端都需要到 /exclusive_lock
节点上注册一个子节点变更的Watcher监听,以便实时监听到lock节点的变更情况。
释放锁
/exlusive_lock/lock
是一个临时节点,所以会有两种情况释放锁:
(1)当前获取锁的客户端机器发生了宕机,session结束,那么Zookeeper上的临时节点就会被移除。
(2)正常执行业务逻辑后,客户端就会主动将自己创建的临时节点删除。
无论在什么情况下移除lock节点,Zookeeper都会通知所有的 /exclusive_lock
节点注册了子节点变更Watcher监听的客户端。这些客户端在收到通知之后,再次重新发起分布式锁的竞争获取,即重复了”获取锁”的过程。
排他锁获取与释放流程
Zookeeper实现共享锁
什么是共享锁
共享锁(简称S锁),又称为读锁。是一种基本的锁类型。
如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,一直到该数据对象上的所有共享锁都被释放。
共享锁和排他锁的区别在于,加上排他锁后,数据对象只对一个事务可见,而加上共享锁后,数据对所有事务都可见。
排他锁,只允许当前的加锁事务的读写操作。共享锁,允许当前的加锁事务的读操作和其他事务的读操作,不允许写操作。
Zookeeper如何实现共享锁
定义锁
通过Zookeeper上的数据结点来表示一个锁,该数据节点为临时顺序节点。例如 /shared_lock/{hostname}-请求类型-序号
代表为一个共享锁:
获取锁
在需要获取共享锁的时候,所有客户端都会到 /shared_lock
这个节点下面创建一个临时顺序节点,
如果当前是读请求,则节点名称为: /shared_lock/host1-R-00000001
如果当前是写请求,则节点名称为: /shared_lock/host1-W-00000002
判断读写顺序
通过Zookeeper来确定分布式读写顺序,分为四步:
(1)创建完节点后,获取/shared_lock节点下所有子节点,并对该节点变更注册监听。
(2)确定自己的节点序号在所有子节点中的顺序。
(3) 对于读请求:如没有比自己序号小的子节点或所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到共享锁,同时开始执行读取逻辑。若比自己序号小的子节点存在写请求,则需要等待。
对于写请求:如自己不是序号最小的子节点,那么就需要等待。
(4)接收到Watcher通知后,重复步骤(1)。
释放锁
两种情况释放锁:
(1)客户端故障宕机,session会话结束,临时顺序节点移除,释放锁。
(2)正常执行业务逻辑后,客户端主动删除自己创建的临时顺序节点。
羊群效应
普通的共享锁实现方式,大题上只能够满足一般的分布式集群(集群规模不大,一般在10台机器以内)竞争锁的需求,并且性能还可以。
但是,如果机器的规模扩大后,会出现羊群效应。
什么是羊群效应
比如当前的数据节点树如下,并对该图所示情况进行分析:
1、host1首先进行读操作,完成后将节点 shared_lock/host1-R-00000001
删除。
2、余下的4台机器均受到了这个节点移除的通知,然后重新从 shared_lock
节点获取一份新的子节点列表。
3、每台机器判断自己的在读写顺序里的位置,其中host2检测到自己序号最小,于是进行写操作,余下的机器则继续等待。
4、继续步骤一的执行。
羊群效应分析
可以看到,host1客户单在移除自己的共享锁后,Zookeeper发送了子节点变更Watcher通知给所有的机器。
但是,除了能给host2产生影响之外,对其他机器是没有任何作用的。
采用普通的分布式锁实现方式,就会导致会有大量的Wather通知和子节点列表获取这两个操作的重复执行,会造成Zookeeper服务器巨大的性能影响和网络开销。如果在同一时间有多个节点对应的客户端完成事务请求或者是事务中断引起节点消失,Zookeeper服务器就会在短时间内向其余客户端发送大量的时间通知。
这就是所谓的羊群效应。
如何避免羊群效应
分布式锁的竞争过程的核心逻辑是:判断自己是否是所有子节点中序号最小的。所以,每个节点对应的客户端只需要关注比自己序号小的那个相关节点的变更情况就可以了,而不需要关注全局子列表的变更情况。
改进分布式锁实现
主要改动:每个锁竞争者,只需要关注 /shared_lock
节点下序号比自己小的那个节点是否存在即可。
1、客户端调用 create()
接口创建临时顺序节点: /shared_lock/{hostname}-请求类型-序号
;
2、客户端调用 getChildren()
接口获取所有已经创建的子节点列表(但是不注册任何Watcher);
3、如果无法获取共享锁,就调用 exist()
接口来对比自己小的节点注册Watcher。针对读请求:向比自己序号小的最后一个写请求节点注册Watcher监听。针对写请求:向比自己序号小的最后一个节点注册Watcher监听。
4、等待Wather通知,然后继续执行步骤2。