ElasticSearch核心之——深入了解 Search 的运行机制

前言

在之前的ElasticSearch(简称ES)系列文章中,我们已经知道了ES的API使用和相关的核心概念,在本篇文章中,我们将会针对ES执行Search检索过程中的步骤,做出进一步的解释,让读者更加深入地了解ES检索的运作过程。主要涉及的内容有检索过程、相关性算分、如何实现排序、分页和遍历的实现方式,希望有兴趣或者对这一块内容不清楚的读者能够从中有所收获。

一、检索流程:Query Then Fetch

ES的检索过程正如其英文含义一样,先检索,后获取。

(一)Query阶段
  1. node3在接收到用户的search 请求后,会先进行Query阶段(此时是CoordinatingNode角色)
  2. node3在6个主副分片中随机选择3个分片,发送search request
  3. 被选中的3个分片会分别执行查询并排序,返回from+size个文档ld和排序值
  4. node3整合3个分片返回的from+size个文档Id,根据排序值排序后选取 from到from+size的文档Id
ES检索的Query阶段
(二)Fetch阶段

node3根据Query阶段获取的文档Id列表去对应的shard 上获取文档详情数据

  1. node3向相关的分片发送multi_get请求
  2. 3个分片返回文档详细数据
  3. node3拼接返回的结果并返回给客户
ES检索的Fetch阶段

从上面的描述中,我们可以看到:由于ES分片机制的存在,我们插入的文档是不连续存放在同一个Shard分片上的,所以当我们想要检索满足指定条件的文档时,就必须要在所有的分片上都进行检索后,再将结果归并后再排序才能得到最终满足条件的文档ID。然后在Fetch阶段,根据文档ID重新在各个分片上查询得到最终结果,再合并后返回出去

二、相关性算分

相关性算分是ES返回最匹配结果的重要参考依据。但是相关性算分在shard 与shard间是相互独立的,也就意味着同一个Term的IDF等值在不同shard上是不同的。文档的相关性算分和它所处的shard相关,所以在文档数量不多时,会导致相关性算分严重不准的情况发生。(注意:在ES 7.x版本后已经没有这个问题了
举一个例子,创建一个Index(分片默认为5),并插入三条数据,那么此时文档就会分布在三个分片上,这个时候如果根据关键字“hello”去查询的话会发现,三条文档的分值都是一致的,这是因为在各个分片上只有一条最匹配记录,所以分值会失真,不能代表真实的匹配度。

PUT /test_index_score
{
  "settings": {
    "number_of_shards": 5
  }
}

POST /test_index_score/_doc
{
  "name": "hello"
}
POST /test_index_score/_doc
{
  "name": "hello,world"
}
POST /test_index_score/_doc
{
  "name": "hello,world.ElasticSearch"
}

GET /test_index_score/_search

由于我本地的ES是7.x版本的,所以这里的话就演示不了。如果读者使用的是7.x版本的话,可以忽略本节。

三、排序

排序是ES检索时经常会用到的一个功能,按照指定字段排序时默认会忽略相关性算分。但如果我们想要其不忽略相关性算分的话,可以在排序过程中通过数组的方式加入到排序条件中。


默认排序会忽略相关性算分
将相关性得分加入到排序条件中

ES的排序中还需要注意的一个问题是,关于字符串类型数据的排序。我们知道,在ES中有关字符串的类型有2种,一种是text,一种是keyword。但是在ES中,是不允许直接将text类型的数据作为排序条件来直接检索的,原因是ES中的文档数据都是分词后存储在倒排索引中的,而我们想要将text类型的数据作为排序条件,就必须要知道该文档分词前的状态。

PUT /test_index_sort
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text"
      },
      "age": {
        "type": "integer"
      }
    }
  }
}
POST /test_index_sort/_doc/1
{
  "name": "huang xiaoming",
  "age": 14
}
POST /test_index_sort/_doc/1
{
  "name": "huang baoqiang",
  "age": 15
}

GET /test_index_sort/_search
{
  "sort": [
    {
      "name": {
        "order": "desc"
      }
    }
  ]
}

使用text类型数据作为检索条件

从上图我们可以看到,当我们使用text类型的数据来排序时,ES会直接报错,并提示我们说需要用keywork类型的数据或者设置fielddata=true才可以进行检索。

PUT /test_index_sort1/_doc/1
{
  "name": "huang xiaoming",
  "age": 14
}

GET /test_index_sort1/_search
{
  "sort": [
    {
      "name.keyword": {
        "order": "desc"
      }
    }
  ]
}
使用text类型的keyword类型子字段

排序的过程实质是对字段原始内容排序的过程,这个过程中倒排索引无法发挥作用,需要用到正排索引,也就是通过文档d和字段可以快速得到字段原始内容。

ES对此提供了2种实现方式: FieldDataDoc values

