ES基本概念和原理

分片

在分布式系统中,单机无法存储规模巨大的数据,要依靠大规模集群处理和存储这些数据,一般通过增加机器数量来提高系统水平扩展能力。因此,需要将数据分成若干小块分配到各个机器上。然后通过某种路由策略找到某个数据块所在的位置。

ES 将数据副本分为主从两部分,即主分片 primary shard 和副分片 replica shard。主数据作为权威数据,写过程中先写主分片,成功后再写副分片,恢复阶段以主分片为准。

分片(shard 是底层的基本读写单元,分片的目的是分割巨大索引,让读写可以并行操作,由多台机器共同完成。读写请求最终落到某个分片上,分片可以独立执行读写工作。ES 利用分片将数据分发到集群内各处。分片是数据的容器,文档保存在分片内,不会跨分片存储。分片又被分配到集群内的各个节点里。当集群规模扩大或缩小时,ES 会自动在各节点中迁移分片使数据仍然均匀分布在集群里。

ES 索引包含很多分片,每个分片是一个 Lucene 的索引,它本身就是一个完整的搜索引擎,可以独立执行建立索引和搜索任务。Lucene 索引又由很多分段组成,每个分段都是一个倒排索引。ES 每次 fresh 都会生成 个新的分段,其中包含若干文档的数据。在每个分段内部,文档的不同字段被单独建立索引。每个字段的值由若干词 Term 组成,Term 是原文本容经过分词器处理和语言处理后的最终结果(例如,去除标点符号和转换为词根)。

索引建立的时候就需要确定好主分片数,在较老的版本中( 5.x 本之前),主分片数量不可以修改,副分片数量可以随时修改。当前版本ES已经支持一定条件的限制性下,对某个索引的主分片进行拆分(split)或缩小(Shirnk),但是我们仍然需要在一开始就尽量规划好主分片数量:先依据硬件情况定好单个分片容量,然后依据业务场景预估数量和增长量,再除以单个分片容量。

实际应用中,我们不应该向单个索引持续写数据,直到它的分片巨大无比。巨大的索引会在数据老化之后难以删除,以_id为单位删除文档不会立刻释放空间,删除的doc只会在Lucene分段合并时才会真正的删除。即使手动的触发分段合并,仍然会导致较高的IO压力,并且可能因为分段巨大导致在合并过程中磁盘空间不足。

动态更新索引

为文档建立索引,会使用倒排索引数据结构,倒排索引一旦被写入文件后就具有不变性,不变形具有很多好处:对文件的访问不需要加锁,读取索引文件时可以被文件系统缓存等。

那么索引如何更新:新增的内容并写到一个新的倒排索引中,查询时每个倒排索引轮流查询,查询完在对结果进行合并。每次内存缓冲的数据被写入文件时,会产生一个新的Lucene分段,每个段都是一个倒排索引,在一个记录元信息的文件中描述了当前Lucene索引都包含哪些分段。

由于分段的不变性,更新删除等操作实际上是将数据标记为删除,记录到单独的位置,这种方式称为标记删除。因此删除部分数据不会释放磁盘空间。

近实时搜索

在写操作中,一般会先在内存中缓冲一段数据,再将这些数据写入硬盘,每次写入硬盘的这批数据称为一个分段,如同任何写操作,一般情况下( direct 方式除外〉,通过操作系统write接口写到磁盘的数据先到达系统缓存(内存), write 函数返回成功时,数据未必被刷到磁通过手工调用 flush ,或者操作系统通过一定策略将系统缓存刷到磁盘。这种策略大幅提升了写入效率 write 函数返回成功开始,无论数据有没有被刷到磁盘,该数据已经对读取可见。

ES 正是利用这种特性实现了近实时搜索 每秒产生 个新分段,新段先写入文件系统缓存,但稍后再执行 flush 刷盘操作,写操作很快会执行完,一旦写成功,就可以像其他文件一样被打开和读取了。

由于系统先缓冲一段数据才写,且新段不会立即刷入磁盘,这两个过程中如果出现某些意外情况(如主机断电),则会存在丢失数据的风险 通用的做法是记录事务日志 每次对行操作时均记录事务日志,当 ES 启动的时候,重放 translog 中所有在最后一次提交后发生的变更操作。比如 HBase 都有自己的事务日志。

当一个文档写入Lucene后是不能被立即查询到的,Elasticsearch提供了一个refresh操作,会定时地调用lucene的reopen(新版本为openIfChanged)为内存中新写入的数据生成一个新的segment,此时被处理的文档均可以被检索到。refresh操作的时间间隔由refresh_interval参数控制,默认为1s, 当然还可以在写入请求中带上refresh表示写入后立即refresh,另外还可以调用refresh API显式refresh。

段合并

ES 中,每秒清空一次写缓冲,将这些数据写入文件,这个过程称为 refresh 每次 refresh 会创建一个新的 Lucene 段。但是分段数太多会带来较大的麻烦,每个段都会消耗文件句柄、内存。每个搜索请求都需要轮流检查每个段,查询完再对结果进行合并;所以段越多,搜索也就越慢。因此需要通过一定的策略将这些较小的段合并为大的段,常用的方案是选择大小相似的分段进行合并。在合并过程中,标记为删除的数据不会写入新分段,当合并过程结束,旧的分段数据被删除,标记删除的数据才从磁盘删除。

用户还可以手动调用_forcemerge API来主动触发merge,以减少集群的segment个数和清理已删除或更新的文档。

如果段文件设置一定上限不再合井,对表中部分数据无法实现真正的物理删除。

数据存储可靠性

引入translog

当一个文档写入Lucence后是存储在内存中的,即使执行了refresh操作仍然是在文件系统缓存中,如果此时服务器宕机,那么这部分数据将会丢失。为此ES增加了translog,当进行文档写操作时会先将文档写入Lucene,然后写入一份到translog,写入translog是落盘的(如果对可靠性要求不是很高,也可以设置异步落盘,可以提高性能,由配置index.translog.durability和index.translog.sync_interval控制),这样就可以防止服务器宕机后数据的丢失。由于translog是追加写入,因此性能比较好。与传统的分布式系统不同,这里是先写入Lucene再写入translog,原因是写入Lucene可能会失败,为了减少写入失败回滚的复杂度,因此先写入Lucene。

translog的落盘时机可以配置,index.translog.durability配置项,可选参数有request和async。当配置为request时,每次请求之后都同步提交,当出现硬件故障时,所有有响应的操作都肯定已经同步到了磁盘上。当设置成async时,每经过index.translog.sync_interval时长间隔,才会在后台做一次同步和提交操作。当出现硬件故障时,从最后一次提交之后的所有写入操作都会被丢弃。

flush操作

另外每30分钟或当translog达到一定大小(由index.translog.flush_threshold_size控制,默认512mb), ES会触发一次flush操作,此时ES会先执行refresh操作将buffer中的数据生成segment,然后调用lucene的commit方法将所有内存中的segment fsync到磁盘。此时lucene中的数据就完成了持久化,会清空translog中的数据(6.x版本为了实现sequenceIDs,不删除translog)。

索引文档操作

注意:一般我们在修改数据的时候,还是尽量使用PUT式的全量替换,而不是使用POST式的部分替换,虽然POST修改只修改我们需要修改的字段,能够节省网络开销,但是其实我们内部的修改实现是和PUT是一样的,都是先删除在插入,而且POST式的修改,ES先把要修改的所有文档的内容找出来,然后将我们POST过来的数据中的部分字段内容替换,然后将原文档删除,插入此新的文档。所以我们发现虽然能节省网络开销,但是多了重新构建数据这一步,对于数据量很大的情况来说,还是会有损失效率,所以尽量使用PUT来修改。注意:如果我们是用的POST这种方式修改数据,我们内部也是有并发控制的,同样使用CAS,如果我们在拼装好新的数据后,发现_version已经不一样了,那么它内部会重新拉去doucment数据,然后重新拼装,然后再次CAS,一共可以重试五次,如果五次都失败了,则会抛弃此数据。

评分机制

评分是指对给定查询计算某个文档的分值属性的过程,文档得分是一个描述文档与查询匹配程度的参数。Lucene提供了很多算法用于评分计算,但从最早版本的Lucene发布开始,TF-IDF(词频/逆文档频率)就一直是默认评分算法,在Lucene 6.0 版本以后,默认的评分算法已经换成了BM25。

精确率:获取到的相关文档数站获取到的总文档数(包括相关与不想管的)的比例,用百分数表示。

召回率:获取到的相关记录数占数据库中相关的记录总数的比例,用百分数表示。

TF-IDF

IF-IDF是Lucene评级功能的核心,融合了向量空间模型和信息获取的布尔模型。主要理念是:与一个查询词项在整个集合中出现的次数越多,这个词项在一个文档中出现的次数越多,那这个文档就和查询越相关。Lucene也会利用查询规范的布尔逻辑,先用布尔模型来缩小要打分的文档范围。用TF-IDF来为文档打分,还要考虑几个因子,包括:

  • 词频:一个基于词项的因子,用来表示一个词项在某文档中出现了多少次。计算方法是用该此项在文档中出现的次数,除以文档的词项总数。词频越高,文档得分越高。

  • 逆文档频率:一个基于词项的因子,用来告诉评分公式该词项有多罕见。逆文档频率越高,该词项越罕见。评分公式利用该因子来为包含罕见词项的文档加权。它的计算方法是log_e(包含词项t的文档数除以文档总数)。一个词语普遍重要性的度量。某一特定词语的IDF,可以由总文件数目除以包含该词语的文件的数目,再将得到的商取对数得到。注意IDF的影响,在我们的ES中,如果有多个主分片,如果文档较少,可能会出现各个分片文档分布不均匀,当前包含此搜索关键字的文档占此分片的总文档比例越少,此节点计算出的IDF得分越高,这就会导致同样的标题或文章内容,同样的搜索关键字,搜索出来的这两个文章的得分相差很大。(此问题会在文档较少分布不均匀的情况下出现)

  • 协调因子:基于文档中词项个数的协调因子,一个文档内命中了查询中的词项越多,得分越高。

  • 字段权重:查询期赋予某个字段的权重值。

  • 文档权重:索引期赋予某个字段的权重值。

  • 长度范数:每个字段基于词项个数的归一化因子(在索引期被计算并存储在索引中),一个字段包含的词项数越多,该因子的权重越低,这意味着,Lunece评分公式更喜欢包含更少词项的字段。

  • 查询范数:一个基于查询的归一化因子,等于查询中词项的权重平方和,查询范数是不同查询的得分能互相比较,尽管这种比较通常是困难和不可行的。

BM25

BM25也是一种根据相关性来为文档进行打分和评级,它与TF-IDF有许多的共同点,两种算法都用到了词频、逆文档频率和字段长度范化。两种模型都根据某些TD和IDF函数为每个词项算出权重,并把所有的词项的权重值相加,作为这次查询的得分。

BM25与TF-IDF有什么不同

饱和点

在TF-IDF中由饱和度引起的评分问题:如果你的布尔查询中的N个词项里,某一个词项在某份文档中出现了许多次,那这份文档的分值就会极高,因为它的词项饱和度很弱。如果查询条件是 x 和 y,儿某份文档中有1000个x,0个y,TF-IDF不会考虑y从未出现过,仍然会给它极高的分数。

BM25在这种场景下就表现的好很多,因为它提供了饱和度参数,可以对词项饱和度提供更有力的控制。即使查询条件中的某个词项出现了许多次,它所增加的分数也远比不上另一个词项出现的次数从0变到1。BM25天然喜欢哪些尽量多的查询词项都出现过的文档。

平均文档长度

TF-IDF与BM25的另一个显著区别就是BM25也考虑了文档长度的影响。比如:某篇包含了1000个词项的文章中,假如 旅行 这个词只出现过一两次,那它的内容与 旅行 就应该没有太大关系,但如果 旅行 这个词在一篇很短的文章中就出现了两次,那这个文章就与 旅行 肯定有很大关系,TF-IDF在计算与文档长度相关的分数时,处理的很片面,篇幅较长的文档字数自然多,因此词频也会比较高,与词项不太相关,与查询条件也不太相关了,BM25针对这种情况引入了文档长度进行补偿,有些文档的内容涉及范围比较广,因此字数多也合理,从数学公式中可以看到,BM25引入了长度参数、文档长度、平均文档长度等来调节词项因子。

缓存

缓存允许我们在内存中保存之前使用过的数据,并根据需要适时重用它们。当然,我们不可能缓存所有的数据,因为数据量总是大于内存容量,另外构建缓存的代价也非常高昂。

节点查询缓存

查询缓存用于缓存查询的结果。每个节点上都有一个查询缓存,供节点上的所有分片公用。查询缓存使用的淘汰策略是LRU(最近最少使用):当缓存满时,最近最少被使用的数据将被淘汰,为新数据腾出空间。

查询缓存有两个参数可以配置,一个是配置所使用的内存大小,默认为10%,或者一个具体值,如512MB。一个是是否启用查询缓存开关,这个参数是针对于索引级的。

分片查询缓存

当ES针对一个或多个索引执行查询时,协调节点在接收到查询请求后,会将请求转发给所有相关的数据节点,然后每个节点上的相关分片都会在本地执行查询,并将本地结果返回给协调节点,再由协调节点将这些分片级的结果合并成一个完整的结果集,分片请求缓存负责将每个分片上的结果缓存起来,由此可以快速的响应查询次数最多(通常也是代价最大的)请求。

通过这个缓存,经常使用的汇聚结果(比如网站主页上的内容)就可以被缓存起来,让响应更快,无论是否缓存,得到的汇聚结果都是相同的,不会得到过期数据。如果只有最新的索引上的数据,会经常被更新,那就非常适合使用这个缓存了,旧索引上的结果在缓存中就可以直接得到。

当分片刷新时,或者分片中的数据被更新时,缓存的结果就会自动失效。换句话说,从缓存中得到的结果与不使用缓存得到的结果是相同的。刷新间隔越长,缓存内容的有效期就越长。如果缓存满了,最近最少使用的缓存键将被淘汰。

字段数据缓存

字段数据缓存的使用时机是当查询涉及非倒排数据操作时。ES所做的是将相关字段的全部数据加载到内存中,这就是字段数据缓存,这种缓存可以被ES用于聚合和脚本计算,以及基于字段值的排序等场景。当第一次执行非倒排数相关操作时,ES会把所有相关字段的数据加载入内存,默认情况下这些给定字段的数据不会被移除。因此,可以快速用基于文档的方法访问索引文档中给定字段的值。需要注意的是,从硬件资源的角度来看,构建字段数据缓存代价通常很高,因为相关字段的所有数据都要加载到内存中,这需要消耗I/O操作和CPU资源。

可以在集群没饿节点上通过indices.fielddata.cache.size参数控制字段数据缓存。这个参数值是字段数据缓存的最大值,比如节点堆空间的30%,或者如12GB之类的绝对值。默认不设限制。

字段数据还是doc values:在ES中,doc values是字段数据的另一个选择,现在对于每个not_analyzed都是默认启用的。在索引期会进行计算,并按列格式存储在磁盘上。doc values的速度与字段数据缓存不相上下,而且需要的内存还更少。因此从ES 5.x 版本开始,就不要使用字段数据了,直接使用doc values。

doc values就是一个列式存储,存储的是不需要分词的字段原文数据(正排索引),用户聚合和脚本中等使用。

集群内部管理

分布式系统的集群方式大致可以分为主从(Master-Slave)模式和无主模式。ES使用主从模式,主从模式可以简化系统设计, Master 作为权权威节点,部分操作仅由 Master 执行,并负责维护集群元信息。缺点是 Maste 节点存在单点故障,需要解决灾备问题,井且集群规模会受限于 Master 节点的管理能力。

集群节点角色

主节点(Master node)

主节点负责集群层面的相关操作,管理集群变更。通过配置 node.maste: true (默认)使节点具有被选举为 Master 资格。主节点是全局唯一的,将从有资格成为 Master 的节点中进行选举。

主节点也可以作为数据节点,但尽可能做少量的工作。

数据节点(Data node)

负责保存数据、执行数据相关操作: CRUD 、搜索、聚合等。数据节点对 CPU 、内存、 I/O要求较高。一般情况下,,数据读写流程只和数据节点交互,不和主节点打交道。

预处理节点(Ingest node)

这是从 5.0 版本开始引入的概念。预处理操作允许在索引文档之前,即写入数据之前,通过事先定义好的一系列的 processors (处理器) pipeline (管道〉 ,对数据进行某种转换、富华,processors pipeline 拦截 bulk {日 inde 请求 在应用 关操作后将文档传回 index Bulk API。默认情况下, 在所有的节点上启用 ingest ,如果想在某个节点上禁用 ingest ,则可以添加配
node ingest false 。

协调节点(Coordinating node)

客户端请求可以发送到集群的任何节点,每个节点都知道任意文档所处的位置,然后转发这些请求,收集数据井返回给客户端,处理客户端请求的节点称为协调节点。协调节点将请求转发给保存数据的数据节点。每个数据节点在本地执行请求,并将结果返回协调节点。协调节点收集完数据后,将每个数据节点的结果合井为单个全局结果。对结果收集和排序的过程可能需要很多 PU 和内存资源。

部落节点(Tribe node)

tribes (部落)功能允许部落节点在多个集群之间充当联合客户端。它不做主节点,也不做数据节点,仅用于路由请求,本质上是 个智能负载均衡器(从负载均衡器的定义来说,智能和非智能的区别在于是否知道访问的内容存在于哪个节点〉,从 5.0版本开始,这个角色被协调节点取代。

集群健康状态

从数据完整性的角度划分,集群健康状态分为:

  • Green,所有的主分片和副分片都正常运行。

  • Yellow,所有的主分片都正常运行,但不是所有的副分片都正常运行 存在单点故障风险。

  • Red,有主分片没能正常运行。

每个索引也有上述 种状态,假设丢失了一个副分片,该分片所属的索引和整个集群变为Yellow 状态,其他索引仍为 Green。

集群状态

集群状态元数据是全局信息,元数据包括内容路由信息、配置信息等,其中最重要的是内容路由信息,它描述了“哪个分片位于哪个节点”这种信息。

集群状态由主节点负责维护,如果主节点从数据节点接收更新,则将这些更新广播到集群的其他节点,让每个节点上的集群状态保持最新 ES 2.0 版本之后,更新的集群状态信息只发增量内容,并且是被压缩的。

集群扩容

当扩容集群、添加节点时,分片会均衡地分配到集群的各个节点,从而对索引和搜索过程进行负载均衡,这些都是系统自动完成的。当扩容集群、添加节点时,分片会均衡地分配到集群的各个节点,从而对索引和搜索过程进行负载均衡,这些都是系统自动完成的。

分配过程中除了 节点间均匀存储,还要保证不把主分片和副分片配到同避免单个节点故障引起数据丢失。分布式系统难免出现故障,当节点异常时,ES 会自动处理节点异常。当主节点异常时,集群会重新选举主节点。当某个主分片异常时, 会将副分片提升为主分片。

主要内部模块

Cluster

Cluster 模块是主节点执行集群管理的封装实现,管理集群状态,维护集群层面的配置信息要功能如下:

  • 管理集群状态,将新生成的集群状态发布到集群所有节点。

  • 调用 llocation 模块执行分片分配,决策哪些分片应该分配到哪个节点。

  • 在集群各节点中直接迁移分片,保持数据平衡。

allocation

封装了分片分配相关的功能和策略,包括主分片的分配和副分片的分配,本模块由主节点调用。创建新索引、集群完全重启都需要分片分配的过程。

Discovery

发现模块负责发现集群中的节点 ,以及选举主节点。当节点加入或退出集群时,主节点会采取相应的行动。从某种角度来说,发现模块起到类似 ZooKeep町的作用,选主并管理集群拓扑。

gateway

负责对收到 Master 广播下来的集群状态( cluster state )数据的持久化存储,并在集群完全重启时恢复它们。

Indices

索引模块管理全局级的索引设置,不包括索引级的(索引设置分为全局级和每个索引级)。它还封装了索引数据恢复功能 集群启动阶段需要的主分片恢复和副分片恢复就是在这个模块实现的。

HTTP

HTTP 模块允许通过 JSON over HTTP 方式访 ES API,HTTP 模块本质上是完全异步的,这意味着没有阻塞线程等待响应,使用异步通信进行 HTTP 的好处是解决了 C10k 问题, (10k 量级的并发连接)。

在部分场景下,可考虑使用 HTTP keepalive 提升性能。注意不要在客户端使用 HTTP chunking。

Transport

传输模块用于 群内节点之间的内部通信 节点到另 个节点的每个请求都使用传输模块。如同 HTTP ,传输模块本质上也是完全异步的。传输模块使用 TCP 通信,每个节点都与其他节点维持若干 TCP 长连接,内部节点间的所有通信都是本模块承载的。

Engine

Engine 模块封装了对 Lucene 的操作及 translog 调用,它是对一个分片读写操作的最终提供者。ES 使用 Guice 框架进行模块化管理。 Guice 是Google研发的轻量级依赖注入框架 IoC。

模块管理

定义好的模块由 Module Builder 类统一管理 ModulesBuilder 是 ES 对 Guice 的封装,内部调用 Guice 接口,主要对外提供两个方法。

  • add 方法:添加创建好的模块。

  • createlnjector 方法 调用 Guice.createlnjector 创建并返回 Injector ,后续通过 Injector 取相应 Service 类的实例。

集群启动流程

集群启动的整体流程如下图所示:

avatar

选举主节点

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

ES 的选主算法是基于 Bully 算法的改进,主要思路是对节点 ID 排序,取 ID 值最大的节点作为 Master,每个节点都运行这个流程,选主的目的是确定唯一的主节点,初学者可能认为选举出的主节点应该持有最新的元数据信息,实际上这个问题在实现上被分解为两步:先确定唯一的、 大家公认的主节点,再想办法把最新的机器元数据复制到选举出节点上。

基于节点 ID 排序的简单选举算法有三个附加约定条件:

  1. 选人数需要过半,达到 quorum (多数)后就选出了临时的主。

  2. 得票数需过半。某节点被选为主节点,必须判断加入它的节点数过半,才确认 Master身份。解决第一个问题。

  3. 当探测到节点离开事件时,必须判断当前节点数是否过半。如果达不 quorum ,则放弃 Master 身份, 重新加入集群。防止集群脑裂。

选举集群元信息

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

集群元信息的选举包括两个级别:集群级和索引级,不包含哪个 shard 存于哪个节点这种信息 。这种信息以节点磁盘存储的为准, 需要上报。因为读写流程是不经过Master的, Master不知道各 shard 副本直接的数据差异。 HDFS 也有类似的机制, block 信息依赖于DataNode 的上报。为了集群一致性,参与选举的元信息数量需要过半, Master 发布集群状态成功的规则也是等待发布成功的节点数过半。集群元信息选举完毕后, Master 发布首次集群状态,然后开始选举 shard 级元信息。

allocation 过程

选举 shard 级元信息,构建内容路由表,是在 allocation 模块完成的。在初始阶段,所有的 shard 都处于 UNASSIGNED (未分配)状态。 ES中通过分配过程决定哪个分片位于哪个节点重构内容路由表。此时,首先要做的是分配主分片。

选主分片

所有的分配工作都是 Master 来做的,此时,Master 不知道主分片在哪,它向集群的所有节点询问:大家把[website] [OJ 分片的元信息发给我。然后, Master 等待所有的请求返回,正常情况下它就有了这个 shard 的信息,然后根据某种策略选一个分片作为主分片。是不是效率有些低?这种询问量=shard 数×节点数。 所以说我们好控制 rd 的总规模别太大。

ES 5.x 开始实施一种新的策略:给 shard 都设置一个 UUID ,然后在集群级的元信息中记录哪个shard 是最新的,因为 ES 是先写主分片,再由主分片节点转发请求去写副分片 ,所以主分片所在节点肯定是最新的,如果它转发失败了,则要求 Master 删除那个节点。 所以,从 ES 5.x 开始,主分片选举过程是通过集群级元信息中记录的“最新主分片的列表”来确定主分片的:汇报信息中存在,并且这个列表中也存在。

选副分片

主分片选举完成后,从上一个汇总过程的 shard 信息中选举一个副本作为副分片。如果汇总信息不存在,则分配一个全新的副本。

index recovery(索引恢复)

分片分配完成后进入recovery流程,主分片的恢复不会等待其副本分片分配成功才开始恢复。它们是独立的流程,只是副分片的回复需要主分片恢复完毕才开始。为什么需要恢复,对于主分片,可能有一些数据没来得及刷盘;对于副分片,一是没有刷盘,二是主分片写完了,副分片还没有来得及写,主副分片数据不一致。

  • 主分片恢复:由于每次写操作都会记录事务日志( translog ), 事务日志中记录了哪种操作,以及相关的数据。因此将最后一次提交( Lucene 的一次提交就是一次 fsync 盘的过程)之后的 translog 中进行重放,建立 Lucene 索引,如此完成主分片的 recovery。

  • 副分片恢复:副分片需要恢复成与主分片一致,同时,恢复期间允许新的索引操作。在目前的 6.x 版本中,恢复分成两阶段执行:

  1. phase1:在主分片所在节点 获取 translog 保留锁,从获取保留锁开始,会保留 translog 不受其刷盘清空的影响 。然后调 Lucene 接口把 shard 做快照,这是已经刷磁盘中的分片数据。把这些 shard 数据复制到副本节点。在 phase1 完毕前,会向副分片节点发送告知对方启动 engine ,在 phase2 开始之前,副分片就可以正常处理写请求了。

  2. phase2:对 translog 做快照,这个快照里包含从 phasel 开始,到执行 translog 快照期间的新增索引 。将这些 translog 发送到副分片所在节点进行重放。

分片数据完整性:如何做到副分片不丢数据?第二阶段的 translog 快照包括第一阶段所有的新增操作,那么第一阶段执行期间如果发生 Lucene commit (将文件系统写入缓冲中的数据刷盘,并清空 translog ),清除的translog怎么办?。从 6.0 版本开始 translog.view 被移除,引入了TranslogDeletionPolicy 的概念,它将 translog 做一个快照来保持 translog 不被清理,这样实现了在第一阶段允许 Lucene commit。

数据一致性:恢复期间没有任何写阻塞过程,在副分片节点,重放 translog 时, phase1 和 phase2 间的写操作与phase2重放操做间的时序错误和冲突,通过写流程中进行异常处理,对比版本号来过滤掉过期操作。这样,时序上存在错误的操作被忽略,对于特定的 doc ,只有最新一次操作生效,保证了主副分片一致。

第一阶段尤其漫长,因为它需要从主分片拉取全量的数据,6.x 中,对第一阶段再次优化:标记每个操作。在正常的写操作中,每次写入成功的操作都分配一个序号,通过对比序号就可以计算出差异范围。在实现方式上,添加了 global checkpoint local checkpoint,主分片负责维护 global checkpoint ,代表所有分片都己写入这个序号的位置, local checkpoint 表当前分片己写入成功的最新位置,恢复时通过对比两个序列号,计算出缺失的数据范围,然后通过translog 重放这部分数据,同时 translog 会为此保留更长的时间。

因此,有两个机会可 以跳过副分片恢复的 phase1:基于 SequenceNumber ,从主分片节点的 translog 恢复数据;主副两分片有相同的 syncid 且 doc 数相同,可以跳过 phase1。

选主流程

Discovery 模块负责发现集群中的节点,以及选择主节点。ES 支持多种不同 Discovery 类型选择,称为 Zen Discove ,其他的包括公有云平台亚马逊的 EC2 、谷歌的 GCE 等。本章讨论内置的 Zen Discovery 实现。 Zen Discovery 封装了节点发现( Ping )、选主等实现过程。

为什么使用主从模式

除主从( Leader/Fo llower )模式外,另一种选择是分布式哈希表( DHT ),可以支持每小时数千个节点的离开和加入,其可以在不了解底层网络拓扑的异构网络中工作, 查询响应时间大约为 10 跳(中转次数〉,例如, Cassandra 就使用这种方案 但是在相对稳定的对等网络中,主从模式会更好。ES 典型场景中的另一个简化是集群中没有那么多节点 通常,节点的数 远远 于单个节点能够维护的连接数,并且网络环境不必经常处理节点 的加入和离开 这就是为什么主从模式更适合 ES。

选举算法

Bully 算法

Leader 选举的基本算法之 。它假定所有节点都有一个唯一ID ,使用该 ID 对节点进行排序。任何时候的当前 Leader 都是参与集群的最高 ID 节点。该算法的优点是易于实现。但是,当拥有最大 ID 的节点处于不稳定状态的场景下会有问题 例如,Master 负载过重而假死,集群拥有第 ID 的节点被选为新主,这时原来的 Master 恢复,再次被选为新主,然后又假死……。

ES 通过推迟选举,直到当前的 Master 失效来解决上述问题,只要当前主节点不挂掉,就不重新选主。但是容易产生脑裂(双主),为此,再通过“法定得票人数过半”解决脑裂问题。

Paxos 算法

Paxos 非常强大,尤其在什么时机,以及如何进行选举方面 灵活性比简单的 Bully 算法有很大的优势,因为在现实生活中,存在比网络连接异常更多的故障模式。但 Paxos 实现起来非常复杂。

流程概述

ZenDiscovery 的选主过程如下

  • 每个节点计算最小的己知节点 ID ,该节点为临时 Master 。向该节点发送领导投票。

  • 如果一个节点收到足够多的票数,并且该节点也为自己投票,那么它将扮演领导者角色,开始发布集群状态。

所有节点都会参与选举,并参与投票,但是,只有有资格成为 Master 的节点( node.mastertrue )的投票才有效。

获得多少选票可以赢得选举胜利,就是所谓法定人数中, 法定大小是一个可配参数。配置项 discovery.zen_minimum_master_nodes 。为了避免脑裂最小值应该是有 Master资格的节点数 n/2+ 1。

流程分析

整体流程可以概括为:选举临时 Master ,如果本节点当选,则等待确立 Master ,如果其节点当选,则尝试加入集群,然后启动节点失效探测器。

  1. 选举临时 Master

选举过程的实现位于 ZenDiscovey#findMaster 该函数查找当前集群的活跃 Maste 或者从候选者中选择新的 Master 如果选主成功,则返回选定的 Maste 否则返回空。为什么是临时 Master ?因为还需要等待下个步骤,该节点的得票数足够时,才确立为真
正的 Master。这里面会筛选不具有Master资格的节点,里面会有投票机制。

  1. 投票与得票的实现

在ES中,发送投票就是发送加入集群 (JoinRequest)请求。得票就是申请加入求的数量。

  1. 确立 Master 或加入集群

选举出 的临时 Master 有两种情况 临时 Master 是本节点或非本节点。为此单独处理。现在准备向其发送投票。

如果临时 Mast 是本节点:

  1. 等待足够多的具备 Master 资格的节点入本节点(投票达到法定人数),以完成选举。

  2. 超时(默认为 30 可配置)后还没有满足数量 join 请求,则选举失败,需要进行新一轮边举。

  3. 成功后发布新的clusterState。

如果其他节点被选为 Master:

  1. 不再接受其他节点的 join 请求。

  2. 向 Master 发送加入请求,并等待回复。超时时间默认为1分钟(可配置),如果遇到异常,则默认重试3次(可配置)。

  3. 最终当选的 Master 会先发布集群状态,才确认客户 join 请求,因此, joinElectedMaster返回代表收到了 join 请求的确认,并且已经收到了集群状态。本步骤检查收到的集群状态中的Master节点如果为空,或者当选的 Master 是之前选择的节点,则重新选举。

节点失效检测

到此为止,选主流程己执行完毕,Master 身份己确认,非 Master 节点己加入集群。节点失效检测监控节点是否离线,然后处理其中的异常。失效检测是选主流程之后不可或缺的步骤,不执行失效检测可能会产生脑裂(双主或多主〉。在此我们需要启动两种失效探测器:

  • Master 节点,启动 NodesFaultDetection,简称 NodesFD 。定期探测加入集群的节点是否活跃。

  • 在非 Master 点启动 MasterFaultDetection,简称 MasterFD 定期探测 Master 节点是否活跃。

NodesFaultDetection 和 MasterFaultDetection 是通过定期(默认为1秒)发送的 ping 请求探测节点是否正常的,当失败达到一定次数(默认为3次),或者收到来自底层连接模块的节点离线通知时,开始处理节点离开事件。

NodesFaultDetection:检查当前集群总节点数是否达到法定节点数(过半),如果不足,则会放弃 Master 身份,重新加入集群。主节点在探测到节点离线的事件处理中,如果发现当前集群节点数量不足法定人数,则放 Master 身份,从而避免产生双主。

MasterFaultDetection:探测 Master 离线的处理很简单,重新加入集群。本质上就是该节点重新执行一遍选主的流程。

数据模型

PacificA 算法

ES 的数据副本模型基于主从模式(或称主备模式, HDFS Cassandra 为对等模式〕,在实现过程中参考了微软的 PacificA 算法(借鉴了其中部分思想,井非完全按照这个模型实现)。我们先看一下 PacificA 算法的几个特点:

  • 设计了一个通用的、抽象的框架,而不是具体的、特定的算法。模型的正确性容易验证。

  • 配置管理和数据副本分离,Paxos 负责管理配置,数据副本策略采取主从模式。

  • 将错误检测和配置更新放在数据副本的交互里实现,去中心化。

该算法涉及的几个术语如下:

  • Replica Group:互为副本的数据集合称为副本组。其中只有一个副本是主数据Primary, 其他为从数据(Secondary)。

  • Configuration:配置信息 中描述了一个副本组都有哪些副本,Primary 是谁,以及它们位于哪个节点。

  • Configuration Version:配置信息的版本,每次发生变更时递增。

  • Serial Number:代表每个写操作 的顺序, 每次写操作 递增 ,简称 SN 。每个主副本维护自己的递增 SN。

  • Prepared List:写操作的准备序列。存储来自外部请求的列表,将请求按照 SN 排序,向列表中插入的序列号必须大于列表中中最大的 SN 。每个副本上有自己的 Prepared List。

  • Committed List:写操作的提交序列。

数据副本策略

数据写入的流程如下:

  1. 写请求进入主副本节点,节点为该操作分配 SN ,使用该 SN 创建 UpdateRequest 结构。然后将该 UpdateRequest 插入 自己的 prepare list。

  2. 主副本节点将携带 UpdateRequest 发往从副本节点,从节点收到后同样插入prepare list,完成后给主副本节点回复 ACK。

  3. 一旦主副本节点收到所有从副本节点的响应,确定该数据已经被正确写入所有的从副本节点,此时认为可以提交了,将此 UpdateRequest 放入 committed list, committed list 向前移动。

  4. 主副本节点回复客户端更新完成。对每一个 Prepare 消息,主副本节点向从副本节点发送一个 commit 通知,告诉它们自己的 committed point 位置,从副本节点收到通知后根据指示移动 committed point 到相同的位置。

因为主副本只有在所有从副本将请求添加 prepared list 之后才可以通过移动 committed point 方式将该请求插入 committed list 中,因此主副本的 commtted list 是任何一个从副本的prepared list 的前缀(或者称为子集)。例如,从副本 prpared list SN 为 1、2、3、4,主副本committed point SN 定不会大于 4,例如 1、2、3。
同时,因为一个从副本只有在主副本将一个请求添加进 committed list 后才会把同样的请求添加进 committed list 中,因此一个从副本上的 committe list 是主副本上 committed list 的前缀,此不变式称为 Commit Invariant。

配置管理

全局的配置管理器负责管理所有副本组的配置。节点可以向管理器提出添加/移除副本的请求,每次请求都需要附带当前配置版本号,只有这个版本号和管理器记录的版本号一致才会被执行,如果请求成功,则这个新配置会被赋予新的版本号。

错误检测

分布式系统经常存在网络分区、节点离线等异常。全局的配置管理器维护权威配置信息,但其他各节点上的配置信息不一定同步,我们必须处理旧的主副本和新的主副本同时存在的情况 一一 旧的主副本可能没有意识到重新分配了已个新的主副本,从而违反了强一致虚性。 PacificA使用了租约 Clease 机制来解决这个问题。

主副本定期向其他从副本获取租约。这个过程中可能产生两种情况:

  • 如果主副本节点在一定时间内( lease period )未收到从副本节点的租约回复,则主副本节点认为从副本节点异常,向配置管理器汇报,将该异常从副本从副本组中移除,同时,它也将自己降级,不再作为主副本节点。

  • 如果从副本节点在一定时间内( grace period )未收到主副本节点的租约请求,则认为主副本异常,向配置管理器汇报,将主副本从副本组中移除,同时将自己提升为新的主。如果存在多个从副本,则哪个从副本先执行成功,哪个从副本就被提升为新主。

PacificA 算法的这些概念对应在 ES 中:

  • Master 负责维护索引元信息,类似配置管理器维护配置信息。

  • 集群状态中的 routing table 存储了所有索引、索引有哪些 shard、各自的主分片,以及位于哪个节点等信息,类似副本组。

  • SequenceNumber和Checkpoint 类似 PacificA 算法中的 Serial Number 和 Committed Point。

ES 的数据副本模型

ES 中的每个索引都会被拆分为多个分片,并且每个分片都有多个副本。这些副本称为replication group (副本组,与 PacificA 中的副本组概念一致),并且在删除或添加文档的时候,各个副本必须同步。否则,从不同副本中读取的数据会不一致。我们把保持分片副本之间的同步,以及从中读取的过程称为数据副本模型(data replication model)。

ES 的数据副本模型基于主备模式( primary backup model 主分片是所有索引操作的入口,它负责验证索引操作是否有效,一旦主分片接受一个索引操作,主分片的副分片也会接受该操作。

基本写入模型

每个索引操作首先会使用 routing 参数解析到副本组,通常基于文档 ID 。一旦确定副本组,就会内部转发该操作到分片组的主分片中,主分片负责验证操作和转发它到其它副分片。ES维护一个可以接收该操作的分片的副本列表。这个列表叫作同步副本列表( in-sync copies ), 并由Master 节点维护。正如它的名字,这个“好”分片副本列表中的分片,都会保证己成功处理所有的索引和删除操作,并给用户返回 ACK。主分片负责维护不变性(各个副本保持一致〉,因此必须复制这些操作到这个列表中的每个副本。

写入流程遵循以下基本流程:

  1. 请求到达协调节点,协调节点先验证操作,如果有错就拒绝该操作。然后根据当前集群状态,请求被路由到主分片所在节点。

  2. 该操做在主分片上本地执行,例如,索引、更新或删除文挡,会验证字段的内容,如果未通过就拒绝操作(例如,字段串的长度超出 Lucene 定义的长度)。

  3. 操作成功执行后,转发该操作到当前 in-sync 副本组的所有副分片。如果有多个副分片,会并行转发。

  4. 一旦所有的副分片成功执行操作并回复主分片,主分片会把请求执行成功的信息返回给协调节点,协调节点返回给客户端。

写故障处理

写入期间可能会发生很多错误-一硬盘损坏、节点离线,或者某些配置错误,这些错误都可能导致无法在副分片上执行某个操作,虽然这比较少见,但是主分片必须汇报这些错误信息。

对于主分片自身错误的情况,它所在的节点会发送一个消息到 Master 节点 这个索引操作会等待(默认为最多一分钟) Master 节点提升一个副分片为主分片,这个操作会被转发给新的主分片。注意:Master 同样会监控节点的健康,井且可能会主动阵级主分片,这通常发生在主分片所在的节点离线的时候。

在主分片上执行的操作成功后,该主分片必须处理在副分片上潜在发生的错误。错误发生的原因可能是在副分片上执行操作时发生的错误,也可能是因为网络阻塞,导致主分片无法转发操作到副分片,或者副分片无法返回结果给主分片。这些错误都会导致相同的结果 in-sync replica set 中的一个分片丢失一个即将要向用户确认的操作,为了避免出现不一致,主分片会发送一条消息到 Master 节点,要求它把有问题的分片从 in-sync replica set 中移除 。一旦Master认移除了该分片,主分片就会确认这次操作 注意, Master 会指导另一个节点建立新的副本分片,以便把系统恢复成健康状态。

基本读取模型

基本流程如下:

  1. 把读请求转发到相关分片。注意,因为大多数搜索都会发送到一个或多个索引,通常需要从多个分片中读取,每个分片都保存这些数据的一部分。

  2. 从副本组中选择一个相关分片的活跃副本,它可以是主分片或副分片。默认情况下,ES 会简单地循环遍历这些分片。

  3. 发送分片级的读请求到被选中的副本。

  4. 合并结果井给客户端返回响应。注意,针对通过 ID 查找的 get 请求,会跳过这个步骤,因为只有一个相关的分片。

读故障处理

当分片不能响应一个读请求时,协调节点会从副本组中选择另一个副本,将请求转发给没有可用的分片副本会导致重复的错误。在某些情况下,例如,_search,ES会倾向于尽早响应,即使只有部分结果,也不等待问题被解决(可以在响应结果的 _shards 字段中检查本次结果是完整的还是部分的)。

引申的问题

基本流程决定了 ES 系统在读和写时的表现 此外,由于读写可以同时执行,所以这两个基本流程互相有些影响。这有一些固定的含义。

  • 高效读取:在正常操作下,读操作在相关副本组中只执行一次。只有在出错的时候才会在同一个分片的不同副本中执行多次。

  • 在写操作返回应答之前读取:主分片首先在本地进行索引,然后转发请求,由于主分片己经写成功,因此在并行的读请求中,有可能在写请求返回成功之前就可以读取更新的内容。

  • 只有单个分片可能降低索引速度:因为每次操作时主分片会等待所有在 in-sync 列表中的副本,所以单个缓慢的副本可能降低整个副本组的写速度,当然,单个缓慢的分片也会降低读取速度。

  • 脏读:从一个被隔离的主分片进行读取,可能读取没有经过确认的写操作。这是因为只有主分片向副分片转发请求,或者向主节点发送请求的时候才会被隔离,此时数据已经在主分片写成功可以被读取到。ES通过定期(默认为1秒) ping 主节点来降低这种风险,如果没有己知的主节点,则拒绝索引操作。

Allocation IDs

ES 5.x 版本开始引入 Allocation IDs 的概念,用于主分片选举策略。每个分片有自己唯Allocation ID,同时集群元信息中有一个列表,记录了哪些分片拥有最新数据。如果主分片发生错误永久不可用,如果将一个旧数据的分片作为主分片,它将作为最终副本,从而导致这个副本之后的数据将会丢弃。下面我们介绍如何追踪到那个可以安全地被选为主分片的副本,称之为同步(in -sync)分片副本。

安全地分配主分片

每个节点都会通过检查集群状态来判断某个分片是否可用。如果一个分片被指定为主分片则这个节点只需要加载本地分片副本,使之可以用于搜索即可。如果一个分片被分配为副分片,则节点首先需要从主分片所在节点复制差异数据。当集群中可用副分片不足时(在索引设置中指定(index.number_of_replicas),主节点也可以将副分片分配到不含任何此分片副本的节点,从而指示这些节点创建主分片的完整副本。在创建新索引时,主节点在选择哪个节点作为主分片方面有很大的灵活性,会将集群均衡和其他约束(如分配感知及过滤器)考虑在内。。为了确保安全,主节点必须确保被选为主分片的副本含有最新数据,为此 ES 使用 Allocation IDs 的概念,这是区分不同分片的唯一标识(UUIDS)。

Allocation IDs 由主节点在分片分配时指定,并由数据节点存储在磁盘中,紧邻实际的数据分片。主节点负责追踪包含最新数据副本的子集。这些副本集合称为同步分片标识(in-sync Acallocation IDs),存储于集群状态中。集群状态存在于集群的主节点和所有数据节点。对集群状态的更改由 zen discovery 模块实现一致性支持。它确保集群中有共同的理解,即哪些分片副本被认为是同步的( in- sync ),隐式地将那些不在同步集合中的分片副本标记为陈旧。

也就是说, Allocation IDs 存储在 shard 级元信息中,每个 shard 都有自己唯 Allocation ID, 同时集群级元信息中记录了一个被认为是最新 shard Allocation ID 集合,这个集合称为 in-syncallocation IDs。

Sequence IDs

ES 6.0 版本开始引入了 Sequence IDs 概念,使用唯一ID来标记每个写操作,通过这ID我们有了索引操作的总排序。写操作先到达主分片,主分片写完后转发到副分片,在转发到副分片之前,增加一个计数器,为每个操作分配一个序列号是很简单的。但是,由于节点离线随时可能发生,例如,网络分区等,主分片可能被其他副分片取代,仅仅由主分片分配一个序列号无法保证全局唯一性和单调性,因此,我们把当前主分片做一个标记,放到每个操作中,这就是 Primary Terms 这样,来自旧的主分片的迟到的操作就可以被检测到然后拒绝(虽然 Allocation IDs 可以让主分片分配在拥有最新数据的分片上,但仍然可能存在某些情况下主分片上的数据并非最新,例如,手工分配主分片到有旧数据的副本)

Primary Terms 和 Sequence Numbers

  • Primary Terms:由主节点分配给每个主分片,每次主分片发生变化时递增。然后持久化到集群状态中,从而表示集群主分片所处的一个版本。有了 Primary Terms,操作历史中的任何冲突都可以通过查看操作的 Primary Terms 来解决,新的 Terms 优先于旧 Terms,拒绝过时的操作,避免混乱的情况。

  • Sequence Numbers:标记发生在某个分片上的写操作。由主分片分配,只对写操作分配。假设索引 website 有2个主分片和1个副分片,当分片 website[O]的序列号增加到5时,它的主分片离线,副分片被提升为新的主分片,对于后续写操作,序列号从6开始递增。分片 website[1]有自己独立的序列号计数器。Sequence Numbers 使我们能够理解发生在主分片节点上的索引操作的特定顺序。

本地及全局检查点

有了 Primary Terms 和 Sequence Numbers,我们就有了在理论上能够检测出分片之间差异并在主分片失效时,重新对齐它们的工具。旧主分片就可以恢复为与拥有更高 Primary Terms 值的新主分片一致:从旧主分片中删除新主分片操作历史中不存在的操作,并将缺少的操作引到旧主分片。

遗憾的是,当同时为每秒成百上千的事件做索引时,比较数百万个操作的历史是不切实际存储成本非常昂贵,直接进行比较的计算工作量太大。为了解决这个问题, ES 维护了名为“全局检查点”( global checkpoint )的安全标记。

全局检查点是所有活跃分片历史都己对齐的序列号,换句话说,所有低于全局检查点的操作都保证己被所有活跃的分片处理完毕。这意味着,当主分片失效时,我们只需要比较新主分片与其他副分片之间的最后一个全局检查点之后的操作即可。当旧主分片恢复时,我们使用它知道的全局检查点,与新主分片进行比较。这样,我们只有小部分操作需要比较,不用比较全部。

主分片负责推进全局检查点,它通过跟踪在副分片上完成的操做来实现。一旦它检测到所有副分片已经超出给定序列号,它将相应地更新全局检查点。副分片不会跟踪所有操作,而是维护一个类似全局检查点局部变量,称为本地检查点。本地检查点是一个序列号,所有序列号低于它的操作都己在该分片上处理( Lucene translog 写成功 ,不一定刷盘)完毕。当副分片确认( ACK 写操作到主分片节点 ,它也会更新本地检查点。使用本地检查点,主分片节点能够更新全局检查点,然后在下一次索引操作时将其发送到所有分片副本。

用于快速恢复(Recovery)

当 ES 恢复一个分片时,需要保证恢复之后与主分片一致。对于冷数据来说,synced flush 可以快速验证副分片与主分片是否相同,但对于热数据来说,恢复过程需要从主分片复制整个Lucene分段,如果分段很大,则是非常耗时的操作。

现在我们使用副本所知道的最后一个全局检查点,重放来自主分片事务日志(translog)中的相关更改。也就是说,现在可以计算出待恢复分片与主分片数据的差异范围,因此避免复制整个分片。同时,我们多保留一些事务日志(默认为 512MB, 12 小时),直到“太大”或“太老”。如果不能从事务日志恢复,则使用旧的恢复模式。

_version

每个文档都有一个版本号(_version),当文档被修改时版本号递增。ES 使用这个_version来确保变更以正确顺序执行.如果旧版本的文档在新版本之后到达,则它可以被简单地忽略。例如,索引 recovery 阶段就利用了这个特性。版本号由主分片生成,在将请求转发给副本片时将携带此版本号。

版本号的另一个作用是实现乐观锁,如同其他数据库的乐观锁 样。我们在写请求中指定文档的版本号,如果文档的当前版本与请求中指定的版本号不同,则请求会失败。

最后更新: 2021年04月14日 09:15

原始链接: https://jjw-story.github.io/2019/10/20/Elasticsearch-核心技术一/

× 请我吃糖~
打赏二维码