ES底层原理
1、倒排索引(分段,segment)
Elasticsearch 使用一种称为倒排索引的结构,它适用于快速的全文搜索。
有倒排索引,肯定会对应有正向索引:
- 正向索引(forward index)
- 反向索引(inverted index,实际就是倒排索引)
所谓的正向索引,就是搜索引擎会将待搜索的文件都对应一个文件ID,搜索时将这个ID和搜索关键字进行对应,形成K-V对,然后对关键字进行统计计数。
但是互联网上收录在搜索引擎中的文档的数目是个天文数字,这样的索引结构根本无法满足实时返回排名结果的要求。所以,搜索引擎会将正向索引重新构建为倒排索引,即把文件ID对应到关键词的映射转换为关键词到文件ID的映射(跟正向索引反过来了),每个关键词都对应着一系列的文件,这些文件中都出现这个关键词。
1.1、倒排索引示例
倒排索引的例子:一个倒排索引由文档中所有不重复词的列表构成,对于其中每个词,有一个包含它的文档列表。例如,假设我们有两个文档,每个文档的 content 域包含如下内容:
- The quick brown fox jumped over the lazy dog
- Quick brown foxes leap over lazy dogs in summer
为了创建倒排索引,我们首先将每个文档的 content 域拆分成单独的词(我们称它为词条或tokens ),创建一个包含所有不重复词条的排序列表,然后列出每个词条出现在哪个文档。结果如下所示:
现在,如果我们想搜索 quick
brown
,我们只需要查找包含每个词条的文档:
两个文档都匹配,但是第一个文档比第二个匹配度更高。如果我们使用仅计算匹配词条数量的简单相似性算法,那么我们可以说,对于我们查询的相关性来讲,第一个文档比第二个文档更佳。
但是,我们目前的倒排索引有一些问题:
-
Quick
和quick
以独立的词条出现,然而用户可能认为它们是相同的词。 -
fox
和foxes
非常相似,就像dog
和dogs
;他们有相同的词根。 -
jumped
和leap
,尽管没有相同的词根,但他们的意思很相近。他们是同义词。
使用前面的索引搜索+Quick +fox不会得到任何匹配文档。(记住,+前缀表明这个词必须存在)。只有同时出现Quick和fox 的文档才满足这个查询条件,但是第一个文档包含quick fox ,第二个文档包含Quick foxes 。我们的用户可以合理的期望两个文档与查询匹配。我们可以做的更好。
如果我们将词条规范为标准模式,那么我们可以找到与用户搜索的词条不完全一致,但具有足够相关性的文档。例如:
- Quick可以小写化为quick。
- foxes可以词干提取变为词根的格式为fox。类似的,dogs可以为提取为dog。
- jumped和leap是同义词,可以索引为相同的单词jump 。
现在索引看上去像这样:
这还远远不够。我们搜索+Quick +fox 仍然会失败,因为在我们的索引中,已经没有Quick了。但是,如果我们对搜索的字符串使用与content域相同的标准化规则,会变成查询+quick +fox,这样两个文档都会匹配!分词和标准化的过程称为分析,这非常重要。你只能搜索在索引中出现的词条,所以索引文本和查询字符串必须标准化为相同的格式。
1.2、不可改变的倒排索引
早期的全文检索会为整个文档集合建立一个很大的倒排索引并将其写入到磁盘。 一旦新的索引就绪,旧的就会被其替换,这样最近的变化便可以被检索到。
倒排索引被写入磁盘后是不可改变的:它永远不会修改。不变性的好处如下:
- 不需要锁。如果你从来不更新索引,你就不需要担心多进程同时修改数据的问题。
- 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性,只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘,这提供了很大的性能提升。
- 其它缓存(像filter缓存),在索引的生命周期内始终有效,它们不需要在每次数据改变时被重建,因为数据不会变化。
- 写入单个大的倒排索引允许数据被压缩,可减少磁盘 IO 和需要被缓存到内存的索引的使用量。
坏处:
- 每次都要重新构建整个索引
1.3、如何动态更新倒排索引(段,segment)
一个不变的索引有上述好处,当然也有不好的地方,主要因为是它是不可变的,你不能修改它。所以如果你需要让一个新的文档可被搜索,你需要重建整个索引。这要么对一个索引所能包含的数据量造成了很大的限制,要么对索引可被更新的频率造成了很大的限制。
如何在保留不变性的前提下实现倒排索引的更新?答案是:用更多的索引。通过增加新的补充索引来反映新近的修改,而不是直接重写整个倒排索引。每一个倒排索引都会被轮流查询到,从最早的开始查询,然后再对结果进行合并。
Elasticsearch基于Lucene,这个java库引入了按段搜索的概念。每一段本身都是一个倒排索引,但索引在 Lucene 中除表示所有段的集合外,还增加了提交点的概念:一个列出了所有已知段的文件。
按段搜索会以如下流程执行:
1)新文档被收集到内存索引缓存。
2)不时地,缓存被提交。
- 一个新的段,一个追加的倒排索引,被写入磁盘。
- 一个新的包含新段名字的提交点被写入磁盘。
- 磁盘进行同步,所有在文件系统缓存中等待的写入都刷新到磁盘,以确保它们被写入物理文件
3)新的段被开启,让它包含的文档可见以被搜索。
4)内存缓存被清空,等待接收新的文档。
当一个查询被触发,所有已知的段按顺序被查询。词项统计会对所有段的结果进行聚合,以保证每个词和每个文档的关联都被准确计算,这种方式可以用相对较低的成本将新文档添加到索引。
段是不可改变的,所以既不能从把文档从旧的段中移除,也不能修改旧的段来进行反映文档的更新。取而代之的是,每个提交点会包含一个.del 文件,文件中会列出这些被删除文档的段信息。
- 当一个文档被“删除”时,它实际上只是在 .del 文件中被标记删除。一个被标记删除的文档仍然可以被查询匹配到,但它会在最终结果被返回前从结果集中移除。
- 文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。
1.4、索引、分片、分段的关系
每个分片包含多个“分段”,其中分段是倒排索引。分段内的 doc 数量上限是2的31次方。默认每秒都会生成一个 segment 文件。在分片中搜索将依次搜索每个片段,然后将其结果合并到该分片的最终结果中。索引、分片、分段的关系如下图:
2、文档写入原理
分别从集群角度和分片(shard)角度来介绍数据如何写入。
2.1、集群角度(Primary -> Replica)
客户端可以发送请求到集群中的任一节点。 每个节点都有能力处理任意请求。 每个节点都知道集群中任一文档位置,所以可以直接将请求转发到需要的节点上。
流程说明如下:
- 客户端向NODE1 发送写请求。检查 Active 的 Shard 数。
- NODE1 使用文档 ID 来确定文档属于的分片(图例是:分片0),通过集群状态中的信息获知分片0的主分片位于 NODE3,因此请求被转发到 NODE3 上。NODE3 上的主分片执行写操作。
- 并发的向所有副本分片发起写入请求,即将请求并行转发到 NODE1 和 NODE2 的副分片上。
- 等待所有副本分片返回结果给主分片
- 主分片将最终的成功或者失败后结果返回给协调节点,最后返回到 Client
一个文档从开始更新或修改,到可被搜索的延迟主要就是主分片的延时 + 并行写入副本的最大延时,如下图:
Q&A:
- 为什么要检查Active的Shard数?
ES中有一个参数,叫做wait_for_active_shards。这个参数的含义是,在每次写入前,该shard至少具有的active副本数。假设我们有一个Index,其每个Shard有3个Replica,加上Primary则总共有4个副本。如果配置wait_for_active_shards为3,那么允许最多有一个Replica挂掉,如果有两个Replica挂掉,则Active的副本数不足3,此时不允许写入。
这个参数默认是1,即只要Primary在就可以写入。如果配置大于1,可以起到一种保护的作用,保证写入的数据具有更高的可靠性。但是这个参数只在写入前检查,并不保证数据一定在至少这些个副本上写入成功,所以并不是严格保证了最少写入了多少个副本。
- 写入Primary完成后,为何要等待所有同步Replica响应(或连接失败)后返回?
早期ES版本,Primary和Replica之间是允许异步复制的,即写入Primary成功即可返回。但是这种模式下,如果Primary挂掉,就有丢数据的风险,而且从Replica读数据也很难保证能读到最新的数据。所以后来ES就取消异步模式了,改成Primary等同步Replica返回后再返回给客户端。
- 如果某个Replica持续写失败,用户是否会经常查到旧数据?
假如一个Replica持续写入失败,那么这个Replica上的数据可能落后Primary很多。Primary会将这个信息报告给Master,然后Master会在Meta中更新这个Index的InSyncAllocations配置,将这个Replica从中移除,移除后它就不再承担读请求。在Meta更新到各个Node之前,用户可能还会读到这个Replica的数据,但是更新了Meta之后就不会了。所以这个方案并不是非常的严格,考虑到ES本身就是一个近实时系统,数据写入后需要refresh才可见,所以一般情况下,在短期内读到旧数据应该也是可接受的。
2.2、分片角度
下面详细介绍数据是如何写入分片的。
在每一个Shard中,写入流程分为两部分,先写入Lucene,再写入TransLog。
- 写入请求到达Shard(分片)后,先写Lucene文件。此时索引还在内存 Buffer 缓存里面,接着去写TransLog。
- 每隔1秒钟执行一次 refresh 操作,将index-buffer(即内存)中文档(document)生成的segment写到文件缓存系统之中。(当文档在文件系统缓存中时,就已经可以像其它文件一样被打开和读取了。)
- 每 30 分钟或当 translog 达到一定大小(由index.translog.flush_threshold_size控制,默认512mb),ES会触发一次 flush 操作,此时 ES 会先执行 refresh 操作将 buffer 中的数据生成 segment,然后调用 lucene 的 commit 过程将所有文件缓存系统中的 segment fsync 到磁盘。此时lucene中的数据就完成了持久化。
- 写磁盘成功后,请求返回给用户。
(当一个文档写入Lucene后是不能被立即查询到的,Elasticsearch提供了一个refresh操作,会定时地为内存中新写入的数据生成一个新的segment,此时被处理的文档均可以被检索到。refresh操作的时间间隔由refresh_interval参数控制,默认为1s。)
Q&A:
- 为什么es要先写入
lucene
,后写入translog
?
Lucene
的内存写入会有很复杂的逻辑,很容易失败,比如分词,字段长度超过限制等,比较重,为了避免TransLog
中有大量无效记录,为了减少写入失败回滚的复杂度和提高速度,所以就把写Lucene
放在了最前面。
2.2.1、refresh 操作
Memory Buffer
的缓存区。Memory Buffer的性能非常高,客户端发出写入请求的时候是直接写在Memory Buffer里的。Memory Buffer的空间阈值默认大小为堆内存的10%,时间阈值为1s。空间阈值和时间阈值只要达成任意一个,就会触发 Refresh操作。默认1秒钟刷新一次,所以说ES是近实时的搜索引擎,不是准实时。文档的变化并不是立即对搜索可见,但会在1秒之内变为可见。相比于 Lucene 的提交操作,ES的refresh是相对轻量级
的操作。先将缓存中文档(document)生成的segment写到文件缓存系统之中,这样避免了比较损耗性能 io 操作,又可以使搜索可见。
内存索引缓冲区中的文档被写入新段,新段首先写入文件系统缓存(这个过程性能消耗很低),然后才刷新到磁盘(这个过程代价很高)。但是,在文件进入缓存后,它就已经对搜索可见。
-
手动刷新文档(refresh)
Elasticsearch 文档的变化并不是立即对搜索可见的,这种行为可能会对新用户造成困惑:他们索引了一个文档然后尝试搜索它,但却没有搜到。这个问题的解决办法是用refresh API执行一次手动刷新:/usersl_refresh
尽管刷新是比提交(写入磁盘)轻量很多的操作,它还是会有性能开销。当写测试的时候,手动刷新很有用,但是不要在生产环境下每次索引一个文档都去手动刷新。相反,你的应用需要意识到Elasticsearch 的近实时的性质,并接受它的不足。
甚至并不是所有的情况都需要每秒刷新,比如可能你正在使用Elasticsearch索引大量的日志文件,你可能想优化索引速度而不是近实时搜索,可以通过设置refresh_interval ,降低每个索引的刷新频率,如下:
{
"settings": {
"refresh_interval": "30s"
}
}
refresh_interval 可以在既存索引上进行动态更新。在生产环境中,当你正在建立一个大的新索引时,可以先关闭自动刷新,待开始使用该索引时,再把它们调回来。
# 关闭自动刷新
PUT /users/_settings
{ "refresh_interval": -1 }
# 每一秒刷新
PUT /users/_settings
{ "refresh_interval": "1s" }
注意:实际情况需要结合自己的业务场景设置refresh频率值。
2.2.2、flush 操作(写入磁盘)
从filesystem cache(文件缓存)写入磁盘的过程就是flush。
每30分钟或当 translog 达到一定大小(由index.translog.flush_threshold_size控制,默认512mb),ES会触发一次 flush 操作。在 flush 过程中,ES 会先执行 refresh 操作将 buffer 中的数据生成 segment,内存中的缓冲将被清除,内容被写入一个新段,段的 fsync 将创建一个新的提交点,并将内容刷新到磁盘,旧的 translog 将被删除并开始一个新的 translog,此时lucene中的数据就完成了持久化。
如果没有用 fsync 把数据从文件系统缓存刷(flush)到硬盘,我们不能保证数据在断电甚至是程序正常退出之后依然存在。为了保证Elasticsearch 的可靠性,需要确保数据变化被持久化到磁盘。在动态更新索引,我们说一次完整的提交会将段刷到磁盘,并写入一个包含所有段列表的提交点(commit point),Elasticsearch 在启动或重新打开一个索引的过程中使用这个提交点来判断哪些段隶属于当前分片。
你很少需要自己手动执行flush操作,通常情况下,自动刷新就足够了。当Elasticsearch尝试恢复或重新打开一个索引,它需要重放 translog 中所有的操作,所以如果日志越短,恢复越快,所以我们可以在重启节点或关闭索引之前执行 flush。
2.2.3、merge 操作(合并分段)
由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数量太多会带来较大的麻烦,每一个段都会消耗文件句柄、内存和cpu运行周期,更重要的是,每个搜索请求都必须轮流检查每个段,所以段越多,搜索也就越慢。Elasticsearch 会运行一个任务检测当前磁盘中的segment,对符合条件的segment进行合并操作,小的段被合并到大的段,然后这些大的段再被合并到更大的段。
不仅如此,merge 过程也是旧的doc真正被删除的时候,段合并的时候会将那些旧的已删除文档从文件系统中清除,被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。
用户还可以手动调用 _forcemerge API 来主动触发merge,以减少集群的segment个数和清理已删除或更新的文档。
- 刷新(refresh)操作会创建新的段并将段打开以供搜索使用。
- 合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中,这并不会中断索引和搜索。
- 新的段被打开用来搜索,老的段会被删除
2.2.4、translog文件
当一个文档写入Lucence后是存储在内存中的,即使执行了refresh操作仍然是在文件系统缓存中,如果此时服务器宕机,那么这部分数据将会丢失。为此ES增加了translog, 当进行文档写操作时会先将文档写入Lucene,然后写入一份到translog,写入translog后会落盘的,这样就可以防止服务器宕机后数据的丢失。重新启动ES时,Elasticsearch 会将所有未刷新的操作从 Translog 重播到 Lucene 索引,以使其恢复到重新启动前的状态。translog 的目的是保证操作不会丢失,在文件被 fsync 到磁盘前,被写入的文件在重启之后就会丢失。
translog 提供所有还没有被刷到磁盘的操作的一个持久化纪录。当Elasticsearch启动的时候,它会从磁盘中使用最后一个提交点去恢复己知的段,并且会重放 translog 中所有在最后一次提交后发生的变更操作。
translog 也被用来提供实时CRUD。当你试着通过ID查询、更新、删除一个文档,它会在尝试从相应的段中检索之前,首先检查 translog任何最近的变更,这意味着它总是能够实时地获取到文档的最新版本。
参考:https://blog.csdn.net/Mrerlou/article/details/129124784
3、分析器(字符过滤器、分词器、Token 过滤器)
分析包含下面的过程:
- 将一块文本分成适合于倒排索引的独立的词条。
- 将这些词条统一化为标准格式以提高它们的“可搜索性”,或者recall。
分析器执行上面的工作。分析器实际上是将三个功能封装到了一个包里:
- 字符过滤器:首先,字符串按顺序通过每个字符过滤器,他们的任务是在分词前整理字符串。一个字符过滤器可以用来去掉 HTML,或者将 & 转化成 and。
- 分词器:其次,字符串被分词器分为单个的词条。一个简单的分词器遇到空格和标点的时候,可能会将文本拆分成词条。
- Token 过滤器:最后,词条按顺序通过每个 token 过滤器 。这个过程可能会改变词条(例如,小写化Quick ),删除词条(例如, 像 a, and, the 等无用词),或者增加词条(例如,像jump和leap这种同义词)
3.1、ES的内置分析器
Elasticsearch 附带了可以直接使用的预包装的分析器。
下面会列出最重要的分析器,为了证明它们的差异,我们看看每个分析器会从下面的字符串得到哪些词条:
"Set the shape to semi-transparent by calling set_trans(5)"
3.1.1、标准分析器(默认)
标准分析器是Elasticsearch 默认使用的分析器,它是分析各种语言文本最常用的选择。它根据Unicode 联盟定义的单词边界划分文本,删除绝大部分标点,最后,将词条小写。上面字符串将产生以下词条:
set, the, shape, to, semi, transparent, by, calling, set_trans, 5
3.1.2、简单分析器
简单分析器在任何不是字母的地方分隔文本,将词条小写。它会产生:
set, the, shape, to, semi, transparent, by, calling, set, trans
3.1.3、空格分析器
空格分析器在空格的地方划分文本。它会产生:
Set, the, shape, to, semi-transparent, by, calling, set_trans(5)
3.1.4、语言分析器
特定语言分析器可用于很多语言,它们可以考虑指定语言的特点。例如,英语分析器附带了一组英语无用词(常用单词,例如and或者the ,它们对相关性没有多少影响),它们会被删除。由于理解英语语法的规则,这个分词器可以提取英语单词的词干。
上面字符串使用英语分词器会产生下面的词条:
set, shape, semi, transpar, call, set_tran, 5
注意看transparent、calling和 set_trans已经变为词根格式。
3.2、分析器使用场景
当我们索引一个文档,它的全文域被分析成词条以用来创建倒排索引。但是,当我们在全文域搜索的时候,我们需要将查询字符串通过相同的分析过程,以保证我们搜索的词条格式与索引中的词条格式一致。
- 当你查询一个全文域时,会对查询字符串应用相同的分析器,以产生正确的搜索词条列表。
- 当你查询一个精确值域时,不会分析查询字符串,而是搜索你指定的精确值。
3.3、演示分析器的效果
有些时候很难理解分词的过程和实际被存储到索引中的词条,为了理解发生了什么,你可以使用analyze API来看文本是如何被分析的,在消息体里,指定分析器和要分析的文本。
#GET http://localhost:9200/_analyze { "analyzer": "standard", "text": "Text to analyze" }
结果如下,每个元素代表一个单独的词条:
字段说明:
- token是实际存储到索引中的词条。
- start_ offset 和 end_ offset指明字符在原始字符串中的位置。
- position 指明词条在原始文本中出现的位置。
3.4、指定分析器
当Elasticsearch在你的文档中检测到一个新的字符串域,它会自动设置其为一个全文字符串域,使用标准分析器对它进行分析。你不希望总是这样,可能你想使用一个不同的分析器,适用于你的数据使用的语言。有时候你想要一个字符串域就是一个字符串域,不使用分析,直接索引你传入的精确值,例如用户 ID 或者一个内部的状态域或标签。要做到这一点,我们必须手动指定这些域的映射。
3.5、IK分词器
ES 的默认分词器无法识别中文中测试、 单词这样的词汇,而是简单的将每个字拆完分为一个词。
下面通过 Postman 发送 GET 请求查询默认分词效果
# GET http://localhost:9200/_analyze { "text":"测试单词" }
结果如下:
这样的结果显然不符合我们的使用要求,所以我们需要下载 ES 对应版本的中文分词器。
3.5.1、中文分词器
IK 中文分词器下载网址:https://github.com/infinilabs/analysis-ik/releases/tag/v7.8.0,将解压后的后的文件夹放入 ES 根目录下的 plugins 目录下,重启 ES 即可使用。
加入新的查询参数"analyzer":“ik_max_word”:
# GET http://localhost:9200/_analyze { "text":"测试单词", "analyzer":"ik_max_word" }
- ik_max_word:会将文本做最大(即最细)粒度的拆分。
- ik_smart:会将文本做最小(即最粗)粒度的拆分。
结果如下:
ES 中也可以进行扩展词汇,首先默认查询如下:
#GET http://localhost:9200/_analyze { "text":"弗雷尔卓德", "analyzer":"ik_max_word" }
结果如下:
上面仅仅可以得到每个字的分词结果,我们需要做的就是使分词器识别到弗雷尔卓德也是一个词语。
- 首先进入 ES 根目录中的 plugins 文件夹下的 ik 文件夹,进入 config 目录,创建 custom.dic文件,直接写入“弗雷尔卓德”。
- 然后打开 IKAnalyzer.cfg.xml 文件,将新建的 custom.dic 配置其中。
-
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd"> <properties> <comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 --> <entry key="ext_dict">custom.dic</entry>
<!--用户可以在这里配置自己的扩展停止词字典--> <entry key="ext_stopwords"></entry> <!--用户可以在这里配置远程扩展字典 --> <!-- <entry key="remote_ext_dict">words_location</entry> --> <!--用户可以在这里配置远程扩展停止词字典--> <!-- <entry key="remote_ext_stopwords">words_location</entry> --> </properties>
-
- 重启 ES 服务器 。
扩展后发起相同接口,结果如下:
3.6、自定义分析器
虽然Elasticsearch带有一些现成的分析器,然而在分析器上Elasticsearch真正的强大之处在于,你可以通过在一个适合你的特定数据的设置之中组合字符过滤器、分词器、词汇单元过滤器来创建自定义的分析器。一个分析器就是在一个包里面组合了三种函数的一个包装器,三种函数按照顺序被执行:
- 字符过滤器
字符过滤器用来整理一个尚未被分词的字符串。例如,如果我们的文本是HTML格式的,它会包含像<p>或者<div>这样的HTML标签,这些标签是我们不想索引的。我们可以使用html清除字符过滤器来移除掉所有的HTML标签,并且像把Á转换为相对应的Unicode字符Á 这样,转换HTML实体。一个分析器可能有0个或者多个字符过滤器。
- 分词器
一个分析器必须有一个唯一的分词器。分词器把字符串分解成单个词条或者词汇单元。标准分析器里使用的标准分词器把一个字符串根据单词边界分解成单个词条,并且移除掉大部分的标点符号,然而还有其他不同行为的分词器存在。
例如,关键词分词器完整地输出接收到的同样的字符串,并不做任何分词。空格分词器只根据空格分割文本。正则分词器根据匹配正则表达式来分割文本。
- 词单元过滤器
经过分词,作为结果的词单元流会按照指定的顺序通过指定的词单元过滤器。词单元过滤器可以修改、添加或者移除词单元。我们已经提到过lowercase和stop词过滤器,但是在Elasticsearch 里面还有很多可供选择的词单元过滤器。词干过滤器把单词遏制为词干。ascii_folding过滤器移除变音符,把一个像"très”这样的词转换为“tres”。
ngram和 edge_ngram词单元过滤器可以产生适合用于部分匹配或者自动补全的词单元。
3.6.1、自定义分析器例子
下面演示如何创建自定义的分析器:
#PUT http://localhost:9200/my_index { "settings": { "analysis": { "char_filter": { "&_to_and": { "type": "mapping", "mappings": [ "&=> and " ] } }, "filter": { "my_stopwords": { "type": "stop", "stopwords": [ "the", "a" ] } }, "analyzer": { "my_analyzer": { "type": "custom", "char_filter": [ "html_strip", "&_to_and" ], "tokenizer": "standard", "filter": [ "lowercase", "my_stopwords" ] } } } } }
索引被创建以后,使用 analyze API 来 测试这个新的分析器:
# GET http://127.0.0.1:9200/my_index/_analyze { "text":"The quick & brown fox", "analyzer": "my_analyzer" }
结果如下:
4、处理并发冲突
4.1、文档冲突介绍
当我们使用index API更新文档,可以一次性读取原始文档做我们的修改,然后重新索引整个文档。最早的索引请求将获胜,如果其他人同时更改这个文档,他们的更改将丢失。
很多时候这是没有问题的,很多场景下,我们都是将主数据(关系型数据库)复制到Elasticsearch中并使其可被搜索,也许两个人同时更改相同的文档的几率很小,或者对于我们的业务来说偶尔丢失更改并不是很严重的问题,但有时丢失了一个变更就是非常严重的。比如我们使用Elasticsearch 存储我们网上商城商品库存的数量,每次我们卖一个商品的时候,我们在 Elasticsearch 中将库存数量减少。有一天,管理层决定做一次促销,一秒卖好几个商品,假设有两个web程序并行运行,每一个都同时处理所有商品的销售数据。
web_1 对stock_count所做的更改会丢失,因为 web_2不知道它的 stock_count 的拷贝实际已经过期,结果就会出现卖出的商品数超过实际库存。
变更越频繁,读数据和更新数据的间隙越长,也就越可能丢失变更。在数据库领域中,有两种方法通常被用来确保并发更新时变更不会丢失:
- 悲观并发控制:这种方法被关系型数据库广泛使用,它假定有变更冲突可能发生,因此阻塞访问资源以防止冲突。一个典型的例子是读取一行数据之前先将其锁住,确保只有放置锁的线程能够对这行数据进行修改。
- 乐观并发控制:Elasticsearch 中使用的这种方法假定冲突是不可能发生的,并且不会阻塞正在尝试的操作。然而,如果源数据在读写当中被修改,更新将会失败。应用程序接下来将决定该如何解决冲突。例如,可以重试更新、使用新的数据、或者将相关情况报告给用户。
(正常更新文档其实并不需要指定版本号,但在并发时就可能会发生更新请求顺序混乱的问题,下面一系列措施在更新时指定版本号就是为了解决这些问题)
4.2、乐观并发控制
Elasticsearch是分布式的。当文档创建、更新或删除时,新版本的文档必须复制到集群中的其他节点。Elasticsearch也是异步和并发的,这意味着将有很多复制请求可能会被并行发送,并且到达目的地时也许顺序是乱的。Elasticsearch需要一种方法确保文档的旧版本不会覆盖新的版本。
当我们之前讨论 index , GET和DELETE请求时,我们指出每个文档都有一个_version(版本号),当文档被修改时版本号递增。Elasticsearch使用这个version号来确保变更以正确顺序得到执行。如果旧版本的文档在新版本之后到达,它可以被简单的忽略。
我们可以利用 version 号来确保应用中相互冲突的变更不会导致数据丢失。我们也可以通过指定想要修改文档的 version 号来达到这个目的,如果该版本不是当前版本号,我们的请求将会失败。
(老的版本ES使用version,但是新版本不支持了,会报下面的错误,提示我们用if_seq _no和if _primary_term)
创建文档:
#PUT http://127.0.0.1:9200/shopping/_create/1007
结果如下:
更新文档数据如下:
#POST http://127.0.0.1:9200/shopping/_update/1007 { "doc":{ "title":"华为手机" } }
结果如下:
旧版本使用的防止冲突更新方法:
#POST http://127.0.0.1:9200/shopping/_update/1007?version=2 { "doc":{ "title":"华为手机2" } }
在新版 ES 中可能会报错,结果如下:
新版本使用的防止冲突更新方法:
#POST http://127.0.0.1:9200/shopping/_update/1007?if_seq_no=43&if_primary_term=13 { "doc":{ "title":"华为手机2" } }
结果如下:
4.3、外部系统版本控制(指定编号作为文档最新版本号)
一个常见的设置是使用其它数据库作为主要的数据存储,使用Elasticsearch做数据检索,这意味着主数据库的所有更改发生时都需要被复制到 Elasticsearch,如果多个进程负责这一数据同步,你可能遇到类似于上述描述的并发问题。
如果你的主数据库已经有了版本号,或一个能作为版本号的字段值比如用 timestamp 作为版本号,那么你就可以在 Elasticsearch 中通过 version_type=extermal 参数来使用指定的外部版本号(如 timestamp)作为文档的最新版本号。注意,版本号必须是大于零的整数,且小于9.2E+18,一个Java中 long类型的正值。
外部版本号的处理方式和我们之前讨论的内部版本号的处理方式有些不同,Elasticsearch 不是检查当前 _version 和请求中指定的版本号是否相同,而是检查当前_version是否小于指定的版本号。如果请求成功,请求中指定的外部的版本号将作为文档的新_version进行存储。
#POST http://127.0.0.1:9200/shopping/_doc/1007?version=999&version_type=external { "title":"华为手机3" }
结果如下:
5、文档更新、删除原理
删除和更新也都是写操作,但是 Elasticsearch 中的文档是不可变的,因此不能被删除或者改动以展示其变更;
磁盘上的每个段都有一个相应的.del 文件。当删除请求发送后,文档并没有真的被删除,而是在.del文件中被标记为删除。该文档依然能匹配查询,但是会在结果中被过滤掉。当段合并时,在.del 文件中被标记为删除的文档将不会被写入新段。
在新的文档被创建时,Elasticsearch 会为该文档指定一个版本号,当执行更新时,旧版本的文档在.del文件中被标记为删除,新版本的文档被索引到一个新段。旧版本的文档依然能匹配查询,但是会在结果中被过滤掉。
6、搜索的原理
搜索过程分为两阶段,我们称之为 Query Then Fetch。
- 在初始查询阶段时,查询会广播到索引中每一个分片拷贝(主分片或者副本分片)。 每个分片在本地执行搜索并构建一个匹配文档且大小为 from + size 的优先队列。PS:在搜索的时候除了磁盘也会查询Filesystem Cache 的,但是有部分数据还在 Memory Buffer,所以搜索是近实时的。每个分片返回各自优先队列中所有文档的 ID 和排序值给协调节点,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。
- 接下来就是取回阶段,协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。每个分片加载并丰富文档,如果有需要的话,接着返回文档给协调节点。一旦所有的文档都被取回了,协调节点返回结果给客户端。
Query Then Fetch 的搜索类型在文档相关性打分的时候参考的是本分片的数据,这样在文档数量较少的时候可能不够准确,DFS Query Then Fetch 增加了一个预查询的处理,询问 Term 和 Document frequency,这个评分更准确,但是性能会变差。
7、在并发情况下,Elasticsearch 如果保证读写一致
可以通过版本号使用乐观并发控制,以确保新版本不会被旧版本覆盖,由应用层来处理具体的冲突;
另外对于写操作,一致性级别支持 quorum/one/all,默认为 quorum,即只有当大多数分片可用时才允许写操作。但即使大多数可用,也可能存在因为网络等原因导致写入副本失败,这样该副本被认为故障,分片将会在一个不同的节点上重建。
对于读操作,可以设置 replication 为 sync(默认),这使得操作在主分片和副本分片都完成后才会返回;如果设置 replication 为 async 时,也可以通过设置搜索请求参数_preference 为 primary 来查询主分片,确保文档是最新版本。