其中Fielddata默认禁用Doc Values默认启用(除了text类型)。也就是说,默认情况下doc_values是启动的(这也是为什么对于text类型我们可以使用其keyword类型的子字段来排序的原因),使得我们可以在检索过程中实现对非text类型数据的文档排序,当doc_values的生成会占用一定的磁盘空间,如果我们确认某个字段不会用于索引的话,就可以关闭这个设置。同时,Doc_values不支持text类型的排序,所以当我们需要对text类型排序时,就可以临时开启fielddata开关来实现文档排序。下面我们来演示一下两种方式的使用吧。

使用Fielddata
开启字段的fielddata属性

这里要注意,对于其他数据类型,fielddata是不可以设为true的,同时不同版本的ES在开启/关闭Fielddata的API上略有不同,具体可以自己看一下官方文档

使用Doc values

Doc values默认是开启的,我们现在试着将它关闭一下。

关闭字段的doc_value属性

这里需要注意,doc_value属性是不支持实时开关的,也就是说我们在定义索引的时候就要指定好该索引的某个字段是否要关闭Doc values(默认为开启状态)。

对于两种实现方式的区别,可以参考一下下面的对比表
对比 FieldData DocValues
创建时机 搜索时即时创建 索引时创建,与倒排索引创建时机一致
创建位置 JVM Heap 磁盘
优点 不会占用额外的磁盘资源 不会占用 Heap内存
缺点 文档过多时,即时创建会花过多时间,占用过多Heap内存 减慢索引的速度,占用额外的磁盘资源

四、分页和遍历

ES中提供了3种方式来解决分页与遍历的问题:from/sizescrollsearch_after,下面让我们来详细了解一下这三种解决方式的具体做法和区别。

(一)From/Size

在检索过程中指定From/Size是较为常用的分页方案,其中From表示开始位置(默认从0开始),Size表示获取的总数。

使用form、size进行分页

这个方案虽然简单,但是会面临着深度分页问题带来的不便。

可能有些读者会好奇什么是深度分页
深度分页是一个经典的问题︰比如说在数据分片存储的情况下如何获取前1000个文档?

我们获取从990~1000的文档时,会在每个分片上都先获取1000个文档,然后再由Coordinating Node聚合所有分片的结果后再排序选取前1000个文档,页数越深(即from值越大),处理文档就需要越多,占用内存越多,耗时越长。
为了尽量避免深度分页,es通过index.max_result_window限定最多到10000条数据。如果分页的深度过大,ES会直接拒绝这个检索请求。这种限制也是可以理解的,从检索的功能来说,本身也应该更关注匹配度高的结果项,深度分页的结果往往属于相关度较低的匹配结果,对于这类数据来说,也不应该太过于关注。

ES拒绝分页过深的请求

(二)Scroll

Scroll是遍历文档集的api,它以快照的方式来避免深度分页的问题。使用过程中要注意以下两点:

  1. 不能用来做实时搜索,因为数据不是实时的
  2. 尽量不要使用复杂的sort条件,使用_doc最高效
使用Scroll进行分页需要经过两个步骤,第1个步骤是发起快照请求,es在收到该请求后会根据查询条件创建文档d合集的快照
步骤1:发起scroll search请求
第2个步骤是不断使用更新后的_scroll_id迭代调用API获取下一个结果集
步骤2:调用scroll search的api,获取文档集合

使用过程中需要注意的是,Scroll本质上是通过快照来实现分页的,所以一旦快照创建后,我们后续新增的文档或者删除的文档都不会同步到快照中,所以通过Scroll查询得到的结果是不具有实时性的。
同时,过多的scroll调用会占用大量内存,可以通过clear api删除过多的scroll快照

批量删除scroll id以及全部id

(三)Search_After

Search After使用简单且可以避免深度分页的性能问题,提供实时的下一页文档获取功能,缺点是不能使用from参数,即不能指定页数,只能向下翻页。我本地ES7.6版本的根据官方文档操作使用不了这个API,这里的话就举例6.X版本的用法吧。
第一步为正常的搜索,但要指定sort值,并保证值唯一,第二步为使用上一步最后一个文档的sort值进行查询

Search_After的API
那么Search_After是如何避免深度分页带来的性能问题呢?

答案是Search_After通过唯一排序值定位将每次要处理的文档数都控制在size内。我们回忆一下使用from/size时的分页,比如说要查询第5000-5010的所有文档,由于ES并不知道第5000个文档在哪里,那么就必须从所有分片上面各自查询5010个文档再合并后排序输出。而使用Search_After则通过唯一排序值解决了这个问题,使得其在查询的过程中,每个分片可以根据唯一排序值定位到文档后,取出数量和size相同的文档即可,这样就避免了由于分页过深而带来的性能消耗问题。

image.png

ES提供的三种分页方式各有特点,应用场景也不同,具体的使用各位读者可以参考下面的表格。

类型 场景
From/Size 需要实时获取顶部的部分文档,且需要自由翻页
Scroll 需要全部文档,如导出所有数据的功能
Search_After 需要全部文档,不需要自由翻页

你可能感兴趣的:(ElasticSearch核心之——深入了解 Search 的运行机制)