本篇从启动流程开始,先在宏观上看看整个集群是如何启动的,集群是如何从 Red 变成 Green 状态的。集群启动过程指集群完全重启时的启动过程,期间主要经历如下四个重要阶段,理解其中原理和细节,对于解决或避免集群维护过程中可能遇到的脑裂、无主、恢复慢、丢数据等问题有重要作用。
image.png

选举 Master 节点

假设在一个集群中同时有若干节点正在启动,集群启动的第一件事是从已知的活跃机器列表中选择一个节点作为主节点,选主之后的流程由主节点触发。

ES 的选主算法是基于 Bully 算法的改进,主要思路是每个节点都有一个唯一 ID,通过对节点 ID 排序取 ID 值最大的节点来作为 Master,每个节点都运行这个流程。选主的目的是确定唯一的主节点,整个过程在实现上被分解为两步:先确定出唯一、公认的主节点,然后再把最新的机器元数据复制到选举出的主节点上。

基于节点 ID 排序的简单选举算法有三个附加约定条件:
image.png
1)参选人数需要过半,达到 quorum 后就选出了临时的主。为什么是临时的?因为每个节点运行排序取最大值的算法结果不一定相同。比如集群有 5 个节点 1、2、3、4、5。当产生网络分区或节点启动速度差异较大时,节点 1 看到的节点列表是 1、2、3、4,由此选出 4;节点 2 看到的节点列表是 2、3、4、5,由此选出 5。结果就不一致了,由此产生下面的第二条限制。

2)得票数需过半。某节点被选为主节点,必须判断加入它的节点数过半才确认 Master 身份。

3)当探测到节点离开事件时,必须判断当前节点数是否过半。如果达不到 quorum 则放弃 Master 身份,重新加入集群。因为如果不这么做可能会产生脑裂。

相关配置:

  1. # 主节点选举时的最小得票数,用于防止脑裂,默认值为-1
  2. discovery.zen.minimum_master_nodes:
  3. # 集群的种子节点列表,构建集群时本节点会尝试连接这个节点列表,在 7.0 由discovery.seed_hosts代替
  4. discovery.zen.ping.unicast.hosts:
  5. discovery.seed_hosts:
  6. # 节点加入现有集群时的超时时间,默认为 ping_timeout 的20倍
  7. discovery.zen.join_timeout:
  8. # 节点加入现有集群超时后的重试次数,默认3次
  9. discovery.zen.join_retry_attempts:
  10. # 节点加入现有集群超时后,重试前的延迟时间,默认为100毫秒
  11. discovery.zen.join_retry_delay:

选举集群元信息

被选出的 Master 和集群元信息的新旧程度没有关系。因此主节点的第一个任务是选举集群元信息,让各节点把各自存储的集群元信息发过来,然后根据版本号确定集群最新的元信息,再把这个最新的元信息广播下去,这样集群的所有节点都有了最新的元信息。

为了集群一致性,参与选举集群元信息的节点数量需要过半,Master 发布集群状态成功的规则也是等待发布成功的节点数过半。在选举过程中,不接受新节点的加入请求。集群元信息选举完毕后,Master 发布首次集群状态,然后再开始选举 shard 级元信息,即哪个 shard 位于哪个节点上。

选举分片信息

在初始阶段,所有的 shard 都处于 UNASSIGNED(未分配)状态,ES 通过 allocation 模块完成选举 shard 级元信息,重构内容路由表的工作。

1. 选举主分片

我们以 [website][0] 分片为例讲解 Master 节点是如何选举一个分片的主副本的。

在初始阶段,Master 并不知道主分片位于哪个节点,因此它向集群的每个节点广播获取 [website][0] 分片的元信息,然后等待所有请求返回,这样 Master 就获取了 [website][0] 分片的多份信息,具体数量取决于该分片的副本数。不过这种获取分片元信息的方式效率有些低,因为对每一个分片,Master 都会对集群内所有节点进行广播获取分片元信息,所以说我们最好控制 shard 的总规模不要太大。

接下来考虑把哪个分片作为主分片。在 ES 5.x 以下的版本,会通过对比 shard 级元信息的版本号来决定。但是这种方式有缺陷,比如在分片有多个副本的情况下,此时如果只有一个 shard 信息汇报上来,则它一定会被选为主分片,但该副本的版本却不一定是最新的,只是版本号比它大的那个 shard 所在节点还没启动。

