Zookeeper实现分布式锁
在分布式系统架构中,想必大家对分布式锁已经很熟悉了,实现方式也有很多种,比如基于数据库的分布式锁、基于redis缓存的分布式锁以及本文要介绍的Zookeeper实现的分布式锁。
分布式锁的基本场景
如果在多线程并行情况下去访问某一个共享资源,比如说 共享变量,那么势必会造成线程安全问题。那么我们可以 用很多种方法来解决,比如 synchronized、 比如 Lock 之 类的锁操作来解决线程安全问题,那么在分布式架构下, 涉及到多个进程访问某一个共享资源的情况,比如说在电 商平台中商品库存问题,在库存只有 10 个的情况下进来 100 个用户,如何能够避免超卖呢?所以这个时候我们需 要一些互斥手段来防止彼此之间的干扰。 然后在分布式情况下,synchronized 或者 Lock 之类的锁 只能控制单一进程的资源访问,在多进程架构下,这些 api 就没办法解决我们的问题了。
- 独占锁(排他锁)
我们可以 利用 zookeeper 节点的特性来实现独占锁,就是同级节点的唯一性,多个进程往 zookeeper 的指定节点下创建一个 相同名称的节点,只有一个能成功,其余进程创建都是失败,创建失败的节点全部通过 zookeeper 的 watcher 机制来监zookeeper 这个子节点的变化,一旦监听到子节点的删除事件,则再次触发所有进程去获取锁,这种实现方式很简单,但是会产生“羊群效应”,简单来说就 是如果存在许多的客户端在等待获取锁,当成功获取到锁 的进程释放该节点后,所有处于等待状态的客户端都会被 唤醒,这个时候 zookeeper 在短时间内发送大量子节点变 更事件给所有待获取锁的客户端,然后实际情况是只会有 一个客户端获得锁。如果在集群规模比较大的情况下,会 对 zookeeper 服务器的性能产生比较的影响 - 共享锁(公平锁)
我们可以通过临时有序节点来实现分布式锁,每个客户端都往指定的节点下注册一个临时有序节点,越早创建的节点, 节点的顺序编号就越小,那么我们可以判断子节点中最小 的节点设置为获得锁。如果自己的节点不是所有子节点中 最小的,意味着还没有获得锁。这个的实现和前面单节点 实现的差异性在于,每个节点只需要监听比自己小的节点, 当比自己小的节点删除以后,客户端会收到 watcher 事件, 此时再次判断自己的节点是不是所有子节点中最小的,如 果是则获得锁,否则就不断重复这个过程,这样就不会导 致羊群效应,因为每个客户端只需要监控一个节点。
Curator实现Zookeeper分布式锁的使用
curator 对于锁这块做了一些封装,例如:
- InterProcessMutex:分布式可重入排它锁
- InterProcessSemaphoreMutex:分布式排它锁
- InterProcessReadWriteLock:分布式读写锁
InterProcessMutex实现Demo
private static String CONNECTION_STR = "47.93.37.94:2181,39.105.162.160:2181,39.105.163.224:2181";
public static void main(String[] args) throws Exception {
//Curator连接zkServer
CuratorFramework curatorFramework = CuratorFrameworkFactory.builder().
connectString(CONNECTION_STR).sessionTimeoutMs(50000000).
retryPolicy(new ExponentialBackoffRetry(1000, 3)).build();
curatorFramework.start();
//创建锁(重入锁)
final InterProcessMutex lock = new InterProcessMutex(curatorFramework, "/locks"); //lock为持久结点,创建的临时有序结点为其子节点
for (int i = 0; i < 10; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "->>尝试竞争锁开始");
try {
lock.acquire(); //阻塞竞争锁
System.out.println(Thread.currentThread().getName() + "->>成功获得了锁");
} catch (Exception e) {
e.printStackTrace();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
try {
lock.release(); //释放锁
} catch (Exception e) {
e.printStackTrace();
}
}
}, "Thread-" + i).start();
}
}
结果如图:
Zookeeper 实现 leader 选举
leader election 是很重要的一个功能, 这个选举过程是这样子的:指派一个进程作为组织者,将 任务分发给各节点。在任务开始前,哪个节点都不知道谁是 leader 或者 coordinator。当选举算法开始执行后,每 个节点最终会得到一个唯一的节点作为任务 leader。除此 之外,选举还经常会发生在 leader 意外宕机的情况下,新 的 leader 要被选举出来。
Curator 有两种选举 recipe(Leader Latch 和 Leader Election)
- Leader Latch
参与选举的所有节点,会创建一个顺序节点,其中最小的 节点会设置为 master 节点, 没抢到 Leader 的节点都监听 前一个节点的删除事件,在前一个节点删除后进行重新抢 主,当 master 节点手动调用 close()方法或者 master 节点挂了之后,后续的子节点会抢占 master。
其中 spark 使用的就是这种方法 - LeaderSelectorh
LeaderSelector 和 Leader Latch 最的差别在于,leader 可以释放领导权以后,还可以继续参与竞争
来看一下例子
public class LeaderSelectorClientA extends LeaderSelectorListenerAdapter implements Closeable {
private String name; //当前的进程
private LeaderSelector leaderSelector; //leader选举的API
private CountDownLatch countDownLatch = new CountDownLatch(1); //让进程不释放lead权限
public LeaderSelectorClientA() {
}
public LeaderSelectorClientA(String name) {
this.name = name;
}
public LeaderSelector getLeaderSelector() {
return leaderSelector;
}
public void setLeaderSelector(LeaderSelector leaderSelector) {
this.leaderSelector = leaderSelector;
}
public void start() {
leaderSelector.start(); //开始竞争leader
}
@Override
public void takeLeadership(CuratorFramework client) throws Exception {
//如果进入当前的方法,意味着当前的进程获得了锁。获得锁以后,这个方法会被回调
//这个方法执行结束之后,表示释放leader权限
System.out.println(name + "->现在是leader了");
countDownLatch.await(); //阻塞当前的进程防止leader丢失
}
@Override
public void close() throws IOException {
leaderSelector.close();
}
private static String CONNECTION_STR = "47.93.37.94:2181,39.105.162.160:2181,39.105.163.224:2181";
public static void main(String[] args) throws IOException {
CuratorFramework curatorFramework = CuratorFrameworkFactory.builder().
connectString(CONNECTION_STR).sessionTimeoutMs(50000).//缓存时间
retryPolicy(new ExponentialBackoffRetry(1000, 3)).build();
curatorFramework.start();
LeaderSelectorClientA leaderSelectorClient = new LeaderSelectorClientA("ClientA");
LeaderSelector leaderSelector = new LeaderSelector(curatorFramework, "/leader", leaderSelectorClient);
leaderSelectorClient.setLeaderSelector(leaderSelector);
leaderSelectorClient.start(); //开始选举
System.in.read();//进程不结束
}
}
public class LeaderSelectorClientB extends LeaderSelectorListenerAdapter implements Closeable {
private String name; //表示当前的进程
private LeaderSelector leaderSelector; //leader选举的API
private CountDownLatch countDownLatch=new CountDownLatch(1);
public LeaderSelectorClientB(){
}
public LeaderSelectorClientB(String name) {
this.name = name;
}
public LeaderSelector getLeaderSelector() {
return leaderSelector;
}
public void setLeaderSelector(LeaderSelector leaderSelector) {
this.leaderSelector = leaderSelector;
}
public void start(){
leaderSelector.start(); //开始竞争leader
}
@Override
public void takeLeadership(CuratorFramework client) throws Exception {
//如果进入当前的方法,意味着当前的进程获得了锁。获得锁以后,这个方法会被回调
//这个方法执行结束之后,表示释放锁
System.out.println(name+"->现在是leader了");
countDownLatch.await(); //阻塞当前的进程防止leader丢失
}
@Override
public void close() throws IOException {
leaderSelector.close();
}
private static String CONNECTION_STR = "47.93.37.94:2181,39.105.162.160:2181,39.105.163.224:2181";
public static void main(String[] args) throws IOException {
CuratorFramework curatorFramework = CuratorFrameworkFactory.builder().
connectString(CONNECTION_STR).sessionTimeoutMs(50000).
retryPolicy(new ExponentialBackoffRetry(1000, 3)).build();
curatorFramework.start();
LeaderSelectorClientB leaderSelectorClient=new LeaderSelectorClientB("ClientB");
LeaderSelector leaderSelector=new LeaderSelector(curatorFramework,"/leader",leaderSelectorClient);
leaderSelectorClient.setLeaderSelector(leaderSelector);
leaderSelectorClient.start(); //开始选举
System.in.read();
}
}
public class LeaderSelectorClientC extends LeaderSelectorListenerAdapter implements Closeable {
private String name; //表示当前的进程
private LeaderSelector leaderSelector; //leader选举的API
private CountDownLatch countDownLatch=new CountDownLatch(1);
public LeaderSelectorClientC(){
}
public LeaderSelectorClientC(String name) {
this.name = name;
}
public LeaderSelector getLeaderSelector() {
return leaderSelector;
}
public void setLeaderSelector(LeaderSelector leaderSelector) {
this.leaderSelector = leaderSelector;
}
public void start(){
leaderSelector.start(); //开始竞争leader
}
@Override
public void takeLeadership(CuratorFramework client) throws Exception {
//如果进入当前的方法,意味着当前的进程获得了锁。获得锁以后,这个方法会被回调
//这个方法执行结束之后,表示释放锁
System.out.println(name+"->现在是leader了");
countDownLatch.await(); //阻塞当前的进程防止leader丢失
}
@Override
public void close() throws IOException {
leaderSelector.close();
}
private static String CONNECTION_STR = "47.93.37.94:2181,39.105.162.160:2181,39.105.163.224:2181";
public static void main(String[] args) throws IOException {
CuratorFramework curatorFramework = CuratorFrameworkFactory.builder().
connectString(CONNECTION_STR).sessionTimeoutMs(5000).
retryPolicy(new ExponentialBackoffRetry(1000, 3)).build();
curatorFramework.start();
LeaderSelectorClientC leaderSelectorClient=new LeaderSelectorClientC("ClientC");
LeaderSelector leaderSelector=new LeaderSelector(curatorFramework,"/leader",leaderSelectorClient);
leaderSelectorClient.setLeaderSelector(leaderSelector);
leaderSelectorClient.start(); //开始选举
System.in.read();//
}
}
三个主函数依次启动后,最先启动的会成为leader,leader挂掉后会按照启动时间的顺序选择新的leader
Zookeeper 数据的同步流程
在 zookeeper 中,客户端会随机连接到 zookeeper 集群中 的一个节点,如果是读请求,就直接从当前节点中读取数 据,如果是写请求,那么请求会被转发给 leader 提交事务,然后只要有超过半数节点写入成功,leader 就会广播事务,, 那么写请求就会被提交;有三个问题需要考虑下
-
- 集群中的 leader 节点如何选举出来?
-
- leader 节点崩溃以后,整个集群无法处理写请求,如何快速从其他节点里面选举出新的 leader 呢?
-
- leader 节点和各个 follower 节点的数据一致性如何保证
ZAB 协议
ZAB(Zookeeper Atomic Broadcast) 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议。在ZooKeeper 中,主要依赖 ZAB 协议来实现 分布式数据一致性,基于该协议,ZooKeeper 实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性
简介
ZAB 协议包含两种基本模式,分别是
-
- 崩溃恢复
-
- 原子广播
当整个集群在启动时,或者当 leader 节点出现网络中断、 崩溃等情况时,ZAB 协议就会进入模式并选举产生新 的 Leader,当 leader 服务器选举出来后,并且集群中有过 半的机器和该 leader 节点完成数据同步后(同步指的是数 据同步,用来保证集群中过半的机器能够和 leader 服务器 的数据状态保持一致),ZAB 协议就会退出恢复模式。
当集群中已经有过半的 Follower 节点完成了和 Leader 状 态同步以后,那么整个集群就进入了消息广播模式。这个 时候,在 Leader 节点正常工作时,启动一台新的服务器加 入到集群,那这个服务器会直接进入数据恢复模式,和leader 节点进行数据同步。同步完成后即可正常对外提供 非事务请求的处理;##leader 节点可以处理事务请求和非事务请 求,follower 节点只能处理非事务请求,如果 follower 节 点接收到非事务请求,会把这个请求转发给 Leader 服务器##
消息广播的实现原理
消息广播的过程实际上是一个 简化版本的二阶段提交过程:
-
- leader 接收到消息请求后,将消息赋予一个全局唯一的64 位自增 id,叫zxid,通过 zxid 的大小比较既可以实现因果有序这个特征
zxid中高32位标识epoch ,低32位表示递增编号,epoch类似于一个国家的国号,在古代每一个朝代变更,国号都会变更,例如唐、宋、元、明、清,同样每次发生选举epoch都会发生变更,可以有效防止前一个leader的数据提交影响影响到新的选举,其实就是乐观锁的思想。
- leader 接收到消息请求后,将消息赋予一个全局唯一的64 位自增 id,叫zxid,通过 zxid 的大小比较既可以实现因果有序这个特征
-
- leader 为每个 follower 准备了一个 FIFO 队列(通过 TCP协议来实现,以实现了全局有序这一个特点)将带有 zxid的消息作为一个提案(proposal)分发给所有的 follower
-
- 当 follower 接收到 proposal,先把 proposal 写到磁盘,写入成功以后再向 leader 回复一个 ack
-
- 当 leader 接收到合法数量(超过半数节点)的 ACK 后,leader 就会向这些 follower 发送 commit 命令,同时会在本地执行该消息
-
- 当 follower 收到消息的 commit 命令以后,会提交该消息
这里需要注意的是:
leader 的投票过程,不需要Observer的ack,也就是Observer不需要参与投票过程,但是 Observer 必须要同 步 Leader 的数据从而在处理请求的时候保证数据的一致性**
崩溃恢复的实现原理
前面我们已经清楚了 ZAB 协议中的消息广播过程,ZAB 协 议的这个基于原子广播协议的消息广播过程,在正常情况下是没有任何问题的,但是一旦 Leader 节点崩溃,或者由 于网络问题导致 Leader 服务器失去了过半的 Follower 节 点的联系(leader 失去与过半 follower 节点联系,可能是 leader 节点和 follower 节点之间产生了网络分区,那么此时的 leader 不再是合法的 leader 了),那么就会进入到崩 溃恢复模式。崩溃恢复状态下 zab 协议需要做两件事
-
- 选举出新的 leader
-
- 数据同步
消息广播时,知道 ZAB 协议的消息广播机制是 简化版本的 2PC 协议,这种协议只需要集群中过半的节点响应提交即可。但是它无法处理 Leader 服务器崩溃带来的 数据不一致问题。因此在 ZAB 协议中添加了一个“崩溃恢 复模式”来解决这个问题。那么 ZAB 协议中的崩溃恢复需要保证,如果一个事务 Proposal 在一台机器上被处理成功,那么这个事务应该在 所有机器上都被处理成功,哪怕是出现故障。为了达到这 个目的,我们先来设想一下,在 zookeeper 中会有哪些场 景导致数据不一致性,以及针对这个场景,zab 协议中的 崩溃恢复应该怎么处理。
- 数据同步
已经被处理的消息不能丢
当 leader 收到合法数量 follower 的 ACKs 后,就向各 个 follower 广播 COMMIT 命令,同时也会在本地执行 COMMIT 并向连接的客户端返回「成功」。但是如果在各 个 follower 在收到 COMMIT 命令前 leader 就挂了,导 致剩下的服务器并没有执行都这条消息。
Zookeeper的一致性问题
zk实现的是是顺序一致性,要保证任何一次读都能督导最近一次写入或者修改的值,类似于并发编程中的voliate;
zookeeper 不保证在每个实例中,两个不同的客户端具有相同的 zookeeper 数据视图,由于网络延迟等因素,一个客户端可能会在另外一 个客户端收到更改通知之前执行更新, 考虑到2个客户端A和B的场景,如果A把znode /a的值从0设置为 1,然后告诉客户端 B 读取 /a, 则客户端 B 可能会读取到旧的值 0,具体取决于他连接到那个服务器,如果客户端 A 和 B 要读取必须要读取到 相同的值,那么 client B 在读取操作之前执行 sync 方法。
除此之外,zookeeper 基于 zxid 以及阻塞队列的方式来实现请求的顺序 一致性。如果一个 client 连接到一个最新的 follower 上,那么它 read 读 取到了最新的数据,然后 client 由于网络原因重新连接到 zookeeper 节 点,而这个时候连接到一个还没有完成数据同步的 follower 节点,那么这 一次读到的数据不久是旧的数据吗?实际上 zookeeper 处理了这种情况, client 会记录自己已经读取到的最大的 zxid,如果 client 重连到 server 发 现 client 的 zxid 比自己大。连接会失败
Leader选举的原理
选举的情况有俩种
- 服务启动时候的leader选举
- 运行过程中leader宕机导致的leader选举
先来弄清楚几个参数
- 服务器id(myid)
比如有三台服务器,编号分别是 1,2,3;编号越大在选举算法中权重也就越大 - zxid(事务id)
值越大说明数据越新,在选举算法中的权重越大 - 逻辑始终(epoch)
叫投票的次数,同一轮投票过程中的逻辑时钟值是相同的。每投完一次票这个数据就会增加,然后与接收到的其它服务器返回的投票信息中的数值相比,根据不同的值做出不同的判断。 - 选举状态
Looking(竞选状态);FOLLOWING(随从状态,同步 leader 状态,参与投票。);LEADING(领导者状态);OBSERVING(观察状态,同步 leader 状态,不参与投票。)
服务启动时leader选举
每个节点启动的时候状态都是 LOOKING,处于观望状态,接下来就开始进行选举流程。
若进行 Leader 选举,则至少需要两台机器,这里选取 3 台机器组成的服 务器集群为例。在集群初始化阶段,当有一台服务器 Server1 启动时,其 单独无法进行和完成 Leader 选举,当第二台服务器 Server2 启动时,此 时两台机器可以相互通信,每台机器都试图找到 Leader,于是进入 Leader 选举过程。选举过程如下:
- 每个Ser ver 发出一个投票。由于是初始情况,Server1 和 Server2 都会将自己作为 Leader 服务器来进行投票,每次投票会包含所推举的服务器的 myid 和 ZXID、epoch,使用(myid, ZXID,epoch)来表示, 此时 Server1 的投票为(1, 0),Server2 的投票为(2, 0),然后各自将这个投票发给集群中其他机器
- 接受来自各个服务器的投票。集群的每个服务器收到投票后,首先判断该投票的有效性,如检查是否是本轮投票(epoch)、是否来自 LOOKING 状态的服务器。
- 处理投票。针对每一个投票,服务器都需要将别人的投票和自己的投 票进行 PK,PK 规则如下:
-
- 优先比较 epoch
- 2其次检查 ZXID。ZXID 比较大的服务器优先作为 Leader
- 3如果 ZXID 相同,那么就比较 myid。myid 较大的服务器作为Leader 服务器
对于 Server1 而言,它的投票是(1, 0),接收 Server2 的投票为(2, 0), 首先会比较两者的 ZXID,均为 0,再比较 myid,此时 Server2 的 myid 最大,于是更新自己的投票为(2, 0),然后重新投票,对于 Server2 而言,其无须更新自己的投票,只是再次向集群中所有机器 发出上一次投票信息即可
- 统计投票。每次投票后,服务器都会统计投票信息,判断是否已经有 过半机器接受到相同的投票信息,对于 Server1、Server2 而言,都统计出集群中已经有两台机器接受了(2, 0)的投票信息,此时便认为 已经选出了 Leader
- 改变服务器状态。一旦确定了 Leader,每个服务器就会更新自己的 状态,如果是 Follower,那么就变更为 FOLLOWING,如果是 Leader, 就变更为 LEADING。
运行过程中的 leader 选举
当集群中的 leader 服务器出现宕机或者不可用的情况时,那么整个集群 将无法对外提供服务,而是进入新一轮的 Leader 选举,服务器运行期间 的 Leader 选举和启动时期的 Leader 选举基本过程是一致的。
- 变更状态。Leader 挂后,余下的非Observer 服务器都会将自己的服务器状态变更为 LOOKING,然后开始进入 Leader 选举过程。
- 每个Server 会发出一个投票。在运行期间,每个服务器上的 ZXID 可 能不同,此时假定 Server1 的 ZXID 为 123,Server3的ZXID 为 122; 在第一轮投票中,Server1和 Server3都会投自己,产生投票(1, 123), (3, 122),然后各自将投票发送给集群中所有机器。接收来自各个服务器的投票。与启动时过程相同。
- 处理投票。与启动时过程相同,此时,Server1 将会成为 Leader。
- 统计投票。与启动时过程相同。
- 改变服务器的状态。与启动时过程相同