掌握它才说明你真正懂 Elasticsearch
Elasticsearch 基于 Lucene,隐藏其复杂性,并提供简单易用的 Restful API接口、Java API 接口。所以理解 ES 的关键在于理解 Lucene 的基本原理。
Lucene 简介
Lucene 是一种高性能、可伸缩的信息搜索(IR)库,在 2000 年开源,最初由鼎鼎大名的 Doug Cutting 开发,是基于 Java 实现的高性能的开源项目。
Lucene 采用了基于倒排表的设计原理,可以非常高效地实现文本查找,在底层采用了分段的存储模式,使它在读写时几乎完全避免了锁的出现,大大提升了读写性能。
核心模块
Lucene 的写流程和读流程如下图所示:
图 1:Lucene 的写流程和读流程
其中,虚线箭头(a、b、c、d)表示写索引的主要过程,实线箭头(1-9)表示查询的主要过程。
Lucene 中的主要模块及模块说明如下:
-
analysis: 主要负责词法分析及语言处理,也就是我们常说的分词,通过该模块可最终形成存储或者搜索的最小单元 Term。
-
index 模块: 主要负责索引的创建工作。
-
store 模块: 主要负责索引的读写,主要是对文件的一些操作,其主要目的是抽象出和平台文件系统无关的存储。
-
queryParser 模块: 主要负责语法分析,把我们的查询语句生成 Lucene 底层可以识别的条件。
-
search 模块: 主要负责对索引的搜索工作。
-
similarity 模块: 主要负责相关性打分和排序的实现。
核心术语
下面介绍 Lucene 中的核心术语:
-
Term: 是索引里最小的存储和查询单元,对于英文来说一般是指一个单词,对于中文来说一般是指一个分词后的词。
-
词典(Term Dictionary,也叫作字典): 是 Term 的集合。词典的数据结构可以有很多种,每种都有自己的优缺点。
比如:排序数组通过二分查找来检索数据:HashMap(哈希表)比排序数组的检索速度更快,但是会浪费存储空间。
FST(finite-state transducer)有更高的数据压缩率和查询效率,因为词典是常驻内存的,而 FST 有很好的压缩率,所以 FST 在 Lucene 的最新版本中有非常多的使用场景,也是默认的词典数据结构。
-
倒排序(Posting List): 一篇文章通常由多个词组成,倒排表记录的是某个词在哪些文章中出现过。
-
正向信息: 原始的文档信息,可以用来做排序、聚合、展示等。
-
段(Segment): 索引中最小的独立存储单元。一个索引文件由一个或者多个段组成。在 Luence 中的段有不变性,也就是说段一旦生成,在其上只能有读操作,不能有写操作。
Lucene 的底层存储格式如下图所示,由词典和倒排序两部分组成,其中的词典就是 Term 的集合:
图 2:Lucene 的底层存储格式
词典中的 Term 指向的文档链表的集合,叫做倒排表。词典和倒排表是 Lucene 中很重要的两种数据结构,是实现快速检索的重要基石。
词典和倒排表是分两部分存储的,在倒排序中不但存储了文档编号,还存储了词频等信息。
在上图所示的词典部分包含三个词条(Term):Elasticsearch、Lucene 和 Solr。词典数据是查询的入口,所以这部分数据是以 FST 的形式存储在内存中的。
在倒排表中,“Lucene”指向有序链表 3,7,15,30,35,67,表示字符串“Lucene”在文档编号为3、7、15、30、35、67的文章中出现过,Elasticsearch 和 Solr 同理。
检索方式
在 Lucene 的查询过程中的主要检索方式有以下四种:
①单个词查询
指对一个 Term 进行查询。比如,若要查找包含字符串“Lucene”的文档,则只需在词典中找到 Term“Lucene”,再获得在倒排表中对应的文档链表即可。
②AND
指对多个集合求交集。比如,若要查找既包含字符串“Lucene”又包含字符串“Solr”的文档,则查找步骤如下:
-
在词典中找到 Term “Lucene”,得到“Lucene”对应的文档链表。
-
在词典中找到 Term “Solr”,得到“Solr”对应的文档链表。
-
合并链表,对两个文档链表做交集运算,合并后的结果既包含“Lucene”也包含“Solr”。
③OR
指多个集合求并集。比如,若要查找包含字符串“Luence”或者包含字符串“Solr”的文档,则查找步骤如下:
-
在词典中找到 Term “Lucene”,得到“Lucene”对应的文档链表。
-
在词典中找到 Term “Solr”,得到“Solr”对应的文档链表。
-
合并链表,对两个文档链表做并集运算,合并后的结果包含“Lucene”或者包含“Solr”。
④NOT
指对多个集合求差集。比如,若要查找包含字符串“Solr”但不包含字符串“Lucene”的文档,则查找步骤如下:
-
在词典中找到 Term “Lucene”,得到“Lucene”对应的文档链表。
-
在词典中找到 Term “Solr”,得到“Solr”对应的文档链表。
-
合并链表,对两个文档链表做差集运算,用包含“Solr”的文档集减去包含“Lucene”的文档集,运算后的结果就是包含“Solr”但不包含“Lucene”。
通过上述四种查询方式,我们不难发现,由于 Lucene 是以倒排表的形式存储的。
所以在 Lucene 的查找过程中只需在词典中找到这些 Term,根据 Term 获得文档链表,然后根据具体的查询条件对链表进行交、并、差等操作,就可以准确地查到我们想要的结果。
相对于在关系型数据库中的“Like”查找要做全表扫描来说,这种思路是非常高效的。
虽然在索引创建时要做很多工作,但这种一次生成、多次使用的思路也是非常高明的。
分段存储
在早期的全文检索中为整个文档集合建立了一个很大的倒排索引,并将其写入磁盘中,如果索引有更新,就需要重新全量创建一个索引来替换原来的索引。
这种方式在数据量很大时效率很低,并且由于创建一次索引的成本很高,所以对数据的更新不能过于频繁,也就不能保证实效性。
现在,在搜索中引入了段的概念(将一个索引文件拆分为多个子文件,则每个子文件叫做段),每个段都是一个独立的可被搜索的数据集,并且段具有不变性,一旦索引的数据被写入硬盘,就不可修改。
在分段的思想下,对数据写操作的过程如下:
-
新增: 当有新的数据需要创建索引时,由于段段不变性,所以选择新建一个段来存储新增的数据。
-
删除: 当需要删除数据时,由于数据所在的段只可读,不可写,所以 Lucene 在索引文件新增一个 .del 的文件,用来专门存储被删除的数据 id。
当查询时,被删除的数据还是可以被查到的,只是在进行文档链表合并时,才把已经删除的数据过滤掉。被删除的数据在进行段合并时才会被真正被移除。
-
更新: 更新的操作其实就是删除和新增的组合,先在.del文件中记录旧数据,再在新段中添加一条更新后的数据。
段不可变性的优点如下:
-
不需要锁: 因为数据不会更新,所以不用考虑多线程下的读写不一致情况。
-
可以常驻内存: 段在被加载到内存后,由于具有不变性,所以只要内存的空间足够大,就可以长时间驻存,大部分查询请求会直接访问内存,而不需要访问磁盘,使得查询的性能有很大的提升。
-
缓存友好: 在段的声明周期内始终有效,不需要在每次数据更新时被重建。
-
增量创建: 分段可以做到增量创建索引,可以轻量级地对数据进行更新,由于每次创建的成本很低,所以可以频繁地更新数据,使系统接近实时更新。
段不可变性的缺点如下:
-
删除: 当对数据进行删除时,旧数据不会被马上删除,而是在 .del 文件中被标记为删除。而旧数据只能等到段更新时才能真正地被移除,这样会有大量的空间浪费。
-
**更新:**更新数据由删除和新增这两个动作组成。若有一条数据频繁更新,则会有大量的空间浪费。
-
新增: 由于索引具有不变性,所以每次新增数据时,都需要新增一个段来存储数据。当段段数量太多时,对服务器的资源(如文件句柄)的消耗会非常大,查询的性能也会受到影响。
-
过滤: 在查询后需要对已经删除的旧数据进行过滤,这增加了查询的负担。
为了提升写的性能,Lucene 并没有每新增一条数据就增加一个段,而是采用延迟写的策略,每当有新增的数据时,就将其先写入内存中,然后批量写入磁盘中。
若有一个段被写到硬盘,就会生成一个提交点,提交点就是一个用来记录所有提交后的段信息的文件。
一个段一旦拥有了提交点,就说明这个段只有读的权限,失去了写的权限;相反,当段在内存中时,就只有写数据的权限,而不具备读数据的权限,所以也就不能被检索了。
从严格意义上来说,Lucene 或者 Elasticsearch 并不能被称为实时的搜索引擎,只能被称为准实时的搜索引擎。
写索引的流程如下:
-
新数据被写入时,并没有被直接写到硬盘中,而是被暂时写到内存中。Lucene 默认是一秒钟,或者当内存中数据量达到一定阶段时,再批量提交到磁盘中。
当然,默认的时间和数据量的大小是可以通过参数控制的。通过延时写的策略,可以减少数据往磁盘上写的次数,从而提升整体的写入性能,如图 3。
-
在达到出触发条件以后,会将内存中缓存的数据一次性写入磁盘中,并生成提交点。
-
清空内存,等待新的数据写入,如下图所示。
图 3:Elasticsearch 写索引
从上述流程可以看出,数据先被暂时缓存在内存中,在达到一定的条件再被一次性写入硬盘中,这种做法可以大大提升数据写入的速度。
但是数据先被暂时存放在内存中,并没有真正持久化到磁盘中,所以如果这时出现断电等不可控的情况,就会丢失数据,为此,Elasticsearch 添加了事务日志,来保证数据的安全。
段合并策略
虽然分段比每次都全量创建索引有更高的效率,但是由于在每次新增数据时都会新增一个段,所以经过长时间的的积累,会导致在索引中存在大量的段。
当索引中段的数量太多时,不仅会严重消耗服务器的资源,还会影响检索的性能。
因为索引检索的过程是: 查询所有段中满足查询条件的数据,然后对每个段里查询的结果集进行合并,所以为了控制索引里段的数量,我们必须定期进行段合并操作。
但是如果每次合并全部的段,则会造成很大的资源浪费,特别是“大段”的合并。
所以 Lucene 现在的段合并思路是:根据段的大小将段进行分组,再将属于同一组的段进行合并。
但是由于对于超级大的段的合并需要消耗更多的资源,所以 Lucene 会在段的大小达到一定规模,或者段里面的数据量达到一定条数时,不会再进行合并。
所以 Lucene 的段合并主要集中在对中小段的合并上,这样既可以避免对大段进行合并时消耗过多的服务器资源,也可以很好地控制索引中段的数量。
段合并的主要参数如下:
-
mergeFactor: 每次合并时参与合并的最少数量,当同一组的段的数量达到此值时开始合并,如果小于此值则不合并,这样做可以减少段合并的频率,其默认值为 10。
-
SegmentSize: 指段的实际大小,单位为字节。
-
minMergeSize: 小于这个值的段会被分到一组,这样可以加速小片段的合并。
-
maxMergeSize: 若有一段的文本数量大于此值,就不再参与合并,因为大段合并会消耗更多的资源。
段合并相关的动作主要有以下两个:
-
对索引中的段进行分组,把大小相近的段分到一组,主要由 LogMergePolicy1 类来处理。
-
将属于同一分组的段合并成一个更大的段。
在段合并前对段的大小进行了标准化处理,通过 logMergeFactorSegmentSize 计算得出。
其中 MergeFactor 表示一次合并的段的数量,Lucene 默认该数量为 10;SegmentSize 表示段的实际大小。通过上面的公式计算后,段的大小更加紧凑,对后续的分组更加友好。
段分组的步骤如下:
①根据段生成的时间对段进行排序,然后根据上述标准化公式计算每个段的大小并且存放到段信息中,后面用到的描述段大小的值都是标准化后的值,如图 4 所示:
图 4:Lucene 段排序
② 在数组中找到最大的段,然后生成一个由最大段的标准化值作为上限,减去 LEVEL_LOG_SPAN(默认值为 0.75)后的值作为下限的区间,小于等于上限并且大于下限的段,都被认为是属于同一组的段,可以合并。
③ 在确定一个分组的上下限值后,就需要查找属于这个分组的段了,具体过程是:创建两个指针(在这里使用指针的概念是为了更好地理解)start 和 end。
start 指向数组的第 1 个段,end 指向第 start+MergeFactor 个段,然后从 end 逐个向前查找落在区间的段。
当找到第 1 个满足条件的段时,则停止,并把当前段到 start 之间的段统一分到一个组,无论段的大小是否满足当前分组的条件。
如图 5 所示,第 2 个段明显小于该分组的下限,但还是被分到了这一组。
图 5:Lucene 段分组
这样做的好处如下:
-
增加段合并的概率,避免由于段的大小参差不齐导致段难以合并。
-
简化了查找的逻辑,使代码的运行效率更高。
④ 在分组找到后,需要排除不参加合并的“超大”段,然后判断剩余的段是否满足合并的条件。
如图 5 所示,mergeFactor=5,而找到的满足合并条件的段的个数为 4,所以不满足合并的条件,暂时不进行合并,继续找寻下一个组的上下限。
⑤ 由于在第 4 步并没有找到满足段合并的段的数量,所以这一分组的段不满足合并的条件,继续进行下一分组段的查找。
具体过程是: 将 start 指向 end,在剩下的段(从 end 指向的元素开始到数组的最后一个元素)中寻找最大的段,在找到最大的值后再减去 LEVEL_LOG_SPAN 的值,再生成一下分组的区间值。
然后把 end 指向数组的第 start+MergeFactor 个段,逐个向前查找第 1 个满足条件的段:重复第 3 步和第 4 步。
⑥ 如果一直没有找到满足合并条件的段,则一直重复第 5 步,直到遍历完整个数组,如图 6 所示:
图 6:Lucene 段分组二
⑦ 在找到满足条件的 mergeFactor 个段时,就需要开始合并了。但是在满足合并条件的段大于 mergeFactor 时,就需要进行多次合并。
也就是说每次依然选择 mergeFactor 个段进行合并,直到该分组的所有段合并完成,再进行下一分组的查找合并操作。
⑧ 通过上述几步,如果找到了满足合并要求的段,则将会进行段的合并操作。
因为索引里面包含了正向信息和反向信息,所以段合并的操作分为两部分:
-
一个是正向信息合并, 例如存储域、词向量、标准化因子等。
-
一个是反向信息的合并, 例如词典、倒排表等。
在段合并时,除了需要对索引数据进行合并,还需要移除段中已经删除的数据。
Lucene 相似度打分
我们在前面了解到,Lucene 的查询过程是: 首先在词典中查找每个 Term,根据 Term 获得每个 Term 所在的文档链表;然后根据查询条件对链表做交、并、差等操作,链表合并后的结果集就是我们要查找的数据。
这样做可以完全避免对关系型数据库进行全表扫描,可以大大提升查询效率。
但是,当我们一次查询出很多数据时,这些数据和我们的查询条件又有多大关系呢?其文本相似度是多少?
本节会回答这个问题,并介绍 Lucene 最经典的两个文本相似度算法:基于向量空间模型的算法和基于概率的算法(BM25)。
如果对此算法不太感兴趣,那么只需了解对文本相似度有影响的因子有哪些,哪些是正向的,哪些是逆向的即可,不需要理解每个算法的推理过程。但是这两个文本相似度算法有很好的借鉴意义。
Elasticsearch 简介
Elasticsearch 是使用 Java 编写的一种开源搜索引擎,它在内部使用 Luence 做索引与搜索,通过对 Lucene 的封装,提供了一套简单一致的 RESTful API。
Elasticsearch 也是一种分布式的搜索引擎架构,可以很简单地扩展到上百个服务节点,并支持 PB 级别的数据查询,使系统具备高可用和高并发性。
核心概念
Elasticsearch 的核心概念如下:
-
Cluster: 集群,由一个或多个 Elasticsearch 节点组成。
-
Node: 节点,组成 Elasticsearch 集群的服务单元,同一个集群内节点的名字不能重复。通常在一个节点上分配一个或者多个分片。
-
Shards: 分片,当索引上的数据量太大的时候,我们通常会将一个索引上的数据进行水平拆分,拆分出来的每个数据库叫作一个分片。
在一个多分片的索引中写入数据时,通过路由来确定具体写入那一个分片中,所以在创建索引时需要指定分片的数量,并且分片的数量一旦确定就不能更改。
分片后的索引带来了规模上(数据水平切分)和性能上(并行执行)的提升。每个分片都是 Luence 中的一个索引文件,每个分片必须有一个主分片和零到多个副本分片。
-
Replicas: 备份也叫作副本,是指对主分片的备份。主分片和备份分片都可以对外提供查询服务,写操作时先在主分片上完成,然后分发到备份上。
当主分片不可用时,会在备份的分片中选举出一个作为主分片,所以备份不仅可以提升系统的高可用性能,还可以提升搜索时的并发性能。但是若副本太多的话,在写操作时会增加数据同步的负担。
-
Index: 索引,由一个和多个分片组成,通过索引的名字在集群内进行唯一标识。
-
Type: 类别,指索引内部的逻辑分区,通过 Type 的名字在索引内进行唯一标识。在查询时如果没有该值,则表示在整个索引中查询。
-
Document: 文档,索引中的每一条数据叫作一个文档,类似于关系型数据库中的一条数据通过 _id 在 Type 内进行唯一标识。
-
**Settings:**对集群中索引的定义,比如一个索引默认的分片数、副本数等信息。
-
Mapping: 类似于关系型数据库中的表结构信息,用于定义索引中字段(Field)的存储类型、分词方式、是否存储等信息。Elasticsearch 中的 Mapping 是可以动态识别的。
如果没有特殊需求,则不需要手动创建 Mapping,因为 Elasticsearch 会自动根据数据格式识别它的类型,但是当需要对某些字段添加特殊属性(比如:定义使用其他分词器、是否分词、是否存储等)时,就需要手动设置 Mapping 了。一个索引的 Mapping 一旦创建,若已经存储了数据,就不可修改了。
-
Analyzer: 字段的分词方式的定义。一个 Analyzer 通常由一个 Tokenizer、零到多个 Filter 组成。
比如默认的标准 Analyzer 包含一个标准的 Tokenizer 和三个 Filter:Standard Token Filter、Lower Case Token Filter、Stop Token Filter。
Elasticsearch 的节点的分类如下:
①主节点(Master Node): 也叫作主节点,主节点负责创建索引、删除索引、分配分片、追踪集群中的节点状态等工作。Elasticsearch 中的主节点的工作量相对较轻。
用户的请求可以发往任何一个节点,并由该节点负责分发请求、收集结果等操作,而并不需要经过主节点转发。
通过在配置文件中设置 node.master=true 来设置该节点成为候选主节点(但该节点不一定是主节点,主节点是集群在候选节点中选举出来的),在 Elasticsearch 集群中只有候选节点才有选举权和被选举权。其他节点是不参与选举工作的。
②数据节点(Data Node): 数据节点,负责数据的存储和相关具体操作,比如索引数据的创建、修改、删除、搜索、聚合。
所以,数据节点对机器配置要求比较高,首先需要有足够的磁盘空间来存储数据,其次数据操作对系统 CPU、Memory 和 I/O 的性能消耗都很大。
通常随着集群的扩大,需要增加更多的数据节点来提高可用性。通过在配置文件中设置 node.data=true 来设置该节点成为数据节点。
③客户端节点(Client Node): 就是既不做候选主节点也不做数据节点的节点,只负责请求的分发、汇总等,也就是下面要说到的协调节点的角色。
其实任何一个节点都可以完成这样的工作,单独增加这样的节点更多地是为了提高并发性。
可在配置文件中设置该节点成为数据节点:
node.master=false
node.data=false
**
④部落节点(Tribe Node):**部落节点可以跨越多个集群,它可以接收每个集群的状态,然后合并成一个全局集群的状态。
它可以读写所有集群节点上的数据,在配置文件中通过如下设置使节点成为部落节点:
tribe:
one:
cluster.name: cluster_one
two:
cluster.name: cluster_two
因为 Tribe Node 要在 Elasticsearch 7.0 以后移除,所以不建议使用。
⑤协调节点(Coordinating Node): 协调节点,是一种角色,而不是真实的 Elasticsearch 的节点,我们没有办法通过配置项来配置哪个节点为协调节点。集群中的任何节点都可以充当协调节点的角色。
当一个节点 A 收到用户的查询请求后,会把查询语句分发到其他的节点,然后合并各个节点返回的查询结果,最好返回一个完整的数据集给用户。
在这个过程中,节点 A 扮演的就是协调节点的角色。由此可见,协调节点会对 CPU、Memory 和 I/O 要求比较高。
集群的状态有 Green、Yellow 和 Red 三种,如下所述:
-
Green: 绿色,健康。所有的主分片和副本分片都可正常工作,集群 100% 健康。
-
Yellow: 黄色,预警。所有的主分片都可以正常工作,但至少有一个副本分片是不能正常工作的。此时集群可以正常工作,但是集群的高可用性在某种程度上被弱化。
-
Red: 红色,集群不可正常使用。集群中至少有一个分片的主分片及它的全部副本分片都不可正常工作。
这时虽然集群的查询操作还可以进行,但是也只能返回部分数据(其他正常分片的数据可以返回),而分配到这个分片上的写入请求将会报错,最终会导致数据的丢失。
3C 和脑裂
①共识性(Consensus)
共识性是分布式系统中最基础也最主要的一个组件,在分布式系统中的所有节点必须对给定的数据或者节点的状态达成共识。
虽然现在有很成熟的共识算法如 Raft、Paxos 等,也有比较成熟的开源软件如 Zookeeper。
但是 Elasticsearch 并没有使用它们,而是自己实现共识系统 zen discovery。
Elasticsearch 之父 Shay Banon 解释了其中主要的原因: “zen discovery是 Elasticsearch 的一个核心的基础组件,zen discovery 不仅能够实现共识系统的选择工作,还能够很方便地监控集群的读写状态是否健康。当然,我们也不保证其后期会使用 Zookeeper 代替现在的 zen discovery”。
zen discovery 模块以“八卦传播”(Gossip)的形式实现了单播(Unicat):单播不同于多播(Multicast)和广播(Broadcast)。节点间的通信方式是一对一的。
②并发(Concurrency)
Elasticsearch 是一个分布式系统。写请求在发送到主分片时,同时会以并行的形式发送到备份分片,但是这些请求的送达时间可能是无序的。
在这种情况下,Elasticsearch 用乐观并发控制(Optimistic Concurrency Control)来保证新版本的数据不会被旧版本的数据覆盖。
乐观并发控制是一种乐观锁,另一种常用的乐观锁即多版本并发控制(Multi-Version Concurrency Control)。
它们的主要区别如下:
-
乐观并发控制(OCC): 是一种用来解决写-写冲突的无锁并发控制,认为事务间的竞争不激烈时,就先进行修改,在提交事务前检查数据有没有变化,如果没有就提交,如果有就放弃并重试。乐观并发控制类似于自选锁,适用于低数据竞争且写冲突比较少的环境。
-
多版本并发控制(MVCC): 是一种用来解决读-写冲突的无所并发控制,也就是为事务分配单向增长的时间戳,为每一个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。
这样在读操作不用阻塞操作且写操作不用阻塞读操作的同时,避免了脏读和不可重复读。
③一致性(Consistency)
Elasticsearch 集群保证写一致性的方式是在写入前先检查有多少个分片可供写入,如果达到写入条件,则进行写操作,否则,Elasticsearch 会等待更多的分片出现,默认为一分钟。
有如下三种设置来判断是否允许写操作:
-
One: 只要主分片可用,就可以进行写操作。
-
All: 只有当主分片和所有副本都可用时,才允许写操作。
-
Quorum(k-wu-wo/reng,法定人数): 是 Elasticsearch 的默认选项。当有大部分的分片可用时才允许写操作。其中,对“大部分”的计算公式为 int((primary+number_of_replicas)/2)+1。
Elasticsearch 集群保证读写一致性的方式是,为了保证搜索请求的返回结果是最新版本的文档,备份可以被设置为 Sync(默认值),写操作在主分片和备份分片同时完成后才会返回写请求的结果。
这样,无论搜索请求至哪个分片都会返回最新的文档。但是如果我们的应用对写要求很高,就可以通过设置 replication=async 来提升写的效率,如果设置 replication=async,则只要主分片的写完成,就会返回写成功。
④脑裂
在 Elasticsearch 集群中主节点通过 Ping 命令来检查集群中的其他节点是否处于可用状态,同时非主节点也会通过 Ping 来检查主节点是否处于可用状态。
当集群网络不稳定时,有可能会发生一个节点 Ping 不通 Master 节点,则会认为 Master 节点发生了故障,然后重新选出一个 Master 节点,这就会导致在一个集群内出现多个 Master 节点。
当在一个集群中有多个 Master 节点时,就有可能会导致数据丢失。我们称这种现象为脑裂。
事务日志
我们在上面了解到,Lucene 为了加快写索引的速度,采用了延迟写入的策略。
虽然这种策略提高了写入的效率,但其最大的弊端是,如果数据在内存中还没有持久化到磁盘上时发生了类似断电等不可控情况,就可能丢失数据。
为了避免丢失数据,Elasticsearch 添加了事务日志(Translog),事务日志记录了所有还没有被持久化磁盘的数据。
Elasticsearch 写索引的具体过程如下: 首先,当有数据写入时,为了提升写入的速度,并没有数据直接写在磁盘上,而是先写入到内存中,但是为了防止数据的丢失,会追加一份数据到事务日志里。
因为内存中的数据还会继续写入,所以内存中的数据并不是以段的形式存储的,是检索不到的。
总之,Elasticsearch 是一个准实时的搜索引擎,而不是一个实时的搜索引擎。
此时的状态如图 7 所示:
图 7:Elasticsearch 写数据的过程
然后,当达到默认的时间(1 秒钟)或者内存的数据达到一定量时,会触发一次刷新(Refresh)。
刷新的主要步骤如下:
-
将内存中的数据刷新到一个新的段中,但是该段并没有持久化到硬盘中,而是缓存在操作系统的文件缓存系统中。虽然数据还在内存中,但是内存里的数据和文件缓存系统里的数据有以下区别。
内存使用的是 JVM 的内存,而文件缓存系统使用的是操作系统的内存;内存的数据不是以段的形式存储的,并且可以继续向内存里写数据。文件缓存系统中的数据是以段的形式存储的,所以只能读,不能写;内存中的数据是搜索不到,文件缓存系统中的数据是可以搜索的。
-
打开保存在文件缓存系统中的段,使其可被搜索。
-
清空内存,准备接收新的数据。日志不做清空处理。
此时的状态如图 8 所示:
图 8:Elasticsearch 写数据的过程
最后,刷新(Flush)。当日志数据的大小超过 512MB 或者时间超过 30 分钟时,需要触发一次刷新。
刷新的主要步骤如下:
-
在文件缓存系统中创建一个新的段,并把内存中的数据写入,使其可被搜索。
-
清空内存,准备接收新的数据。
-
将文件系统缓存中的数据通过 Fsync 函数刷新到硬盘中。
-
生成提交点。
-
删除旧的日志,创建一个空的日志。
此时的状态如图 9 所示:
图 9:Elasticsearch 写数据的过程
由上面索引创建的过程可知,内存里面的数据并没有直接被刷新(Flush)到硬盘中,而是被刷新(Refresh)到了文件缓存系统中,这主要是因为持久化数据十分耗费资源,频繁地调用会使写入的性能急剧下降。
所以 Elasticsearch,为了提高写入的效率,利用了文件缓存系统和内存来加速写入时的性能,并使用日志来防止数据的丢失。
在需要重启时,Elasticsearch 不仅要根据提交点去加载已经持久化过的段,还需要根据 Translog 里的记录,把未持久化的数据重新持久化到磁盘上。
根据上面对 Elasticsearch,写操作流程的介绍,我们可以整理出一个索引数据所要经历的几个阶段,以及每个阶段的数据的存储方式和作用,如图 10 所示:
图 10:Elasticsearch 写操作流程
在集群中写索引
假设我们有如图 11 所示(图片来自官网)的一个集群,该集群由三个节点组成(Node 1、Node 2 和 Node 3),包含一个由两个主分片和每个主分片由两个副本分片组成的索引。
图 11:写索引
其中,标星号的 Node 1 是 Master 节点,负责管理整个集群的状态;p1 和 p2 是主分片;r0 和 r1 是副本分片。为了达到高可用,Master 节点避免将主分片和副本放在同一个节点。
将数据分片是为了提高可处理数据的容量和易于进行水平扩展,为分片做副本是为了提高集群的稳定性和提高并发量。
在主分片挂掉后,会从副本分片中选举出一个升级为主分片,当副本升级为主分片后,由于少了一个副本分片,所以集群状态会从 Green 改变为 Yellow,但是此时集群仍然可用。
在一个集群中有一个分片的主分片和副本分片都挂掉后,集群状态会由 Yellow 改变为 Red,集群状态为 Red 时集群不可正常使用。
由上面的步骤可知,副本分片越多,集群的可用性就越高,但是由于每个分片都相当于一个 Lucene 的索引文件,会占用一定的文件句柄、内存及 CPU,并且分片间的数据同步也会占用一定的网络带宽,所以,索引的分片数和副本数并不是越多越好。
写索引时只能写在主分片上,然后同步到副本上,那么,一个数据应该被写在哪个分片上呢?
如图 10 所示,如何知道一个数据应该被写在 p0 还是 p1 上呢答案就是路由(routing),路由公式如下:
shard = hash(routing)%number_of_primary_shards
其中,Routing 是一个可选择的值,默认是文档的 _id(文档的唯一主键,文档在创建时,如果文档的 _id 已经存在,则进行更新,如果不存在则创建)。
后面会介绍如何通过自定义 Routing 参数使查询落在一个分片中,而不用查询所有的分片,从而提升查询的性能。
Routing 通过 Hash 函数生成一个数字,将这个数字除以 number_of_primary_shards(分片的数量)后得到余数。
这个分布在 0 到 number_of_primary_shards - 1 之间的余数,就是我们所寻求的文档所在分片的位置。
这也就说明了一旦分片数定下来就不能再改变的原因,因为分片数改变之后,所有之前的路由值都会变得无效,前期创建的文档也就找不到了。
由于在 Elasticsearch 集群中每个节点都知道集群中的文档的存放位置(通过路由公式定位),所以每个节点都有处理读写请求的能力。
在一个写请求被发送到集群中的一个节点后,此时,该节点被称为协调点(Coordinating Node),协调点会根据路由公式计算出需要写到哪个分片上,再将请求转发到该分片的主分片节点上。
图 12:写索引
写操作的流程如下(参考图 11,图片来自官网):
-
客户端向 Node 1(协调节点)发送写请求。
-
Node 1 通过文档的 _id(默认是 _id,但不表示一定是 _id)确定文档属于哪个分片(在本例中是编号为 0 的分片)。请求会被转发到主分片所在的节点 Node 3 上。
-
Node 3 在主分片上执行请求,如果成功,则将请求并行转发到 Node 1 和 Node 2 的副本分片上。
一旦所有的副本分片都报告成功(默认),则 Node 3 将向协调节点报告成功,协调节点向客户端报告成功。
集群中的查询流程
根据 Routing 字段进行的单个文档的查询,在 Elasticsearch 集群中可以在主分片或者副本分片上进行。
图 13
查询字段刚好是 Routing 的分片字段如“_id”的查询流程如下(见图 12,图片来自官网):
-
客户端向集群发送查询请求,集群再随机选择一个节点作为协调点(Node 1),负责处理这次查询。
-
Node 1 使用文档的 routing id 来计算要查询的文档在哪个分片上(在本例中落在了 0 分片上)分片 0 的副本分片存在所有的三个节点上。
在这种情况下,协调节点可以把请求转发到任意节点,本例将请求转发到 Node 2 上。
-
Node 2 执行查找,并将查找结果返回给协调节点 Node 1,Node 1 再将文档返回给客户端。
当一个搜索请求被发送到某个节点时,这个节点就变成了协调节点(Node 1)。
协调节点的任务是广播查询请求到所有分片(主分片或者副本分片),并将它们的响应结果整合成全局排序后的结果集合。
由上面步骤 3 所示,默认返回给协调节点并不是所有的数据,而是只有文档的 id 和得分 score,因为我们最后只返回给用户 size 条数据,所以这样做的好处是可以节省很多带宽,特别是 from 很大时。
协调节点对收集回来的数据进行排序后,找到要返回的 size 条数据的 id,再根据 id 查询要返回的数据,比如 title、content 等。
图 14
取回数据等流程如下(见图 13,图片来自官网):
-
Node 3 进行二次排序来找出要返回的文档 id,并向相关的分片提交多个获得文档详情的请求。
-
每个分片加载文档,并将文档返回给 Node 3。
-
一旦所有的文档都取回了,Node 3 就返回结果给客户端。
协调节点收集各个分片查询出来的数据,再进行二次排序,然后选择需要被取回的文档。
例如,如果我们的查询指定了{"from": 20, "size": 10},那么我们需要在每个分片中查询出来得分最高的 20+10 条数据,协调节点在收集到 30×n(n 为分片数)条数据后再进行排序。
排序位置在 0-20 的结果会被丢弃,只有从第 21 个开始的 10 个结果需要被取回。这些文档可能来自多个甚至全部分片。
由上面的搜索策略可以知道,在查询时深翻(Deep Pagination)并不是一种好方法。
因为深翻时,from 会很大,这时的排序过程可能会变得非常沉重,会占用大量的 CPU、内存和带宽。因为这个原因,所以强烈建议慎重使用深翻。
分片可以减少每个片上的数据量,加快查询的速度,但是在查询时,协调节点要在收集数(from+size)×n 条数据后再做一次全局排序。
若这个数据量很大,则也会占用大量的 CPU、内存、带宽等,并且分片查询的速度取决于最慢的分片查询的速度,所以分片数并不是越多越好。
作者:钱丁君
简介:就职于永辉云创,担任基础架构开发,有多年基础架构经验,主要从事电商新零售、互联网金融行业。技术发烧友,涉猎广泛。熟悉 Java 微服务架构搭建、推进、衍化;多种中间件搭建、封装和优化;自动化测试开发、代码规约插件开发、代码规范推进;容器化技术 Docker、容器化编排技术 Kubernetes,有较为丰富的运维经验。
编辑:陶家龙、孙淑娟
来源:https://www.jianshu.com/p/28fb017be7a7