Allocation IDs 策略
为此在 ES 5.x 版本开始引入 Allocation IDs 的概念,用于主分片选举策略。每个分片有自己唯一的 Allocation ID,同时集群元信息中有一个列表(in-sync allocation ids)记录了哪些分片拥有最新数据。因为 ES 是先写主分片,再由主分片所在节点转发写请求到副分片,所以主分片所在节点肯定是最新的,如果它转发失败了,则会要求 Master 在维护的 in-sync 列表中删除那个节点。

Allocation ID 由主节点在分片分配时指定,并由数据节点存储在磁盘中,紧邻实际的数据分片。在分配主分片时主节点检查磁盘中存储的 Allocation ID 是否会在集群状态的 in-sync allocations ids 集合中出现,只有在这个集合中找到了,此分片才有可能被选为主分片。如果活跃副本中的主分片挂了,则 in-sync 集合中的活跃分片会被提升为主分片,确保集群的写入可用性不变。

2. 选举副分片

主分片选举完成后,才会选举副分片。根据索引设置是副本数,从上一个过程汇总的 shard 元信息列表中选择副本分片作为副分片。如果汇总的 shard 信息中不存在,则分配一个全新副本的操作依赖于延迟配置项:

  1. index.unassigned.node_left.delayed_timeout:

索引数据恢复

当主分片分配成功后就会进入 recovery 流程,不会等待其副分片是否分配成功,它们是独立的流程。但是副分片的 recovery 流程需要等待主分片恢复完毕才开始。

待恢复的数据是客户端写入成功,但还未执行 flush 操作的 Lucene 分段。例如,当节点异常重启时,写入磁盘的数据先到文件系统缓冲,未必来得及刷盘,如果不通过某种方式将未刷盘的数据找回来,则会丢失一些数据,这是保持数据完整性的体现;另一方面,由于写入操作在多个分片副本上没有来得及全部执行,副分片需要同步成和主分片完全一致,这是数据副本一致性的体现。

根据数据分片性质,索引恢复过程可分为主分片恢复流程和副分片恢复流程。

  • 主分片从 translog 中自我恢复,尚未执行 flush 到磁盘的 Lucene 分段可以从 translog 中重建
  • 副分片要从主分片中拉取 Lucene 分段和 translog 进行恢复,但有机会跳过拉取 Lucene 分段的过程

1. 主分片 recovery

由于每次写操作都会记录事务日志(translog),所以事务日志中记录了完整的操作类型以及相关的数据。并且在 Lucene 索引内部维护了 commit point 信息,其记录了当前 Lucene 索引中哪些分段信息已经被持久化到磁盘上了。

本阶段需要重放事务日志中尚未刷入磁盘的信息,因此,根据最后一次提交的信息做快照,来确定事务日志中哪些数据需要重放。重放完毕后将新生成的 Lucene 数据刷入磁盘,由此完成主分片的 recovery。

2. 副分片 recovery

副分片的恢复是比较复杂的,因为副分片需要恢复成与主分片的数据保持一致,并且在恢复期间可以允许新的索引操作。副分片恢复策略在不同的版本中有过不少调整,在 ES 6.0 中副分片恢复分为两个阶段:

  • 阶段一:在主分片所在节点,获取 translog 保留锁,该锁会保留 translog 不受 flush 的影响。然后调用 Lucene 接口对主分片数据做快照,快照是已经刷到磁盘中的数据。主分片所在节点把快照数据复制到副分片所在节点,并发送一个启动其 engine 的通知,启动成功后副分片就可以正常处理写请求了。


  • 阶段二:主分片所在节点对 translog 做快照,这个快照里包括从阶段一开始,到执行 translog 快照期间的新增索引。然后将这些 translog 快照发送到副分片所在节点进行重放。

为什么需要阶段二?
因为在副分片恢复期间允许新的写操作,从复制 Lucene 分段的那一刻开始,所恢复的副分片数据不包括新增的内容,而这些内容存在于主分片的 translog 中,因此副分片需要从主分片节点拉取 translog 进行重放,以获取新增内容。而这也需要主分片节点的 translog 不能被清理。

