ES节点发现
ES自己实现了一套用于节点发现和选主等功能的模块(7.0版本之前是Zen Discovery,在7.0版本中引入了Coordinator,并且对并对使用了Zen Discovery的节点进行了兼容处理,之后在8.0版本中彻底删除了Zen Discovery的代码),没有依赖Zookeeper等工具(这种做法从长远来看无疑是更合理的,zookeeper会带来额外的运营和学习成本,kafka就在其3.3版本的production代码中正式使用了kraft,不再使用zookeeper了)。
下面基于Coordinator
模块分析它的节点发现机制。
Discovery的流程有两个发起时机,其一是当节点启动的时候,其二是节点认为master节点发生故障的时候(定期ping master节点,即所谓的leader check,类似心跳检测)。 一直到发现master节点或者通过选举产生新的master时,才会终止discovery流程。
整个流程大致是节点通过seed hosts providers 提供的其他的节点的ip地址或者域名(也会通过dns服务转化为ip地址)去和其他节点连接上并交换各自拥有的所有候选节点的地址列表,接着再逐一连接上这些列表中的节点,重复刚刚所说的交换流程。
那么什么时候终止呢?取决于这个节点是不是master的候选节点:
- 如果这个节点不是候选节点,那么discovery流程会进行到发现master节点为止。
- 如果是候选节点的话,那么discovery流程会进行到发现master节点或者发现大多数候选节点都认为master节点发生故障,无法通信为止。当后者的情况发生以后,便会由着候选节点发起新一轮的选举。
Seed Hosts Providers
seed hosts providers的主要作用是提供一个节点列表,其中最好包括所有的候选节点,以更快地发现master节点或者发现集群中大多数候选节点都认为master节点出现故障,从而选出新的master。ES提供了三种方式来配置seed hosts providers。
-
Settings-based seed hosts provider
直接在elasticsearch.yml中配置,缺点是每次修改节点列表都需要先重启才能生效
discovery.seed_hosts: - 192.168.1.10:9300 - 192.168.1.11 - seeds.mydomain.com - [0:0:0:0:0:ffff:c0a8:10c]:9301
-
File-based seed hosts provider
需要先在elasticsearch.yml启用基于文件的配置,好处是每次该文件有改动时,ES会自动重新读取它,不需要重新启动节点。
discovery.seed_providers: file
之后,创建 $ES_PATH_CONF/unicast_hosts.txt 文件,配置节点列表。
10.10.10.5 10.10.10.6:9305 10.10.10.5:10005 # an IPv6 address [2001:0db8:85a3:0000:0000:8a2e:0370:7334]:9301
此外,如果同时也在配置了
discovery.seed_hosts
,则最终的节点列表也包含了discovery.seed_hosts
中的节点。 -
Seed Hosts Providers plugin
ES也支持通过各种seed hosts providers的插件来提供节点列表,如支持从aws cloud获取节点列表的
EC2 discovery plugin
,支持从azure cloud获取节点列表的Azure Classic discovery plugin
等。 可以通过如下命令进行plugin的安装和卸载。sudo bin/elasticsearch-plugin install xxx sudo bin/elasticsearch-plugin remove xxx
节点发现流程
节点发现的整个流程主要集中在PeerFinder
这个类当中。以下是一些节选代码,没有特殊说明的表示是位于PeerFinder
类中的代码。
Coordinator
if (mode != Mode.CANDIDATE) {
final Mode prevMode = mode;
mode = Mode.CANDIDATE;
peerFinder.activate(coordinationState.get().getLastAcceptedState().nodes());
...
}
}
在节点启动时,master-eligible节点会先转换为Candidate状态,并且尝试与之前持久化存储在磁盘中cluster state中记录的集群节点以及seed host provider提供的节点进行通信。
public void activate(final DiscoveryNodes lastAcceptedNodes) {
logger.trace("activating with {}", lastAcceptedNodes);
synchronized (mutex) {
...
// 表明PeerFinder正在activate过程中
active = true;
...
// wake up这个名字很贴切,”唤醒“ 集群中的节点,即与这些节点建立连接,并将相关的状态维护在一个map中,这个map就是下面代码中的peersByAddress。
handleWakeUp();
}
onFoundPeersUpdated(); // trigger a check for a quorum already
}
private boolean handleWakeUp() {
assert holdsLock() : "PeerFinder mutex not held";
// peersByAddress是一个以节点地址为键,Peer为值的map,peer表示集群中的其他节点
// 首先移除掉peersByAddress中所有已经与当前节点断开连接的节点
final boolean peersRemoved = peersByAddress.values().removeIf(Peer::handleWakeUp);
if (active == false) {
logger.trace("not active");
return peersRemoved;
}
logger.trace("probing master nodes from cluster state: {}", lastAcceptedNodes);
// 与此前持久化存储的cluster state中记录的master-eligible节点建立建立连接,并且保存到peersByAddress中
for (DiscoveryNode discoveryNode : lastAcceptedNodes.getMasterNodes().values()) {
startProbe(discoveryNode.getAddress());
}
// 与配置的seed host provider中的节点建立连接,并且保存到peersByAddress中,由于peersByAddress是一个map,因此不存在重复的ip地址的情况
configuredHostsResolver.resolveConfiguredHosts(providedAddresses -> {
synchronized (mutex) {
lastResolvedAddresses = providedAddresses;
logger.trace("probing resolved transport addresses {}", providedAddresses);
providedAddresses.forEach(this::startProbe);
}
});
// 周期性的使用某个线程去执行此任务,间隔时间由基于配置项:discovery.find_peers_interval
transportService.getThreadPool().scheduleUnlessShuttingDown(findPeersInterval, clusterCoordinationExecutor, new Runnable() {
@Override
public void run() {
synchronized (mutex) {
// 当发现peersByAddress中的所有节点都是连接状态时就会直接退出,因为这表明集群中没有节点变更,所以无需在Coordinator模块中执行onFoundPeersUpdated
if (handleWakeUp() == false) {
return;
}
}
onFoundPeersUpdated();
}
@Override
public String toString() {
return "PeerFinder handling wakeup";
}
});
// 集群中若无节点变更,则返回false,否则返回true,有节点变更的话,需要在Coordinator模块中执行响应的逻辑,会放在后续的leader选举中详细阐述
return peersRemoved;
}
综上所述,所有的过程主要是为了构建一个完备的节点列表,并且这些节点都与本地节点处于连接的状态,相关的信息在PeerFinder
的peersByAddress
成员变量中维护,为后续的选主做准备。
选主分析
在此之前,先罗列一下master节点在集群中的职责:
-
集群状态管理 主节点维护整个集群的状态,包括节点信息、索引元数据和分片分配等。它确保集群的所有节点共享一致的状态信息。
-
分片分配 主节点管理索引的主分片和副本分片的分配和迁移,确保数据在集群中均匀分布,并在节点故障时进行分片的重新分配。
-
节点管理 主节点负责管理集群中的所有节点,包括节点的加入和离开。它通过定期检测节点的健康状态,确保集群的稳定性。
-
索引创建和删除 主节点处理索引的创建、删除和关闭等操作,并维护索引的元数据,确保这些操作在整个集群中一致生效。
-
集群级别设置 主节点管理集群级别的配置和设置,通过API进行动态调整。例如,设置副本数量、分片路由等。
-
快照和恢复 主节点协调集群的快照创建和恢复过程,确保数据的备份和恢复操作有序进行。
-
路由表更新 主节点维护和更新分片的路由表,确保数据请求能够正确路由到对应的节点和分片。
-
处理集群阻塞(Cluster Blocks) 在特定情况下,主节点可以设置集群阻塞,防止对集群或索引的操作,以确保数据的一致性和完整性。
可以看出master节点在集群中扮演着十分重要的角色,因此一套完善的选主流程也是必不可少的。
Zen Discovery(7.0 版本之前使用的节点发现与选主模块)
选举涉及节点
选举发起节点和涉及节点包含了所有进行了如下配置
node.master: true(7.8及之前版本)
的节点,这些节点在ES中称之为master-eligible
节点。因此只是数据节点无法参与选举,也不能成为master节点。
选举流程
发起的时机
- 当一个节点开始启动时,会尝试加入一个集群,首先通过
Zen Discovery
模块找到所有可以连接上的节点,并通过这些节点获取一个master节点,失败时 - 当一个节点通过
MasterFaultDetection
机制(定时地去ping master节点,间隔时间基于配置discovery.zen.fd.ping_interval
)发现无法联系上master节点时
以上两种情况发生时,ES会去检查一下当前是否用有超过半数即大多数(其初始值由discovery.zen.minimum_master_nodes
决定,这个配置对Zen Discovery模块至关重要)的候选节点,如果有的话会尝试去选取一个新的master节点。
选举规则
那么选择哪个候选节点作为master呢?
- 选择clusterStateVersion大的,这是为了保证新的master节点拥有最新的cluster state
- 当clusterStateVersion相同时,选择节点ID小的,这个Id是节点第一次启动时生成的一个随机字符串(基于bully算法,比较节点的某个标识符的大小来决定选择哪个节点作为master)。
特别的,如果最后选中的master节点就是发起选举的候选节点本身,它会进入一个等待选票的状态(等待的超时时基于配置discovery.zen.master_election.wait_for_joins_timeout
,默认是30秒,若超时了,则本次选举自己为master节点失败),当最少有除自己以外半数的节点的投票(自己也算一票,加上除自己以外的半数节点的投票,即得到了大多数节点的投票)后就会成为新的master节点。
否则,如果选中的是别的候选节点,就会对这个候选节点发送一个join请求,相当于投票给这个选出来的节点。
可以看出Zen Discovery
的master选取比较简单,也很容易理解。但是存在一个明显的问题,就是当一个节点意图选举另一个节点为master且迟迟没有成功时就会重新发起join请求,此时如果集群中多了一个ID更小的节点就会去选举这个新的节点,从而相当于一个节点投了两次票给不同的节点,这样可能就会有多个节点收到了大多数节点的投票。而Raft算法通过引入term这个概念及相关逻辑会保证在一个term内,一个节点只会给一个候选节点投一张票。
另外一个缺点就是,Zen Discovery
模块重度依赖于discovery.zen.minimum_master_nodes
的配置,当集群中新增或者移除节点时,必须对该配置进行修改。当然,如果只能通过修改elasticsearch.yml
配置文件的话然后重新启动节点的话对集群整体的可用性是无法接受的,因此ES支持通过api动态修改此配置:
PUT /_cluster/settings
{
"persistent" : {
"discovery.zen.minimum_master_nodes" : 2
}
}
同时提供了persistent
选项,可以将本次修改进行持久化,即使节点重启后也会生效。但是即使如此,每次集群中有节点变更,都需要调用此api,也是一件很麻烦且容易出错的事。
事实上,在7.0版本ES引入了Raft算法的一些理念重新设计了一套机制,即上文中提到过的Coordinator
模块,相对于Zen Discovery
模块,前者的正确性和稳定性以及效率都有较大的提升,同时也移除了discovery.zen.minimum_master_nodes
配置,意味着集群变更时无需手动调用api去修改此配置,解决了集群运维的一大痛点。下文会基于Coordinator
模块继续分析。
Coordinator(7.0 版本及之后使用的节点发现与选主模块)
关键概念
Coordinator
模块引入了Raft算法,相较于之前较为简单的Zen Discovery
模块,多了很多机制和概念,下面先说明一下这些新增的内容。
节点状态
和Raft一样,Coordinator
模块中节点也有三种状态,即leader,follower,candidate。
- Leader 负责处理来自客户端的所有请求,集群中仅有一个leader
- Follower 不处理客户端的请求,收到来自客户端的请求也只会转发给leader,但是会处理来自candidate服务器和leader服务器的请求,集群中有多个follower
- Candidate 是一种特殊的过渡状态,当一个节点决定自身要通过选举成为master时,它就会转变为Candidate状态。
Voting configurations
正如之前的章节中所说的,在Coordinator
模块中,discovery.zen.minimum_master_nodes
这个配置已经被移除,用户无需手动维护大多数节点的数量的配置,取而代之的是Voting configurations及与其相关的一些机制。所谓Voting configurations,指的是集群中所有master-eligible(master候选节点)节点的集合。如果集群中有节点变更,Coordinator
模块也会自动地更新voting configuration。
Term(任期)
在Raft一致性算法中,term(任期)是一个重要的概念,其实际上是一组连续的整数,用于管理leader选举和日志复制过程。Term是指leader任职的周期。每当发起一个新选举时,candidate都需要将当前leader的term加上1,然后发送给别的follower,当选举成功后,这个新的term就将是新leader的任期。在ES的选举中,也引入了term的概念,大致含义差不多。
选举涉及节点
选举发起节点和涉及节点包含了所有进行了如下配置
node.roles: [ master ] (7.9及之后版本)
的节点,这些节点在ES中称之为master-eligible
节点。
选举流程
发起的时刻
Master选举一般都有一个类似的问题,即多个候选节点同时发起选举,最后的结果可能是哪个候选节点都无法获得大部分节点的投票(支持),因此无论是Raft算法还是ES,都会让每个候选节点选举的时间进行一个随机化处理,避免出现这种同时发起选举的情况。
Elections only usually fail when two nodes both happen to start their elections at about the same time, so elections are scheduled randomly on each node to reduce the probability of this happening. Nodes will retry elections until a master is elected, backing off on failure, so that eventually an election will succeed (with arbitrarily high probability).
(来源于ES官方文档)
ES可以通过以下配置
// 设置每个master候选节点当上一次选举失败以后,再此发起选举的时间的一个上限
cluster.election.back_off_time
// 设置每个master候选节点第一次发起选举或者当检测到master故障时尝试发起的第一次选举的时间上限
cluster.election.initial_timeout
// 设置每个master候选节点第一次发起选举的最大时间上限
cluster.election.max_timeout
来设置一些关于选举时间的参数,下面是基于这些配置参数计算发起选举的时间以及下一次选举间隔时间的代码。
void scheduleNextElection(final TimeValue gracePeriod, final Runnable scheduledRunnable) {
if (isClosed.get()) {
logger.debug("{} not scheduling election", this);
return;
}
// 新的一次选举尝试
final long thisAttempt = attempt.getAndIncrement();
// to overflow here would take over a million years of failed election attempts, so we won't worry about that:
// maxTimeout对应于cluster.election.max_timeout配置
// initialTimeout对应于cluster.election.initial_timeout配置
// backoffTime对应于cluster.election.back_off_time配置,
// 此配置需要与当前尝试次数做一个乘积加到总的时间里去,意味着失败的次数越多,下一次发起选举的时间也越久,所以说是回退时间还是很合理的
final long maxDelayMillis = Math.min(maxTimeout.millis(), initialTimeout.millis() + thisAttempt * backoffTime.millis());
// 最终计算随机得到一个小于maxDelayMillis的时间加上gracePeriod的时间,如此就达到了每个节点发起选举的时间的一个随机化处理
final long delayMillis = toPositiveLongAtMost(random.nextLong(), maxDelayMillis) + gracePeriod.millis();
final Runnable runnable = new AbstractRunnable() {
@Override
public void onRejection(Exception e) {
logger.debug("threadpool was shut down", e);
}
@Override
public void onFailure(Exception e) {
logger.debug(() -> format("unexpected exception in wakeup of %s", this), e);
assert false : e;
}
@Override
protected void doRun() {
if (isClosed.get()) {
logger.debug("{} not starting election", this);
} else {
logger.debug("{} starting election", this);
// 尝试 10 次后还不成功,直接终止流程
if (thisAttempt > 0 && thisAttempt % 10 == 0) {
logger.info("""
retrying master election after [{}] failed attempts; \
election attempts are currently scheduled up to [{}ms] apart""", thisAttempt, maxDelayMillis);
}
// 再次执行本函数
scheduleNextElection(duration, scheduledRunnable);
// 执行具体选举要做的事情
scheduledRunnable.run();
}
}
@Override
public String toString() {
return "scheduleNextElection{gracePeriod="
+ gracePeriod
+ ", thisAttempt="
+ thisAttempt
+ ", maxDelayMillis="
+ maxDelayMillis
+ ", delayMillis="
+ delayMillis
+ ", "
+ ElectionScheduler.this
+ "}";
}
};
logger.debug("scheduling {}", runnable);
// 发起下一次选举
threadPool.scheduleUnlessShuttingDown(TimeValue.timeValueMillis(delayMillis), clusterCoordinationExecutor, runnable);
}
可以看到经过这么多计算,就是为了解决上述所说的多个节点同时或者近乎同时发起选举导致选主流程的时间延长的问题。
// 最终计算随机得到一个小于maxDelayMillis的时间加上gracePeriod的时间
final long delayMillis = toPositiveLongAtMost(random.nextLong(), maxDelayMillis) + gracePeriod.millis();
发起的时机
- 整个集群首次启动时
- 当一个节点通过
leader check
机制(定时地去ping master节点,间隔时间基于配置cluster.fault_detection.leader_check.interval
)发现无法联系上leader节点时
选举规则
选择哪个候选节点作为master呢?
- 选择term大的
- 当term相同时,选择clusterStateVersion更新的
相较于Zen Discovery
固定选择节点ID较小的,Coordinator
模块会基于term做考量,term大的表明集群中有一个节点在与master失联后发起新的选举,选择这个节点作为master更为合理,同时考虑到部分节点可能因为网络分区,频繁发起选举导致term很大,所以也会考虑到cluster state version,只有term和cluster state version同时比收到投票请求的节点拥有的更大时,才会认为此节点有资格成为新的master。
详细流程
-
节点转变为Candidate状态
-
在Candidate状态下,首先判断当前节点是否有可能赢得选举,判断的规则为 voting configuration 中是否包含当前节点,并且不包含在排除节点中(当一个节点的名称为_absent_时,该节点在加入集群时,就会记录在 coordinator的Meta data中的VotingConfigExclusion结构中),此时该节点可能成为master,否则直接退出选举流程。
-
开始预投票,其本质是一个二阶段提交,首先先向之前章节:节点发现中建立的
peersByAddress
中记录的所有发现的节点发送preVoteRequest
,参数比较简单,是Candidate节点本身和它的term。当其他节点收到这个预投票请求时,有如下的处理流程:当发起选举的节点收到各个节点的返回的响应后,有如下的处理流程:
值得一提的是,预选举是支持自定义的,通过
ClusterCoordinationPlugin
即可,同时也支持选举策略等的自定义化。 -
开始正式选举
- 条件满足时,向其他节点发送start join请求:
- 其他节点收到start join请求后进行处理:
-
收到投票后的处理流程: