大家好,从这一章开始,就正式进入到 sofajraft 框架的手写实现领域了。在开始正式实现之前,我想先介绍一下接下来要带领大家实现的这个框架,这个框架是一个 Raft 共识算法框架,一般来说,这种框架只会作为其他框架的一个核心依赖使用,并不会和我们的业务层耦合在一起。就像 Netty 一样,可能在很多框架中都能见到 Netty,但是在真正的工作中,你很少有机会直接使用 Netty 对你的业务做一些操作。而且我还可以很负责任地和大家说一句,Sofa-Jraft框架内部虽然有很多细节,并且对 Raft 算法本身做了一些优化,学习起来可能要花点时间,但是,这个框架没有一点难度,内部的所有逻辑都很顺畅,只要你掌握了 Raft 共识算法,彻底掌握这个框架也是易如反掌的事。当然,还是需要付出时间的,就像我说我可以跑 1000 米,这很简单,有两条腿,只要会迈开腿就能跑 1000 米。但让我现在跑,我体力跟不上啊,可能需要几天时间练习一下,就可以跑完 1000 米了。Sofa-Jraft 框架简单得就像迈开腿跑步一样,只要你花几天时间学习,最后肯定可以完全掌握。
引入 Node 接口,分析 Node 节点的状态转换
既然现在已经正式进入实现 sofa-jraft 框架阶段了,有一些概念还是最好和 raft 共识算法中的概念保持一致比较好。在前三章我虽然为大家举了很多例子,但是所有例子都是围绕着主从复制,也就是单主复制来展开的。在单主复制模式下构建的集群,集群主节点可以处理来自客户端的写操作指令,然后将写操作指令包装成日志传输给从节点,所有从节点只需要将日志持久化到本地即可。如果集群的主节点突然故障了,那么其他从节点就会根据各自的随机超时选举时间,决定自己什么时候触发超时选举,进入超时选举阶段,从而选举出一个新的主节点。可以看到,在前三章的内容中,我们只是以主节点和从节点来区分集群中各个节点的身份。 集群中只能有一个主节点,其他的都是从节点。正常提供服务的集群中,只会有两种类型的节点,那就是主节点和从节点;如果主节点宕机了,在选举新的主节点的时候,集群中所有节点的身份都是从节点。这么做当然没问题,因为前三章我就是在这个模式下,为大家举的例子。但是,现在既然是要正式构建一个工业级别的框架了,显然是越正规越好,越正是越好,越详细越好。
在 raft 共识算法中,把一个集群中的节点分为三种身份,第一种身份就是领导者,也就是主节点,集群中只能有一个领导者;第二种身份就是跟随者,也就是从节点;领导者负责处理客户端的写指令,将写指令包装成带索引的日志,然后把日志传输给集群中的所有跟随着,跟随者会将日志持久化到本地;第三种身份就是候选者,这个候选者听起来有些特殊,实际上很容易理解,当集群中的领导者宕机后,率先触发了超时选举,进入领导者选举阶段的节点,就是候选者。所谓候选者,就是领导者的候选者。 可以看到,虽然在 raft 共识算法中节点的身份是用另一种概念来解释的,但其承担的职责,以及工作模式,和前三章的主节点、从节点都是一样并且对应的。
我在这里忽然解释这个,并不是说想为大家再详细讲解一遍 raft 共识算法,这个算法的大部分内容在前三章都已经讲解完毕了,只不过有些术语并不是按照 raft 共识算法论文中那样解释的。术语或者概念的不同并不会影响大家构建 sofa-jraft 框架。我解释 raft 共识算法定义集群中节点身份的方式,是希望大家能帮助大家形成一种认识:集群中的节点可以被叫做主节点,也可以被叫做从节点,也可以被叫做领导者,被叫做跟随者,被叫做候选者,你可以使用任何称谓,只要你觉得合适。但是隐藏在这些称谓背后的,实际上是节点状态的改变。
好了,到这里让我们来讨论一下,所谓的节点是什么?在我看来,节点就是服务器的抽象。 我们都知道,程序都是部署在服务器上的,集群构建成功之后,集群中的各个服务器会根据其他服务器的状态改变自己的状态,当然,集群一切正常的时候,各个服务器正常工作即可。但有一个服务器无法提供正常服务,可能就会让另一个服务器进入到领导者选举阶段,从而改变自己的状态。在程序运行的过程中,在集群工作的时候,我们希望某个服务器作为领导者,也就是主节点,希望某些服务器可以作为跟随者,也就是从节点,希望某个服务器可以在适当的时候成为候选者,这些情况我们肯定会遇到。但是,我们又不能直接操纵服务器本身,所以就在程序中定义了一个 Node 节点,用这个节点来作为服务器的抽象。给这个 Node 定义了一些方法和属性,人为地规定这个节点在某些情况下只能干什么,不能干什么,这样一来,集群中的一套规则也就构建完整了。这就和创建的线程池差不多,线程池在某个状态下只能做固定的工作,如果一个线程池处于已经关闭的状态,肯定就不能执行任务了。
接下来再举一个例子,比如说,一个集群被构建完成了,我启动了这个集群。集群刚刚启动的时候,集群中的每个节点都是刚刚被创建,这时候集群中没有领导者,也没有跟随者,也没有候选者。集群中各个节点的状态就应该都是未初始化的状态。在经过了一段时间之后,某个节点率先触发了超时选举,进入了领导者选举阶段,这时候,这个节点就要变成候选者状态,如果成功当选为领导者,那么这个节点就要变更自己的状态为领导者,如果没能当选成功,就要变更自己的状态为跟随者。而节点所处的状态,决定了节点本身可不可以将指令包装为日志,也就是能否生产日志。如果不能生产日志,那么节点只能复制日志,被动应用每一条客户端指令。
很好,现在我们已经为节点定义了 4 种状态了,分别是:未初始化状态,领导者状态,候选者状态,跟随者状态。 也许有的朋友认为这样就已经够了,但要我说,这几种状态定义得还是不够全面,还要再添加几种。比如说节点运行过程中出现错误不能正常工作了,至少应该定义一个错误状态;总有一个时刻,节点也可能要结束工作了,就像线程池那样,所以也应该定义一个正在停止工作状态,还有一个已经停止工作状态。这种套路大家应该都已经很熟悉了吧?优雅停机那一套大家应该已经见得够多了。所以,现在我们已经给节点定了一 7 种状态了,分别是:未初始化状态,领导者状态,候选者状态,跟随者状态,错误状态,正在停止工作状态,已经停止工作状态。 如果用代码来表示的话,可以定义成一个 State 枚举类,并且我们还可以在枚举类中定义一个判断当前节点是否处于活跃状态的方法。请看下面代码块。
//节点状态枚举类
public enum State {
//领导者状态
STATE_LEADER,
//候选者状态
STATE_CANDIDATE,
//跟随者状态
STATE_FOLLOWER,
//当前状态表示节点出现了错误
STATE_ERROR,
//表示节点还未初始化
STATE_UNINITIALIZED,
//表示节点正在停止工作
STATE_SHUTTING,
//已经停止工作
STATE_SHUTDOWN,
//该方法判断当前节点是否处于活跃状态
public boolean isActive() {
//原理很简单,就是判断当前状态的枚举对象是否小于STATE_ERROR的值
//ordinal方法用于返回一个int,排在前面的枚举对象的这个int值小于排在后面的
//只要是小于STATE_ERROR,就意味着当前节点还在正常工作,大于STATE_ERROR,当前节点不是出错,就是要停止工作了
return this.ordinal() < STATE_ERROR.ordinal();
}
}
到此为止,节点的几种状态就已经展示完毕了。接下来,我们就应该重点分析节点的启动过程了。
引入 Node 接口
既然要分析节点的启动过程了,首先还是先把节点本身定义一下吧。在上一小节我们已经把服务器本身抽象成了一个 Node 节点,但实际上,这个 Node 在我们正在搭建的 raft 框架中,只是一个接口,在这个接口中会定义很多方法,这些方法都会由这个接口的实现类 NodeImpl 来实现。当然,现在只是搭建框架的起步阶段,我还没想好 Node 接口中要定义什么方法,所以就先写一个空接口吧。请看下面代码块。
public interface Node {
}
接下来就是 NodeImpl 实现类了,这里我就不得不解释一下,虽然 Node 接口现在是空的,这并不意味着 NodeImpl 实现类也是空的。接口中一般只定义方法,但现在让我们想一想,就算我们还没有想好要给 Node 节点定义什么方法,这并不妨碍给 NodeImpl 实现类定义一些应该具备的属性。比如说,我们是在构建一个完整的集群,集群中有很多节点,每一个 NodeImpl 都是节点本身,是对服务器的抽象,并且集群中节点与节点之间是需要通信的。既然要通信,就应该定义一个类来封装每一个节点的 IP 地址和端口号,比如,我们还是使用前三章的那个 Endpoint 对象来封装节点的 IP 地址和端口号。 具体实现请看下面代码块。
//该类的对象会封装节点的IP地址和端口号信息
public class Endpoint implements Copiable<Endpoint>, Serializable {
private static final long serialVersionUID = -7329681263115546100L;
//初始化的ip地址
private String ip = Utils.IP_ANY;
//端口号
private int port;
//ip地址端口号的字符串形式
private String str;
public Endpoint() {
super();
}
public Endpoint(String address, int port) {
super();
this.ip = address;
this.port = port;
}
public String getIp() {
return this.ip;
}
public int getPort() {
return this.port;
}
@Override
public String toString() {
if (str == null) {
str = this.ip + ":" + this.port;
}
return str;
}
@Override
public Endpoint copy() {
return new Endpoint(this.ip, this.port);
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (this.ip == null ? 0 : this.ip.hashCode());
result = prime * result + this.port;
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final Endpoint other = (Endpoint) obj;
if (this.ip == null) {
if (other.ip != null) {
return false;
}
} else if (!this.ip.equals(other.ip)) {
return false;
}
return this.port == other.port;
}
}
很好,现在让我们继续来思考另一个问题,Endpoint 对象是用来封装一个节点的 IP 地址和端口号的,也就是封装一个服务器的 IP 地址和端口号信息,但现在我们是在构建一个集群,如果某个节点属于一个集群,并且正在对外提供服务,我们还应该再对这个 Endpoint 对象做一层封装。现在让我来解释一下为什么要做一层简单的封装:如果我们只是按照 raft 论文实现一个简单的 raft 共识算法框架,也不考虑一些其他的优化或者细节,那么其实对 Endpoint 封不封装也无所谓,但如果可以自定义一些优化呢?比如我打算给节点本身添加一个额外的属性,就是节点优先级,本来在 raft 集群中,每一个节点都有可能成为领导者,但我们都知道,领导者要处理大量的写操作,所有写操作的指令都会访问到领导者,从这一点上来说,领导者本身的压力是比较大的。如果现在我有 5 台服务器,第 1 台服务器硬件配置最好,其他 4 台性能都一般,只要我脑子不傻,我肯定会选择第 1 台服务器作为集群中的领导者。这时候我们就可以在每一个节点初始化的时候,给每一个节点定义一个优先级,在节点触发了超时选举之后,即将进入领导者选举阶段时,会判断一下自己的优先级是不是集群中最大的优先级,如果是就进入领导者选举阶段,如果不是就不进行领导者选举。这样一来,就可以按照我们的意思指定某个节点成功当选领导者了。 当然,在我们的第一版本代码中,我并没有引入这个节点优先级机制,但不是说这个机制很麻烦,恰恰相反,这个机制非常简单,只需要一个成员变量,一个不到十行的方法就实现了。我之所以没有引入是想先按照比较纯粹的 raft 算法来实现我们这个框架,在第十三版本代码中,我把这个优先级机制实现了,到时候大家可以自己看看,或者直接去源码中查看。
引入集群节点信息类 PeerId
分析了这么多,那么这个新的类该怎么定义呢?首先,这个类肯定需要持有 Endpoint 成员变量,然后就是刚才分析的节点优先级属性,当然,这个属性我们暂时还用不上。所以,我们可以把这个类定义成下面这样,类名就叫做 PeerId 。请看下面代码块。
//这个类就是用来解析当前节点信息的,当前节点的IP地址和端口号,节点参与选举的优先级,这些信息都会封装在该类的对象中
public class PeerId implements Serializable{
private static final long serialVersionUID = 8083529734784884641L;
private static final Logger LOG = LoggerFactory.getLogger(PeerId.class);
public static final String IP_ANY = "0.0.0.0";
//当前节点的IP地址和端口号就封装在这个成员变量对象中
private Endpoint endpoint = new Endpoint(IP_ANY, 0);
//该类对象的toString结果的缓存
private String str;
//节点初始化的时候,默认选举优先级功能是禁止的
private int priority = ElectionPriority.Disabled;
//构造方法
public PeerId() {
}
//创建一个空节点对象
public static PeerId emptyPeer() {
return new PeerId();
}
//设置当前节点选举优先级
public void setPriority(int priority) {
this.priority = priority;
this.str = null;
}
//还有一些get方法就全省略了
}
至于选举优先级的具体定义,请看下面代码块。
//节点选举的优先级
public class ElectionPriority {
//这些优先级信息都会封装在PeerId对象中,在第一版本中并没有展示这方面的功能
//优先级为-1时,表示该节点已禁用了按照优先级选举功能
public static final int Disabled = -1;
//为0时,表示该节点永远不会参数选举,也就意味着永远不会成为领导者
public static final int NotElected = 0;
//选举优先级的最小值,选举的时候优先级最低,比如在集群中就有一台的服务器性能最好,我就希望这个服务器当作领导者
//那就可以通过优先级配置,使这个服务器成为领导者的概率更大
public static final int MinValue = 1;
}
本来这个 PeerId 类的内容,到这里就简单介绍完毕了,如果还要再定义什么方法,那也是后面小节的内容。但是,在 sofajraft 框架中还有一个扩展点,这个是 sofajraft 框架本身的一个扩展点,那就是在 PeerId 类中还定义了一个成员变量 idx,根据官方文档的解释,这个成员变量的作用是为了支持同一个端口号启动不同的 raft 节点,节点的身份就用 idx 的值来区分。但是,这个成员变量只是一个预留的扩展,在程序中并没有被真正使用到,一直就是默认值 0(说实话,我也不知道它这个最后要实现成什么样子,这里我强调一下,这个 idx 成员变量和 sofajraft 框架的 MULTI-RAFT-GROUP 模式并没有很明显的关系,,因为 sofajraft 内嵌的 kv 数据库框架使用的就是 MULTI-RAFT-GROUP 模式,每一个 Store 就是

本文围绕Sofa-Jraft框架的手写实现展开,介绍了Raft共识算法框架特点。引入Node接口,分析节点状态转换,定义了7种节点状态。还引入了PeerId、NodeId等类,介绍Multi - Raft Group模式。最后讲解了NodeOptions类配置参数,完成启动raft集群的前置工作,后续将实现NodeImpl类的init方法。
最低0.47元/天 解锁文章
3809

被折叠的 条评论
为什么被折叠?