如何做到副分片不丢数据?
第二阶段的 translog 快照包括第一阶段所有的新增操作。那如果在第一阶段执行期间,发生了 flush 操作导致清除了事务日志怎么办?

在 ES 2.0 之前,是阻止了刷新操作,以此让 translog 都保留下来。从 ES 2.0 版本以后,为了避免这种做法产生过大的 translog 而引入了 translog.view 的概念,创建 view 可以获取后续的所有操作。从 ES 6.0 版本开始 view 也被移除,引入 TranslogDeletionPolicy 的概念,它将 translog 做一个快照来保持 translog 不被清理。这样实现了在第一阶段允许 flush 操作以避免 translog 文件过大。

如何防止主分片节点的 translog 被清理?
在 ES 1.x 版本,直接通过阻止 flush 操作让 translog 都保留下来,但这样做可能会产生很大的 translog。

在 ES 2.0~5.x 版本,引入了 translog.view 的概念,这允许在索引恢复阶段可以获取一个引用,包括了所有当前未提交的 translog 文件以及所有未来新创建的 translog 文件,直到 view 关闭。

从 ES 6.0 版本开始,translog.view 被移除,引入 TranslogDeletionPolicy 的概念,这个类的实现很简单,它将 translog 做一个快照来保持 translog 不被清理。

如何保证主副分片数据一致?
索引恢复过程的一个难点在于如何维护主副分片的一致性。假设副分片恢复期间主分片一直有写操作,如何实现 数据一致呢?

我们先看看早期的做法,在 ES 2.0 版本之前,副分片恢复要经历三个阶段:

  1. 将主分片的 Lucene 做快照发送到 target,期间不阻塞索引操作,新增数据写到主分片的 translog
  2. 将主分片 translog 做快照发送到 target 重放,期间不阻塞索引操作
  3. 为主分片加写锁,将剩余的 translog 发送到 target 重放。此时数据量很小,写入过程的阻塞时间很短

从理论上来说,只要流程上允许将写操作阻塞一段时间,实现主副一致是比较容易的。但后来 Elasticsearch 为了更高的可用性删除了阻塞写操作的第三步,整个恢复期间没有任何写阻塞过程。

接下来需要处理的就是解决 phase1 和 phase2 之间的写操作与 phase2 重放操作之间的时序和冲突问题。在副分片节点,phase1 结束后,假如新增索引操作和 translog 重放操作并发执行,因为时序的关系会出现新老数据交替,那应该如何实现主副分片一致呢?

假设在第一阶段执行期间,有客户端索引操作要求将 docA 的内容写为 1,主分片执行了这个操作,而副分片由于尚未就绪所以没有执行。第二阶段期间客户端索引操作要求将 docA 的内容写为 2,此时副分片已经就绪,先执行了将 docA 写为 2 的新增请求,然后又收到了从主分片所在节点发送过来的 translog 写 docA 为 1 的请求该如何处理?具体流程如下图所示:
image.png
答案是在写流程中做异常处理,通过版本号来过滤掉过期操作。

写操作有三种类型:索引新文档、更新、删除。索引新文挡不存在冲突问题,更新和删除操作采用相同的处理机制。每个操作都有一个版本号,这个版本号就是预期 doc 版本,它必须大于当前 Lucene 中的 doc 版本号,否则就放弃本次操作。

对于更新操作来说,预期版本号是 Lucene doc 版本号 + 1。主分片节点写成功后新数据的版本号会放到写副本的请求中,这个请求中的版本号就是预期版本号。这样,时序上存在错误的操作被忽略,对于特定 doc 只有最新一次操作生效,保证了主副分片一致。

总结

当一个索引的主分片分配成功后,到此分片的写操作就是允许的。当一个索引所有的主分片都分配成功后该索引变为 Yellow。当全部索引的主分片都分配成功后整个集群变为 Yellow。当一个索引全部分片分配成功后该索引变为 Green。当全部索引的索引分片分配成功后整个集群变为 Green。

索引数据恢复是最漫长的过程。当 shard 总量达到十万级的时候,6.x 之前的版本集群从 Red 变为 Green 的时间可能需要小时级。ES 6.x 中的副本允许从本地 translog 恢复是一次重大的改进,避免了从主分片所在节点拉取全盘数据,为恢复过程节约了大量时间。