文章目录
- 1. 简介
- 2. 下载与安装
- 2.1 单机版
- 2.2 集群版
- 3. 选举机制
- 3.1 首次启动
- 3.2 非首次启动
- 4. 客户端常用命令行操作
- 4.1 查看节点信息
- 4.2 节点类型
- 4.3 监听器原理
- 4.4 写数据流程
- 5. 编程实现监听服务动态上下线
- 6. 分布式锁
- 6.1 手写简易分布式锁
- 6.2 Curator 框架
- 7. 进阶知识
- 7.1 Paxos算法
- 7.2 ZAB协议
- 7.3 CAP理论
1. 简介
ZooKeeper从设计模式角度来理解:是一个基于观察者模式设计的分布式服务管理框架,它负责存储和管理大家都关心的数据,然 后接受观察者的注册,一旦这些数据的状态发生变化,ZooKeeper就负责通知已经在ZooKeeper上注册的那些观察者做出相应的反应。
特点:
- ZooKeeper:一个领导者(Leader),多个跟随者(Follower)组成的集群
- 集群中要有半数以上节点存活,ZooKeeper集群才能正常服务。所以ZooKeeper适合安装奇数台服务器 N=2 * X + 1(X为容错机器数,N为需有的机器数)
- 全局数据一致:每个Server保存一份相同的数据副本,Client无论连接到哪个Server,数据都是一致的
- 更新请求顺序执行,来自同一个Client的更新请求按其发送顺序依次执行
- 数据更新原子性,一次数据更新要么成功,要么失败
- 实时性,在一定时间范围内,Client能读到最新数据
应用场景:统一命名服务、统一配置管理、统一集群管理、服务器节点动态上下线、软负载均衡等
数据结构:ZooKeeper 数据模型的结构与 Unix 文件系统很类似,整体上可以看作是一棵树,每个节点称做一个 ZNode。每一个 ZNode 默认能够存储 1MB 的数据,每个 ZNode 都可以通过其路径唯一标识。
2. 下载与安装
2.1 单机版
官网下载:https://ZooKeeper.apache.org/releases.html
- 安装前准备:确保已安装 jdk 环境,文件解压到指定目录,然后重命名
tar -zxvf apache-ZooKeeper-3.8.3-bin.tar.gz -C /opt/
mv apache-ZooKeeper-3.8.3-bin apache-ZooKeeper-3.8.3
- 配置修改,进入解压后目录的
conf
目录,复制cp zoo_sample.cfg zoo.cfg
并修改配置文件vim zoo.cfg
tickTime = 2000
:通信心跳时间,ZooKeeper服务器与客户端心跳时间,单位毫秒initLimit = 10
:LF初始通信时限次数,Leader和Follower初始连接时能容忍的最多心跳数(tickTime的数量)syncLimit = 5
:LF同步通信时限次数,Leader和Follower之间通信时间如果超过syncLimit * tickTime,Leader认为Follwer死掉,从服务器列表中删除FollwerdataDir
:保存ZooKeeper中的数据,默认的tmp目录,容易被Linux系统定期删除,所以一般不用默认的tmp目录clientPort = 2181
:客户端连接端口,通常不做修改
dataDir=/opt/apache-ZooKeeper-3.8.3/zkData
- 返回
/opt/apache-ZooKeeper-3.8.3
目录,创建目录mkdir zkData
操作 ZooKeeper:
- 启停查 ZooKeeper:
bin/zkServer.sh start|stop|status
- 连接客户端:
bin/zkCli.sh
;退出quit
2.2 集群版
按单机版方式先在三台机器上分别部署单机版(本地测试请先关闭防火墙,保证网络通畅能互相 ping 通),接下来做以下操作。
- 配置服务器编号:在
zkData
目录vim myid
,文件内容写1
即可,后续添加机器需填写不同编号 - 配置集群环境,修改
zoo.cfg
配置文件,加上如下配置server.A=B:C:D
集群服务配置详析- A:是一个数字,表示这个是第几号服务器。集群模式下配置一个文件 myid,这个文件在 dataDir 目录下,这个文件里面有一个数据就是 A 的值ZooKeeper 启动时读取此文件,拿到里面的数据与 zoo.cfg 里面的配置信息比较从而判断到底是哪个 server
- B:是服务器的地址
- C:是服务器 Follower 与集群中的 Leader 服务器交换信息的端口
- D:是用来执行选举时服务器相互通信的端口
#######################cluster##########################
server.1=zkServer1:2888:3888
server.2=zkServer2:2888:3888
server.3=zkServer3:2888:3888
vim /etc/hosts
修改映射文件添加如下映射,也可不配置映射直接使用地址,但不推荐
192.168.115.129 zkServer1
192.168.115.131 zkServer2
192.168.115.132 zkServer3
- 在其他两台服务器上部署单机并重复上面两个步骤,并设置对应的
myid
即可
统一启停脚本:ZooKeeper.sh
,创建该脚本后需修改脚本权限具备可执行权限 chmod 777
!/bin/bash
echo ---------- ZooKeeper $1 执行 ------------
for i in 192.168.115.129 192.168.115.131 192.168.115.132
do
echo ---------- $i $1 ------------
ssh $i "/opt/apache-ZooKeeper-3.8.3/bin/zkServer.sh $1"
done
之后执行 ./ZooKeeper.sh start
即可一键启动集群 ZooKeeper,也可将 start
换成 stop
或 status
做其他操作
使用 status 查看状态存在 mode :Mode: follower
和 Mode: leader
表示启动成功,否则可以进安装目录的 logs
目录看日志
上面脚本用到了 ssh 登陆,一般需要配置 ssh 免密登陆,步骤如下:
ssh-keygen -t rsa
:在创建脚本的机器上执行该命令生成密钥ssh-copy-id root@192.168.115.131
:上传1中生成的密钥到服务器192.168.115.131
ssh root@192.168.115.131 "chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys"
:确保远程服务器上目录及文件权限正确,该命令执行时无需密码则表示已经配置成功
如何确定应该安装多少 ZooKeeper?
经验:首先必然是奇数台,其次:
- 10 台服务器:3 台 zk
- 20 台服务器:5 台 zk
- 100 台服务器:11 台 zk
- 200 台服务器:11 台 zk
服务器台数多:好处,提高可靠性;坏处:提高通信延时
3. 选举机制
ZooKeeper 的选举机制分为两种,一种是首次启动时进行 leader 选举,另一种是非首次启动选举。首先需清楚,ZooKeeper 服务器有以下属性。
- SID:服务器ID。用来唯一标识一台ZooKeeper集群中的机器,每台机器不能重复,和myid一致。
- ZXID:事务ID。ZXID是一个事务ID,用来标识一次服务器状态的变更。在某一时刻,集群中的每台机器的ZXID值不一定完全一致,这和ZooKeeper服务器对于客户端“更新请求”的处理逻辑有关。来自客户端的每次写操作都有事务id(zxid)
- Epoch:每个Leader任期的代号。没有Leader时同一轮投票过程中的逻辑时钟值是相同的。每投完一次票这个数据就会增加
3.1 首次启动
假设有 5 台 ZooKeeper 机器,myid 分别设置为 1~5,下面将详细描述首次启动时的选举情况:
- 服务器1启动,发起一次选举。服务器1投自己一票。此时服务器1票数一票,不够半数以上(3票),选举无法完成,服务器1状态保持为 LOOKING
- 服务器2启动,再发起一次选举。服务器1和2分别投自己一票并交换选票信息:此时服务器1发现服务器2的myid比自己目前投票推举的(服务器1)
大,更改选票为推举服务器2。此时服务器1票数0票,服务器2票数2票,没有半数以上结果,选举无法完成,服务器1,2状态保持LOOKING - 服务器3启动,发起一次选举。此时服务器1和2都会更改选票为服务器3。此次投票结果:服务器1为0票,服务器2为0票,服务器3为3票。此时服
务器3的票数已经超过半数,服务器3当选Leader。服务器1,2更改状态为 FOLLOWING ,服务器3更改状态为 LEADING - 服务器4启动,发起一次选举。此时服务器1,2,3已经不是LOOKING状态,不会更改选票信息。交换选票信息结果:服务器3为3票,服务器4为
1票。此时服务器4服从多数,更改选票信息为服务器3,并更改状态为 FOLLOWING - 服务器5启动,同4一样当小弟
即 投票过半数时,服务器 id 大的胜出
3.2 非首次启动
当ZooKeeper集群中的一台服务器出现以下两种情况之一时,就会开始进入Leader选举
- 服务器初始化启动,选举过程就是上面首次启动的选举过程
- 服务器运行期间无法和Leader保持连接,本次介绍
当一台机器进入Leader选举流程时,当前集群可能会处于以下两种状态:
- 集群中本来就已经存在Leader:机器试图去选举Leader时,会被告知当前服务器的Leader信息,对于该机器来说,仅仅需要和Leader机器建立连
接,并进行状态同步即可 - 集群中不存在 Leader:选举Leader规则:
- **EPOCH大的直接胜出 **
- EPOCH相同,事务id大的胜出
- **事务id相同,服务器id大的胜出 **
假设ZooKeeper由5台服务器组成,SID分别为1、2、3、4、5,ZXID分别为8、8、8、7、7,并且此时SID为3的服务器是Leader。某一时刻,
3和5服务器出现故障,因此开始进行Leader选举。
SID为1、2、4的机器情况(EPOCH,ZXID,SID): (1,8,1) (1,8,2) (1,7,4)
按照选举规则,机器2将获得票数(3)过半,机器2将当选 Leader 。如此时机器3、5恢复,则会按照集群中本来就存在 Leader 的情况进行,即都变为 follower
4. 客户端常用命令行操作
连接客户端:bin/zkCli.sh -server zkServer1:2181
不加 -server 默认连接本机
命令基本语法 | 功能描述 |
---|---|
ls [-w] [-s] path | 查看当前 znode 的子节点,-w 监听子节点变化,-s 附加次级信息 |
create [-e] [-s] path value | 普通创建,-s 含有序列,-e 临时(重启或者超时消失) |
get [-w] [-s] path | 获得节点的值,-w 监听节点内容变化,-s 附加次级信息 |
set path value | 设置节点的具体值 |
stat | 相当于上面加 -s 参数 |
delete | 删除节点 |
deleteall | 递归删除节点 |
4.1 查看节点信息
ls path
:示例ls /
,可使用-w
监听子节点变化或-s
查看附加次级信息
[zk: localhost:2181(CONNECTED) 8] ls -s /
[ZooKeeper]
cZxid = 0x0
ctime = Thu Jan 01 08:00:00 CST 1970
mZxid = 0x0
mtime = Thu Jan 01 08:00:00 CST 1970
pZxid = 0x0
cversion = -1
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 0
numChildren = 1
- czxid:创建节点的事务 zxid,每次修改 ZooKeeper 状态都会产生一个 ZooKeeper 事务 ID。事务 ID 是 ZooKeeper 中所有修改总的次序。每次修改都有唯一的 zxid,如果 zxid1 小于 zxid2,那么 zxid1 在 zxid2 之前发生
- ctime:znode 被创建的毫秒数(从 1970 年开始)
- mzxid:znode 最后更新的事务 zxid
- mtime:znode 最后修改的毫秒数(从 1970 年开始)
- pZxid:znode 最后更新的子节点 zxid
- cversion:znode 子节点变化号,znode 子节点修改次数
- dataversion:znode 数据变化号
- aclVersion:znode 访问控制列表的变化号
- ephemeralOwner:如果是临时节点,这个是 znode 拥有者的 session id。如果不是临时节点则是 0
- dataLength:znode 的数据长度
- numChildren:znode 子节点数量
4.2 节点类型
节点类型分为持久和短暂
- 持久(Persistent):客户端和服务器端断开连接后,创建的节点不删除
- 短暂(Ephemeral):客户端和服务器端断开连接后,创建的节点自己删除
节点类型还可分为有序号和无序号。创建znode时设置顺序标识,znode名称后会附加一个值,顺序号是一个单调递增的计数器,由父节点维护
注意:在分布式系统中,顺序号可以被用于为所有的事件进行全局排序,这样客户端可以通过顺序号推断事件的顺序
创建命令如下,-s
含有序列,-e
临时,创建对应节点时哪怕不使用 -s
参数,底层也会占用顺序号,且同一连接,不同节点的顺序号也是共用的
create [-e] [-s] path value
4.3 监听器原理
客户端注册监听它关心的目录节点,当目录节点发生变化(数据改变、节点删除、子目录节点增加删除)时,ZooKeeper 会通知客户端。监听机制保证 ZooKeeper 保存的任何的数据的任何改变都能快速的响应到监听了该节点的应用程序。
监听语法:下面监听均只会触发一次,想要多次触发就要触发后再监听
ls -w path
:监听节点路径变化情况,不含子层,如ls -w /a
可监听到/a
本身及最近子结点/a/b
或/a/c
的变化,但不能监听到/a/b/d
的变化,且监听事件只会触发一次get -w path
:监听节点的值的变化,监听事件只会触发一次
原理:
- 首先客户端有一个main()线程
- 在main线程中创建ZooKeeper客户端,这时就会创建两个线程,一个负责网络连接通信(connet),一个负责监听(listener)
- 通过connect线程将注册的监听事件发送给ZooKeeper
- 在ZooKeeper的注册监听器列表中将注册的监听事件添加到列表中
- ZooKeeper监听到有数据或路径变化,就会将这个消息发送给listener线程
- listener线程内部调用了process()方法
4.4 写数据流程
如写入请求发送给 Leader:
如写入请求发送给 Follower:
5. 编程实现监听服务动态上下线
- 引入依赖
<dependency>
<groupId>org.apache.ZooKeeper</groupId>
<artifactId>ZooKeeper</artifactId>
<version>3.8.3</version>
</dependency>
- 添加日志配置文件
logback.xml
到类资源目录resources
下,此步十分必要,不然连接集群会一直打 debug 日志
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%-4relative [%thread] %-5level %logger{35} - %msg %n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>
- 服务端代码:创建临时节点,可以理解为服务的提供者
public class DistributeServer {
public final static String ZK_SER = "192.168.115.129:2181,192.168.115.131:2181,192.168.115.132:2181";
public final static int SESSION_TIME_OUT = 2000;
public final static String ROOT_NODE = "/servers";
public static void main(String[] args) throws IOException, InterruptedException, KeeperException {
ZooKeeper ZooKeeper = getZooKeeper();
Scanner sc = new Scanner(System.in);
do {
System.out.print("请输入要注册的服务名:");
String temp = sc.nextLine();
System.out.println();
if ("stop".equals(temp)) {
break;
}
registerServer(ZooKeeper, temp);
} while (true);
}
public static ZooKeeper getZooKeeper() throws IOException, InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
ZooKeeper ZooKeeper = new ZooKeeper(ZK_SER, SESSION_TIME_OUT, watchedEvent -> {
System.out.println("连接成功会执行一次" + watchedEvent.getType());
countDownLatch.countDown();
});
countDownLatch.await();
return ZooKeeper;
}
public static void registerServer(ZooKeeper ZooKeeper, String serverName) throws InterruptedException, KeeperException {
Stat exists = ZooKeeper.exists(ROOT_NODE, false);
if (exists == null) {
String res = ZooKeeper.create(ROOT_NODE, "Distribute服务".getBytes(StandardCharsets.UTF_8),
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);// 持久节点
System.out.println("创建持久节点响应:" + res);
}
String res = ZooKeeper.create(ROOT_NODE + "/" + serverName, serverName.getBytes(StandardCharsets.UTF_8),
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); // 临时顺序节点
System.out.println("创建临时节点响应:" + res);
}
}
- 客户端代码:可以理解为服务的消费者,监听临时节点变化,如节点消失则表示对应服务不可用
public class DistributeClient {
private static ZooKeeper ZooKeeper = null;
public static ZooKeeper getZooKeeper() throws IOException, InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
ZooKeeper = new ZooKeeper(DistributeServer.ZK_SER, DistributeServer.SESSION_TIME_OUT, watchedEvent -> {
countDownLatch.countDown();
System.out.println("监听事件变化" + watchedEvent);
// NodeChildrenChanged(4)
if (watchedEvent.getType().getIntValue() == 4) {
try {
// 再次监听,达到重复监听的效果
ZooKeeper.getChildren(DistributeServer.ROOT_NODE, true);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
// 等待完全连接上
countDownLatch.await();
return ZooKeeper;
}
public static void main(String[] args) throws IOException, InterruptedException, KeeperException {
ZooKeeper ZooKeeper = getZooKeeper();
// 监听所有节点变化,只会触发一次
List<String> children = ZooKeeper.getChildren(DistributeServer.ROOT_NODE, true);
System.out.println("监听节点:" + children);
System.in.read();
}
}
6. 分布式锁
6.1 手写简易分布式锁
分布式锁在 redis 篇章已经描述够详细,故这里编码只是简易实现一个分布式锁,不考虑原子性,不考虑重入,也不继承 Locks
接口,方便理解基于 ZooKeeper 分布式锁的基础实现算法。算法流程如图:
public class DistributeLock {
public final static String ZK_SER = "192.168.115.129:2181,192.168.115.131:2181,192.168.115.132:2181";
public final static int SESSION_TIME_OUT = 2000;
public final static String ROOT_NODE = "/locks";
private static ZooKeeper ZooKeeper = null;
private static final CountDownLatch INIT_LATCH = new CountDownLatch(1);
static {
try {
System.out.println("ZooKeeper 开始连接");
ZooKeeper = new ZooKeeper(ZK_SER, SESSION_TIME_OUT, watchedEvent -> {
System.out.println("连接 ZooKeeper成功");
INIT_LATCH.countDown();
});
Stat exists = ZooKeeper.exists(ROOT_NODE, false);
if (exists == null) {
String res = ZooKeeper.create(ROOT_NODE, "DistributeLock".getBytes(StandardCharsets.UTF_8),
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);// 持久节点
System.out.println("初始化锁节点成功:" + res);
}
} catch (Exception e) {
e.printStackTrace();
}
}
private String lock;
public DistributeLock(String lock) {
this.lock = lock;
try {
INIT_LATCH.await();
} catch (Exception e) {
e.printStackTrace();
}
}
private String currentNode;
public synchronized void lock() throws InterruptedException, KeeperException {
currentNode = ZooKeeper.create(ROOT_NODE + "/" + lock, null, // /locks/lock0000000014 | /locks/lock0000000015
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); // 临时顺序节点
List<String> children = ZooKeeper.getChildren(ROOT_NODE, false);
// 过滤 + 排序 = [lock0000000014, lock0000000015]
List<String> lockList = children.stream().filter(item -> item.startsWith(lock)).sorted().toList();
// 当前节点不是最小节点则监听并阻塞
if (!currentNode.substring(currentNode.lastIndexOf("/") + 1).equals(lockList.get(0))){
CountDownLatch countDownLatch = new CountDownLatch(1);
ZooKeeper.getChildren(ROOT_NODE + "/" + lockList.get(0), event -> {
if (event.getType() == Watcher.Event.EventType.NodeDeleted) {
countDownLatch.countDown();
}
});
countDownLatch.await();
}
// 是最小节点则返回直接执行
}
public synchronized void unlock() throws InterruptedException, KeeperException {
ZooKeeper.delete(currentNode, -1); // 删除临时节点
}
}
测试代码:
public static void main(String[] args) throws Exception {
DistributeLock lock1 = new DistributeLock("lock");
DistributeLock lock2 = new DistributeLock("lock");
new Thread(() -> {
try {
lock1.lock();
System.out.println("线程1 抢到锁");
TimeUnit.SECONDS.sleep(3);
System.out.println("线程1 即将解锁");
lock1.unlock();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
lock2.lock();
System.out.println("线程2 抢到锁");
TimeUnit.SECONDS.sleep(3);
System.out.println("线程2 即将解锁");
lock2.unlock();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
输出:
线程2 抢到锁
线程2 即将解锁
线程1 抢到锁
线程1 即将解锁
6.2 Curator 框架
Curator 是一个专门解决分布式锁的框架,解决了原生 JavaAPI 开发分布式遇到的问题
官网:https://curator.apache.org/docs/about
- 引入依赖
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>4.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.3.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-client</artifactId>
<version>4.3.0</version>
</dependency>
- 使用演示
public class MainMaven {
private final static String ROOT_NODE = "/locks";
public final static String ZK_SER = "192.168.115.129:2181,192.168.115.131:2181,192.168.115.132:2181";
public final static int SESSION_TIME_OUT = 2000;
public final static int CONNECT_TIME_OUT = 2000;
public static void main(String[] args) {
final InterProcessLock lock1 = new InterProcessMutex(getCuratorFramework(), ROOT_NODE);
final InterProcessLock lock2 = new InterProcessMutex(getCuratorFramework(), ROOT_NODE);
new Thread(() -> {
try {
lock1.acquire();
System.out.println("线程1 抢到锁");
TimeUnit.SECONDS.sleep(3);
System.out.println("线程1 即将解锁");
lock1.release();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
lock2.acquire();
System.out.println("线程2 抢到锁");
TimeUnit.SECONDS.sleep(3);
System.out.println("线程2 即将解锁");
lock2.release();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
// 分布式锁初始化
public static CuratorFramework getCuratorFramework(){
//重试策略,初试时间 3 秒,重试 3 次
RetryPolicy policy = new ExponentialBackoffRetry(3000, 3);
//通过工厂创建 Curator
CuratorFramework client =
CuratorFrameworkFactory.builder()
.connectString(ZK_SER)
.connectionTimeoutMs(CONNECT_TIME_OUT)
.sessionTimeoutMs(SESSION_TIME_OUT)
.retryPolicy(policy).build();
//开启连接
client.start();
System.out.println("ZooKeeper 初始化完成...");
return client;
}
}
输出:
线程2 抢到锁
线程2 即将解锁
线程1 抢到锁
线程1 即将解锁
7. 进阶知识
7.1 Paxos算法
Paxos算法:一种基于消息传递且具有高度容错特性的一致性算法。
Paxos算法解决的问题:就是如何快速正确的在一个分布式系统中对某个数据值达成一致,并且保证不论发生任何异常,都不会破坏整个系统的一致性。
算法描述:在一个Paxos系统中,首先将所有节点划分为Proposer(提议者),Acceptor(接受者),和Learner(学习者)。每个节点都可以身兼数职
一个完整的Paxos算法流程分为三个阶段:
- Prepare准备阶段:
- 提议者向多个接受者发出Propose请求,要求接受者Promise(承诺)
- 接受者针对收到的Propose请求进行Promise(承诺)
- Accept接受阶段:
- 提议者收到多数接收者的承诺后,向接收者再次发出请求
- 接收者针对收到的请求进行Accept处理
- Learn学习阶段:提议者将形成的决议发送给所有Learners
详细描述:
- 提议者生成全局唯一且递增的Proposal ID,向所有接收者发送Propose请求,这里无需携带提案内容,只携带Proposal ID即可
- 接收者收到Propose请求后,做出“两个承诺,一个应答“
- 不再接受Proposal ID小于等于当前请求的Propose请求
- 不再接受Proposal ID小于当前请求的Accept请求
- 不违背以前做出的承诺下,回复已经Accept过的提案中Proposal ID最大的那个提案的Value和Proposal ID,没有则返回空值
- 提议者收到多数接收者的Promise应答后,从应答中选择Proposal ID最大的提案的Value,作为本次要发起的提案。如果所有应答的提案Value均为空值,则可以自己随意决定提案Value。然后携带当前Proposal ID,向所有Acceptor发送Propose请求
- 接收者收到Propose请求后,在不违背自己之前做出的承诺下,接受并持久化当前Proposal ID和提案Value
- 提议者将形成的决议发送给所有Learners
情景示例1:
- 假如有5台服务器 A~E ,A收到写入请求
set a 5
- A则会创建事务ID 1,并向 B~E 发送事务 ID 为 1 的请求
- B~E 收到请求后,做出"两个承诺,一个应答"
- A 收到 B~E 的应答均为空值,超过半数,则会向 B~E 发起事务ID为1的
set a 5
Accept请求 - B~E 收到事务ID为1
set a 5
的Accept请求,不违背事务 ID 小于 0,则接受并持久化当前事务 ID 及提案值 5
情景示例2:
- 假如有5台服务器 A~E ,A收到写入请求
set a 5
,E收到写入请求set a 10
- A发起事务ID为 1的提案,E发起事务ID为 2的提案
- B、C先收到A的提案均做出两个承诺一个应答;D先收到E的提案做出两个承诺一个应答
- A的提案应答过半,则会发起 Accept 请求
- 如果 B、C 已经再次收到 E的提案,则会拒绝接受 A 的 Accept 请求
- 如果 B、C 未收到 E的提案,则会先持久化,然后再收到 E的提案,响应 E的提案
- 则无论如何最终所有节点都将响应 E的提案,并将值最终设置为 10
7.2 ZAB协议
Zab协议是为分布式协调服务ZooKeeper专门设计的一种支持崩溃恢复的原子广播协议,是ZooKeeper保证数据一致性的核心算法。Zab借鉴了Paxos算法,但又不像Paxos那样,是一种通用的分布式一致性算法。它是特别为ZooKeeper设计的支持崩溃恢复的原子广播协议。
Zab协议的核心:定义了事务请求的处理方式
- 所有的事务请求必须由一个全局唯一的服务器来协调处理,这样的服务器被叫做Leader服务器。其他剩余的服务器则是Follower服务器
- Leader服务器负责将一个客户端事务请求,转换成一个事务Proposal,并将该 Proposal 分发给集群中所有的 Follower 服务器,也就是向所有 Follower 节点发送数据广播请求(或数据复制)
- 分发之后Leader服务器需要等待所有Follower服务器的反馈(Ack请求),在Zab协议中,只要超过半数的Follower服务器进行了正确的反馈后(也就是收到半数以上的Follower的Ack请求),那么 Leader 就会再次向所有的 Follower服务器发送 Commit 消息,要求其将上一个 事务proposal 进行提交
Zab 协议包括两种基本的模式:崩溃恢复和消息广播
崩溃恢复:一旦Leader服务器出现崩溃或者由于网络原因导致Leader服务器失去了与过半 Follower的联系,那么就会进入崩溃恢复模式
- 异常情况1:一个事务在Leader上提交了,且过半Follower都响应Ack了,但Leader在Commit消息发出之前挂了。已经产生的提案,Follower必须执行
- 异常情况2:假设一个事务在Leader提出之后,Leader挂了。丢弃胎死腹中的提案
崩溃恢复主要包括两部分:Leader选举和数据恢复。
Leader选举:
- 新Leader必须都是已经提交了Proposal的Follower服务器节点
- 新选举的Leader节点中含有最大的zxid
数据同步:
- 完成Leader选举后,在正式开始工作之前Leader服务器会首先确认事务日志中的所有的Proposal 是否已经被集群中过半的服务器Commit
- Leader服务器需要确保所有的Follower服务器能够接收到每一条事务的Proposal,并且能将所有已经提交的事务Proposal应用到内存数据中。等到Follower将所有尚未同步的事务Proposal都从Leader服务器上同步过,并且应用到内存数据中以后,Leader才会把该Follower加入到真正可用的Follower列表中
Zab数据同步过程中,如何处理需要丢弃的Proposal?
在Zab的事务编号zxid设计中,zxid是一个64位的数字。其中低32位可以看成一个简单的单增计数器,针对客户端每一个事务请求,Leader在产生新的Proposal事务时,都会对该计数器加1。而高32位则代表了Leader周期的epoch编号。
epoch编号可以理解为当前集群所处的年代,或者周期。每次Leader变更之后都会在 epoch的基础上加1,这样旧的Leader崩溃恢复之后,其他Follower也不会听它的了,因为 Follower只服从epoch最高的Leader命令。
每当选举产生一个新的 Leader,就会从这个Leader服务器上取出本地事务日志充最大编号Proposal的zxid,并从zxid中解析得到对应的epoch编号,然后再对其加1,之后该编号就作为新的epoch 值,并将低32位数字归零,由0开始重新生成zxid。
Zab协议通过epoch编号来区分Leader变化周期,能够有效避免不同的Leader错误的使用了相同的zxid编号提出了不一样的Proposal的异常情况。
基于以上策略,当一个包含了上一个Leader周期中尚未提交过的事务Proposal的服务器启动时,当这台机器加入集群中,以Follower角色连上Leader服务器后,Leader 服务器会根据自己服务器上最后提交的 Proposal来和Follower服务器的Proposal进行比对,比对的结果肯定是Leader要求Follower进行一个回退操作,回退到一个确实已经被集群中过半机器Commit的最新Proposal。
7.3 CAP理论
一个分布式系统不可能同时满足以下三种
- 一致性(C:Consistency):在分布式环境中,一致性是指数据在多个副本之间是否能够保持数据一致的特性。
- 可用性(A:Available):可用性是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果
- 分区容错性(P:Partition Tolerance):分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障
ZooKeeper保证的是CP
- ZooKeeper不能保证每次服务请求的可用性。(注:在极端环境下,ZooKeeper可能会丢弃一些请求,消费者程序需要重新请求才能获得结果)。所以说,ZooKeeper不能保证服务可用性
- 进行Leader选举时集群都是不可用