ES3_1分布式搜索

先分析ES中的搜索原理,然后总结ES中的搜索方法,主要包括轻量查询、请求体查询。

分布式搜索原理

在分布式环境中执行的搜索是一个两阶段的过程,我们称之为 query then fetch
ES的一个概念

查询阶段(query)

查询阶段

  • 客户端发送一个 search 请求到协调节点(Coordinating Node)(也就是 Node 3),协调节点会创建一个大小为 from+size空优先队列
  • 协调节点将请求广播到索引中每一个分片拷贝(主分片或者副本分片)。
    比如如果集群中有3对主副分配,那么协调节点会从中随机选择3个分片,发送查询请求
  • 每个分片在本地执行搜索并构建一个匹配文档的 优先队列
    一个 优先队列 仅仅是一个存有 top-n 匹配文档的有序列表。优先队列的大小取决于分页参数 from 和 size ,其值为 from + size,比如下面的搜索请求需要足够大的优先队列来放入 100 条文档。
    1
    2
    3
    4
    5
    GET /_search
    {
    "from": 90,
    "size": 10
    }
  • 协调节点将在之后的请求中轮询所有的分片拷贝来分摊负载,因此更多的副本(结合更多的硬件)能增加搜索吞吐率。
    每个分片返回各自优先队列中所有文档的 ID 和排序值(_score) 给协调节点 ,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。

取回阶段(fetch)

取回阶段

  • 协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。
  • 每个分片加载并 丰富 文档(_source),如果有需要的话,接着返回文档给协调节点。
  • 一旦所有的文档都被取回了,协调节点需要对 number_of_shards * (from + size) 大小的文档集合排序,协调节点返回结果给客户端。

深分页问题(Deep Pagination)

先查后取的过程支持用 fromsize 参数分页,但是这是 有限制的 。 要记住需要传递信息给协调节点的每个分片必须先创建一个 from + size 长度的队列,协调节点需要根据 number_of_shards * (from + size) 排序文档,来找到被包含在 size 里的文档。
取决于你的文档的大小,分片的数量和你使用的硬件,给 10,000 到 50,000 的结果文档深分页( 1,000 到 5,000 页)是完全可行的。但是使用足够大的 from 值,排序过程可能会变得非常沉重,使用大量的 CPU、内存和带宽。因为这个原因,我们强烈建议你不要使用深分页。
实际上,深分页很少符合人的行为。当 2 到 3 页过去以后,人会停止翻页,并且改变搜索标准。会不知疲倦地一页一页的获取网页直到你的服务崩溃的罪魁祸首一般是机器人或者 web spider。
如果你确实需要从你的集群取回大量的文档,你可以通过用 scroll 查询禁用排序使这个取回行为更有效率。

偏好 - 解决Bouncing Results问题

偏好这个参数 preference 允许 用来控制由哪些分片或节点来处理搜索请求。 它接受像 _primary, _primary_first, _local, _only_node:xyz, _prefer_node:xyz, 和 _shards:2,3 这样的值,在search Preference有说明。
偏好字段可以用于解决Bouncing Results问题。

Bouncing Results
想象一下有两个文档有同样值的时间戳字段,搜索结果用 timestamp 字段来排序。 由于搜索请求是在所有有效的分片副本间轮询的,那就有可能发生主分片处理请求时,这两个文档是一种顺序, 而副本分片处理请求时又是另一种顺序。
特别是数据量不大的情况下会比较明显,如果数据量足够大的时候,结果一般不会出现偏差——只要保证文档均匀分散在各个分片上。
这就是所谓的 bouncing results 问题: 每次用户刷新页面,搜索结果表现是不同的顺序。 让同一个用户始终使用同一个分片,这样可以避免这种问题, 可以设置 preference 参数为一个特定的任意值比如用户会话 ID 来解决。

超时

通常分片处理完它所有的数据后再把结果返回给协调节点,协调节点把收到的所有结果合并为最终结果。这意味着花费的时间是最慢分片的处理时间加结果合并的时间。如果有一个节点有问题,就会导致所有的响应缓慢。
参数 timeout 告诉 分片允许处理数据的最大时间。如果没有足够的时间处理所有数据,这个分片的结果可以是部分的,甚至是空数据。
搜索的返回结果会用属性 timed_out 标明分片是否返回的是部分结果:

1
2
...
"time_out": true // 表示搜索请求超时了

很有可能查询会超过设定的超时时间,这种情况可能有两个原因:

  1. 超时检查是基于每文档做的。 但是某些查询类型有大量的工作在文档评估之前需要完成。 这种 “setup” 阶段并不考虑超时设置,所以太长的建立时间会导致超过超时时间的整体延迟。
  2. 因为时间检查是基于每个文档的,一次长时间查询在单个文档上执行并且在下个文档被评估之前不会超时。 这也意味着差的脚本(比如带无限循环的脚本)将会永远执行下去。

路由

路由能够在索引时提供来确保相关的文档,比如属于某个用户的文档被存储在某个分片上。 在搜索的时候,不用搜索索引的所有分片,而是通过指定几个 routing 值来限定只搜索几个相关的分片:

1
GET /_search?routing=user_1,user2

搜索类型

缺省的搜索类型是 query_then_fetch 。 在某些情况下,你可能想明确设置 search_typedfs_query_then_fetch 来改善相关性精确度:

1
GET /_search?search_type=dfs_query_then_fetch

搜索类型 dfs_query_then_fetch 有预查询阶段,这个阶段可以从所有相关分片获取词频来计算全局词频。

相关性

搜索的相关性算分描述了一个文档和查询语句匹配的程度,ES会对每个匹配查询条件的结果进行算分 _score
打分的本质是排序,需要把最符合用户需求的文档排在前面
ES 5之前,默认的相关性算分采用TF-IDF算法,现在采用BM 25算法。

有以下指标可以用于衡量结果的相关性,即结果是否满足用户的期望:

  • Precision(查准率)
    尽可能返回较少的无关文档
  • Recall(查全率/召回率)
    尽量返回较多的相关文档
    体现在用户能否查到自己想要的数据
  • Ranking(排序)

TF-IDF

TF(词频Term Frequency)表示检索词在一篇文档中出现的频率

  • 度量一条查询语句和结果文档相关性的简单方法:将搜索中每一个词的TF进行相加
  • 停用词(Stop Word)对贡献相关度几乎没有用处,不应该考虑他们的TF。

IDF(逆文档频率Inverse Document Frequency)
即log(全部文档数/检索词出现过的文档总数)

TF-IDF本质上就是将TF求和变成了加权求和=TF(word) * IDF(word)

BM 25

和经典的TF-IDF相比,当TF无限增加时,BM 25算分。

搜索 - 轻量查询(Query-string) - 在URL中使用查询参数

当在单一的索引下进行搜索的时候,Elasticsearch 转发请求到索引的每个分片中,可以是主分片也可以是副本分片,然后从每个分片中收集结果。多索引搜索恰好也是用相同的方式工作的——只是会涉及到更多的分片。
搜索一个索引有五个主分片和搜索五个索引各有一个分片准确来所说是等价的,因为索引只是一种逻辑结构,实际上处理请求的是分片。
搜索有两种形式:Query-string(URI) 和请求体查询。

URI查询的方式

  • q
    指定查询语句
  • df
    默认字段,不指定时,会对所有字段进行查询
  • sort
    排序
  • from, size
    分页参数
  • profile
    可以用于查看查询是如何被执行的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HEAD /megacorp/employee/1
GET /megacorp/employee/1
GET /megacorp/employee/_search
# 用q指定查询条件
GET /megacorp/employee/_search?q=last_name:Smith
GET /megacorp/employee/_search?q=+first_name:Jane last_name:Smith
GET /_search?q=mary
GET /_search?q=+name:(mary john) +date:>2014-09-10 +(aggregations geo)
GET /_search 在所有的索引中搜索所有的类型(无条件)
/gb/_search 在 gb 索引中搜索所有的类型(指定索引)
/gb,us/_search 在 gb 和 us 索引中搜索所有的文档(指定多个索引)
/g*,u*/_search 在任何以 g 或者 u 开头的索引中搜索所有的类型(通配符)
/gb/user/_search 在 gb 索引中搜索 user 类型(指定类型)
/gb,us/user,tweet/_search 在 gb 和 us 索引中搜索 user 和 tweet 类型(指定多个类型)
/_all/user,tweet/_search 在所有的索引中搜索 user 和 tweet 类型(指定多个类型但不限制索引)

可以在请求体中指定profile参数为true来展示查询的过程:

1
2
3
4
get /doc/_search?q=name:a
{
"profile": "true"
}

查询条件

  • 其中 ‘_search’ 表示搜索,默认返回 10 条结果;
  • 查询本身赋值给了参数 q,查询链接会被 URL 编码,所以可读性会比较差;
  • “last_name:Smith”表示 last_name 字段中包含 Smith 的文档,”+last_name:Smith”表示必须与查询条件匹配,”-last_name:Smith”表示一定不与查询条件匹配;
    至于多个条件存在的情况下,其实不会发生矛盾,”+first_name:Jane -first_name:Jane”可以看成先查出 first_name 包含”Jane”的,再过滤掉;
  • 区间查询
    可以用区间表示:
    q=year:{2019 TO 2018}
    q=year:[* TO 2018]
    也可以用算数符号来表示:
    q=year:>2010
    q=year:(>2010 && <=2018)
    q=year:(+>2010 +<=2018)

隐含字段

当对文档进行索引时,Elasticsearch 取出所有字段的值拼接成一个大的字符串,作为 _all 字段进行索引。

Query-string 的优点

简洁

Query-string 的缺点

  • 晦涩难懂
  • 脆弱,小的语法错误就会报错
  • 效率较低,查询字符串搜索允许任何用户在索引的任意字段上执行可能较慢且重量级的查询,这可能会暴露隐私信息,甚至将集群拖垮

搜索返回字段

hits:匹配文档总数及所查询结果的前十个文档
_score:衡量文档与查询的匹配程度
took:整个搜索请求耗费的时间(毫秒)
shards:在查询中参与的分片总数,及失败(主分片和副本分片都挂掉)和成功的数量
timeout:返回值中的 timed_out 表示查询是否超时, timeout 不是停止执行查询,它仅仅是告知正在协调的节点返回到目前为止收集的结果并且关闭连接。在后台,其他的分片可能仍在执行查询即使是结果已经被发送了。使用超时是因为 SLA(服务等级协议)对你是很重要的,而不是因为想去中止长时间运行的查询。可以在查询条件中指定时间限制:

1
GET /_search?timeout=10ms

分页

size:显示应该返回的结果数量,默认是 10
from:显示应该跳过的初始结果数量,默认是 0

1
GET /_search?size=5&from=5

考虑到分页过深(比如请求 10001 到 10010 的数据)以及一次请求太多结果的情况:结果集在返回之前先进行排序,但请记住一个请求经常跨越多个分片,每个分片都产生自己的排序结果,这些结果需要进行集中排序以保证整体顺序是正确的。
假设在一个有 5 个主分片的索引中搜索。 当我们请求结果的第一页(结果从 1 到 10 ),每一个分片产生前 10 的结果,并且返回给 协调节点 ,协调节点对 50 个结果排序得到全部结果的前 10 个。
现在假设我们请求第 1000 页–结果从 10001 到 10010 。所有都以相同的方式工作除了每个分片不得不产生前 10010 个结果以外。 然后协调节点对全部 50050 个结果排序最后丢弃掉这些结果中的 50040 个结果。可以看到,在分布式系统中,对结果排序的成本随分页的深度成指数上升。这就是 web 搜索引擎对任何查询都不要返回超过 1000 个结果的原因。

请求体查询(查询表达式)

es 倾向于使用 GET 来表达查询操作,查询条件可以放到请求体中,而事实是这个 RFC 文档 RFC 7231 — 一个专门负责处理 HTTP 语义和内容的文档 — 并没有规定一个带有请求体的 GET 请求应该如何处理!结果是,一些 HTTP 服务器允许这样子,而有一些 — 特别是一些用于缓存和代理的服务器 — 则不允许。所以 es 作了一个妥协,下面的 GET 请求同样可以使用 POST 来实现:

1
2
3
4
5
6
7
8
GET /megacorp/employee/_search
{
"query" : {
"match" : {
"last_name" : "Smith"
}
}
}

查询表达式主要有两种结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
QUERY_NAME: {
ARGUMENT: VALUE,
ARGUMENT: VALUE,...
}
}
{
QUERY_NAME: {
FIELD_NAME: {
ARGUMENT: VALUE,
ARGUMENT: VALUE,...
}
}
}

验证查询是否合法

1
2
3
4
GET /_validate/query?explain
{
"query" : { ... }
}

term

  • term:精确值 匹配,这些精确值可能是数字、时间、布尔或者那些 not_analyzed 的字符串,注意term 不会对输入的文本进行分析,这样和索引过的目标字符串比较很有可能出现明明看起来一样却匹配不上的现象(比如”Jane”被分析后变成”jane”,term 查询时使用”Jane”进行精确匹配当然匹配不上了!);
  • terms:同样是精确值匹配,但可以指定一个数组进行匹配;
    1
    2
    { "term" : {"first_name": "jane"}}
    { "terms": { "tag": [ "search", "full_text", "nosql" ] }}
  • keyword严格匹配
    keyword类型字段可以精确匹配:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    GET /products/_search
    {
    "query": {
    "term": {
    "productId.keyword": {
    "value": "123"
    }
    }
    }
    }

复合查询

constant_score查询可以将Query转换成Filter,不再计算相关性,且Filter可以有效利用缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /products/_search
{
"explain": true,
"query": {
"constant_score": {
"filter": {
"term": {
"productId.keyword": "123"
}
}
}
}
}

将一个不变的常量评分(即 1)应用于所有匹配的文档。经常用于你只需要执行一个 filter 而没有其它查询的情况下,如果使用 bool 查询_score 值将总是为 0:

1
2
3
4
5
6
7
{
"constant_score": {
"filter": {
"term": { "category": "ebooks" }
}
}
}
  • bool:复合语句,可以组合其他语句比如 must 必须匹配、must_not 必须不匹配、should 如果满足则增加_score 否则无影响、filter 用来排除文档不评分,特别的,如果没有 must 语句,那么至少需要能够匹配其中的一条 should 语句。但,如果存在至少一条 must 语句,则对 should 语句的匹配没有要求;每一个子查询都独自地计算文档的相关性得分。一旦他们的得分被计算出来, bool 查询就将这些得分进行合并并且返回一个代表整个布尔操作的得分。(虽说是布尔操作,但是结果分数其实是[0, 1]范围内的小数)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "bool": {
    "must": { "match": { "title": "how to make millions" }},
    "must_not": { "match": { "tag": "spam" }},
    "should": [
    { "match": { "tag": "starred" }}
    ],
    "filter": {
    "range": { "date": { "gte": "2014-01-01" }}
    }
    }
    }

全文查询

全文查询的特点:

  • 索引和搜索时都会进行分词,查询字符串先传递到一个合适的分词器,然后生成一个供查询的词项列表;

  • 查询的时候,先会对输入的查询进行分词,然后每个词项逐个进行底层的查询,最终将结果进行合并,并为每个文档生成一个算分。
    比如查询”Hello World“,会查到包括”Hello“或”World“的所有结果。

  • match_all:简单的匹配所有文档,往往和 filter 搭配;

  • match:如果你在一个全文字段上使用 match 查询,在执行查询前,它将用正确的分析器去分析查询字符串;
    如果在一个精确值的字段上使用它, 例如数字、日期、布尔或者一个 not_analyzed 字符串字段,那么它将会精确匹配给定的值;

  • multi_match:多个字段的 match;

其他结构化查询语句

  • filter:不评分查询,实际上是给了每个查询出的文档一个默认的中性评价 1,并且过滤结果将会被缓存

  • range(范围查询):查询找出那些落在指定区间内的数字或者时间,gt 大于、gte 大于等于、lt 小于、lte 小于等于;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    GET /megacorp/employee/_search
    {
    "query" : {
    "bool": {
    "must": {
    "match" : {
    "last_name" : "smith"
    }
    },
    "filter": {
    "range" : {
    "age" : { "gt" : 30 }
    }
    }
    }
    }
    }
  • exists 和 missing:指定字段有值或无值,但后来的版本中废弃了 missing,因为可以通过 must_not+exists 来实现类似的效果

    1
    { "exists" : { "field" : "age" }}
  • about(全文搜索)
    搜索结果将根据相关性排序。

  • match_phrase(短语搜索)
    针对”rock climbing”这样的短语,如果直接使用 match 会分别使用”rock”和”climbing”两个单词进行搜索,而使用 match_phrase 则会直接使用整个短语进行搜索。

  • highlight(高亮)
    指定某个属性中的匹配部分应该被高亮(结果会使用标签包裹)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    GET /megacorp/employee/_search
    {
    "query" : {
    "match_phrase" : {
    "about" : "rock climbing"
    }
    },
    "highlight": {
    "fields" : {
    "about" : {}
    }
    }
    }
  • 前缀查询
    prefix

  • 正则表达式查询
    regexp

  • 通配符匹配
    使用*匹配字符序列(包括空字符),使用?匹配单个字符。
    最好不要把通配符放在前面,这样会很慢。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    GET mts_sample_order/orders/_search
    {
    "from": 1,
    "size": 10,
    "query": {
    "wildcard": {
    "sample_type_name": "咽拭子*"
    }
    }
    }

聚合 - aggs

Sorting, aggregations, and accessing field values in scripts 需要使用到一个称为 fielddata(正排索引)的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
PUT megacorp/_mapping/employee/
{
"properties": {
"interests": {
"type": "text",
"fielddata": true
}
}
}
GET /megacorp/employee/_search
{
"aggs" : { // 和query同级的关键词
"all_interests" : { // 自定义聚合名字
"terms" : { // 聚合的定义:不同的type+body
"field" : "interests"
},
"aggs" : { // 子聚合查询
"avg_age" : {
"avg" : { "field" : "age" }
}
}
},
"max_salary": { // 可以包含多个同级的聚合查询
"max": { // 求salary字段的最大值
"field": "salary"
}
}
}
}

聚合作用范围

  • ES聚合分析的默认作用范围是query的查询结果集
  • ES还支持以下方式改变聚合的作用范围
    Filter
    Post Filter
    Global

简单聚合

单值分析(只输出一个分析结果):

  • min, max, avg, sum
  • Cardinality (类似 distinct count)

多值分析(输出多个分析结果):

  • stats, extended stats
  • percentile, percentile rank
  • top hits

Bucket

按照一定的规则,将文档分配到不同的桶中,从而达到分类的目的。
Bucket同样支持嵌套。

常用的Bucket Aggregation函数:

  • terms
    按某个字段聚合,结果默认会进行分词
    需要字段打开fielddata才能进行term aggregation
    keyword默认支持doc_values
    text需要在mapping中enable fielddata
  • 数字类型
    • Range
    • Histogram
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      POST employees/_search
      {
      "size": 0,
      "aggs": {
      "jobs": {
      "terms": {
      "field": "job"
      }
      }
      }
      }

Pipeline

对聚合分析结果再进行一次聚合分析
Pipeline的分析结果会输出到原结果中,根据位置的不同,分为两类:

  • Sibing:结果和现有分析结果同级
    max, min, avg, sum bucket
    stats, extended status bucket
    percentiles bucket
  • Parent:结果内嵌到现有的聚合分析结果之中
    Derivative(求导)
    Cumultive Sum(累计求和)
    Moving Function(滑动窗口)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
POST employees/_search
{
"size": 0,
"aggs": {
"jobs": {
"terms": {
"field": "job.keyword",
"size": 10
},
"aggs": {
"avg_salary": {
"avg": {
"field": "salary"
}
}
}
},
"min_salary_by_job": {
"min_bucket": { // min_bucket求之前结果的最小值
"buckets_path": "jobs>avg_salary" // buckets_path关键字指定路径
}
}
}
}

ARGUMENT 一些特殊的参数

  • preference(偏好)
    用来控制由哪些分片或节点来处理搜索请求。 它接受像 _primary, _primary_first, _local, _only_node:xyz, _prefer_node:xyz, 和 _shards:2,3 这样的值,但是最有用的值是某些随机字符串,它可以避免 bouncing results 问题(不同分片上排序顺序不同),这样可能发生每次用户刷新页面,搜索结果表现是不同的顺序,所以最好让同一个用户始终使用同一个分片,可以设置 preference 参数为一个特定的任意值比如用户会话 ID 来解决。
  • timeout(超时)
    在分布式搜索时需要由每个分片处理完、将结果返回给协调节点进行排序、再返回给客户端,所以整个过程的效率受到最慢的那个分片的处理速度和协调节点的处理速度二者的限制;
    参数 timeout 告诉 分片允许处理数据的最大时间。如果没有足够的时间处理所有数据,这个分片的结果可以是部分的,甚至是空数据,搜索的返回结果会用属性 timed_out 标明分片是否返回的是部分结果(是否超时);
    需要注意可能会影响到整体延迟(并非上面说的超时时间)的条件,比如某些查询类型有大量的工作在文档评估之前需要完成、每次评估时基于每个文档的所以单个文档死循环也会影响整个查询死循环。
  • routing(路由)
    在索引时提供 routing 值可以确保文档被存储到某个分片上,在搜索的时候,不用搜索索引的所有分片,而是通过指定几个 routing 值来限定只搜索几个相关的分片,这个技术在 设计大规模搜索系统 时就会派上用场。
  • search_type(搜索类型)
    缺省的搜索类型是 query_then_fetch。你可能想明确设置 search_type 为 dfs_query_then_fetch 来改善相关性精确度,搜索类型 dfs_query_then_fetch 有预查询阶段,这个阶段可以从所有相关分片获取词频来计算全局词频。
    1
    2
    3
    4
    5
    6
    GET /_search?preference=xyzabc123&routing=user_1,user2&search_type=dfs_query_then_fetch
    {
    "from": 0,
    "size": 5,
    "timeout": "1s"
    }

Doc Values / fielddata - 正排索引

在搜索的时候,我们能通过搜索关键词快速得到结果集。当排序的时候,我们需要倒排索引里面某个字段值的集合,此时倒排索引无法发挥作用。换句话说,我们需要 转置 倒排索引。转置 结构在其他系统中经常被称作 列存储 。实质上,它将所有单字段的值存储在单数据列中,这使得对其进行操作是十分高效的,例如排序。
ES有2种方法实现:

  • Fielddata(可以存储Text类型)
  • Doc Values(列式存储,对Text类型无效)
Doc Values Field data
何时创建 索引时,和倒排索引一起创建 搜索时动态创建
创建位置 磁盘文件 JVM Heap
优点 避免大量内存占用 索引速度快,不占用额外的磁盘空间
缺点 降低索引速度,占用额外磁盘空间 文档过多时,动态创建开销大,占用过多JVM Heap
缺省值 ES 2.x 之后 ES 1.x 及之前

当 working set 远小于节点的可用内存,系统会自动将所有的文档值保存在内存中,使得其读写十分高速; 当其远大于可用内存,操作系统会自动把 Doc Values 加载到系统的页缓存中,从而避免了 jvm 堆内存溢出异常。

关闭 Doc Values

Doc Value默认是启用的,可以通过Mapping设置关闭

1
2
3
4
5
6
7
8
9
PUT test_keyword/_mapping
{
"properties": {
"user_name": {
"type": "keyword",
"doc_values": false
}
}
}
  • 关闭有什么好处?增加索引速度、减少磁盘占用空间
  • 关闭会有什么问题?如果后续需要重新打开,则需要重建索引
  • 什么时候需要关闭?明确不需要做排序及聚合分析

排序与相关性

  • sort将目标字段转换为排序所需的格式,date 字段的值表示为自 epoch (January 1, 1970 00:00:00 UTC)以来的毫秒数,通过 sort 字段的值进行返回。
  • 如果字段是一个数组(多值),可以使用 mode 指定其中 min 、 max 、 avg 或是 sum 进行排序;
  • 评分的计算方式取决于 查询类型 不同的查询语句用于不同的目的: fuzzy 查询会计算与关键词的拼写相似程度,terms 查询会计算 找到的内容与关键词组成部分匹配的百分比,但是通常我们说的 relevance 是我们用来计算全文本字段的值相对于全文本检索词相似程度的算法,Elasticsearch 的相似度算法 被定义为检索词频率/反向文档频率(TF/IDF),包括以下内容:
    • 检索词频率
        检索词在该字段出现的频率?出现频率越高,相关性也越高。 字段中出现过 5 次要比只出现过 1 次的相关性高。
    • 反向文档频率
        每个检索词在索引中出现的频率?频率越高,相关性越低。检索词出现在多数文档中会比出现在少数文档中的权重更低。
    • 字段长度准则
        字段的长度是多少?长度越长,相关性越低。 检索词出现在一个短的 title 要比同样的词出现在一个长的 content 字段权重更大。
  • 单个查询可以联合使用 TF/IDF 和其他方式,比如短语查询中检索词的距离或模糊查询里的检索词相似度。如果多条查询子句被合并为一条复合查询语句 ,比如 bool 查询,则每个查询子句计算得出的评分会被合并到总的相关性评分中。
  • 字符串索引后(analusis)会有变化,排序时希望使用原字段(not_analyzed)进行排序,我们想要对同一个字段索引两次,而不是在_source 中保存两份字符串字段,这可以通过为字段添加一个 not_analyzed 子字段来实现:主字段用于搜索、子字段用于排序:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    "tweet": { 
    "type": "string",
    "analyzer": "english",
    "fields": {
    "raw": {
    "type": "string",
    "index": "not_analyzed"
    }
    }
    }
    "sort" : {
    "date": {
    "order": "desc",
    "mode": "min"
    }
    }
  • 默认情况下,返回结果是按相关性倒序排列的
  • 可以在查询参数中加入 explain 参数解释排序结果
    1
    2
    3
    4
    GET /_search?explain 
    {
    "query" : { "match" : { "tweet" : "honeymoon" }}
    }

分页

几种分页方式及应用场景

  • Regular
    平时查询ES只会返回头部的10条数据,一般用于实时获取顶部的部分文档,例如查询最新的订单。
  • Scroll
    需要全部文档时,例如导出全部数据
  • Pagination
    from + size的方式
    如果需要深度分页,则选用Search After

from 和 size(分页)、Search After

ES中的分页是从每个分片上获取from + size条数据,然后协调节点聚合所有结果,再选取前from + size条数据。
因为是from + size,所以from特别大时会有深分页问题
解决办法是Search After

1
2
3
4
5
6
7
8
9
10
11
12
POST users/_search
{
"size": 1,
"query": {
"match_all": {}
},
"search_after": [13, "idididid"],
"sort": [
{"age": "desc"},
{"_id": "asc"}
]
}

缺点是:

  • 不支持指定页数(From)
  • 只能往下翻

需要指定搜索sort:

  • 需要保证值是唯一的,可以加入_id保证唯一性
  • 每次查询使用上一次查询得到的最后一个文档的sort值进行查询(即上边的13和”idididid”)

Search After会通过唯一排序值定位,将每次要处理的文档数都控制在size个。

游标查询 Scroll

scroll 查询 可以用来对 Elasticsearch 有效地执行大批量的文档查询,而又不用付出深度分页那种代价。
游标查询允许我们 先做查询初始化,然后再批量地拉取结果。 这有点儿像传统数据库中的 cursor
游标查询会取某个时间点的快照数据。 查询初始化之后索引上的任何变化会被它忽略。 它通过保存旧的数据文件来实现这个特性,结果就像保留初始化时的索引 视图 一样。
深度分页的代价根源是结果集全局排序,如果去掉全局排序的特性的话查询结果的成本就会很低。 游标查询用字段 _doc 来排序。 这个指令让 Elasticsearch 仅仅从还有结果的分片返回下一批结果。
启用游标查询可以通过在查询的时候设置参数 scroll 的值为我们期望的游标查询的过期时间。 游标查询的过期时间会在每次做查询的时候刷新,所以这个时间只需要足够处理当前批的结果就可以了,而不是处理查询结果的所有文档的所需时间。 这个过期时间的参数很重要,因为保持这个游标查询窗口需要消耗资源,所以我们期望如果不再需要维护这种资源就该早点儿释放掉。 设置这个超时能够让 Elasticsearch 在稍后空闲的时候自动释放这部分资源。

1
2
3
4
5
6
GET /old_index/_search?scroll=1m // 保持游标查询窗口一分钟。
{
"query": { "match_all": {}},
"sort" : ["_doc"], // 关键字 _doc 是最有效的排序顺序。
"size": 1000
}

这个查询的返回结果包括一个字段 _scroll_id, 它是一个 base64 编码的长字符串。现在我们能传递字段 _scroll_id_search/scroll 查询接口获取下一批结果:

1
2
3
4
5
GET /_search/scroll
{
"scroll": "1m", // 注意再次设置游标查询过期时间为一分钟。
"scroll_id" : "cXVlcnlUaGVuRmV0Y2g7NTsxMDk5NDpkUmpiR2FjOFNhNnlCM1ZDMWpWYnRROzEwOTk1OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MTA5OTM6ZFJqYkdhYzhTYTZ5QjNWQzFqVmJ0UTsxMTE5MDpBVUtwN2lxc1FLZV8yRGVjWlI2QUVBOzEwOTk2OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MDs="
}

这个游标查询返回的下一批结果。 尽管我们指定字段 size 的值为 1000,我们有可能取到超过这个值数量的文档。 当查询的时候, 字段 size 作用于单个分片,所以每个批次实际返回的文档数量最大为 size * number_of_primary_shards
当没有更多结果返回的时候,我们就处理完所有匹配的文档了。

缺点:

  • Scroll会创建一个快照,如果查询期间有新的数据写入以后,无法被查到

实践

查询订单昨天的问题订单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 查询索引
GET _cat/indices
// 查询昨天的订单中属于异常状态的订单,并取其中的部分字段
GET /mts_reg_order/_search
{
"query": {
"bool": {
"must": {
"match": {
"order_status": "SUSPEND"
}
},
"filter": {
"range": {
"create_date": {
"gt": "2020-09-23 00:00:00",
"lt": "2020-09-24 00:00:00"
}
}
}
}
},
"_source": ["order_id", "hos_code"],
"size": 100
}

多条件查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 这个语句的特点是must中放置了多个模糊匹配条件
GET /mts_fee_order/_search
{
"query" : {
"bool": {
"must": [
{"match": {"order_status": "SUSPEND"}},
{"match": {"pay_status": "PAY_SUCCESS"}}
],
"filter": {
"range": {
"create_date": { "gt": "2020-09-23 15:00:00",
"lt": "2020-09-24 15:00:00"}
}
}
}
},
"_source": ["order_id", "hos_code"],
"size": 100
}

Search源码流程

GET请求只能查询单个文档,通过_index、_type、_id三元组来确定唯一文档。
而Search在查到匹配的文档之后,还需要将从多个分片上得到的结果组合成单个排序列表,这个过程在协调节点上执行,被称为query then fetch

  • 索引的所有分片(中的某个副本)都需要参与搜索,因为查询的时候不知道文档位于哪个分片;
  • 协调节点需要将结果合并,再根据ID获取文档内容。

建立索引和搜索的过程(单机视角)

ES中的搜索主要分为精确和全文两类:

  • 精确值:比如日期、用户ID、IP地址等字段的完全匹配;
    对精确值的搜索遵循“要么匹配、要么不匹配的原则”。
  • 全文:对文本内容的模糊搜索,比如一条日志、邮件的内容等。
    全文搜索不能确定目标是不是自己要搜索的,而是只能给出一个相似度的评分,评分越高,越有可能是自己要找的文档。

在ES中执行搜索需要建立索引执行搜索这两个步骤:
ES的索引和搜索
在ES中建立索引和执行搜索的大致流程如上图所示。

这个过程比较关注单机视角下的文档处理,因此对最后两步的查找匹配文档相关性得分进行了省略,这部分需要在下面的分布式搜索流程中再作分析。

建立索引

如果是全文数据,则对文本内容进行分析,这项工作在 ES 中由分析器实现。分析器实现如下功能:

  • 字符过滤器。主要是对字符串进行预处理,例如,去掉HTML,将&转换成and等。
  • 分词器(Tokenizer)。将字符串分割为单个词条,例如,根据空格和标点符号分割,输出的词条称为词元(Token)。
  • Token过滤器。根据停止词(Stop word)删除词元,例如,and、the等无用词,或者根据同义词表增加词条,例如,jump和leap。
  • 语言处理。对上一步得到的Token做一些和语言相关的处理,例如,转为小写,以及将单词转换为词根的形式。语言处理组件输出的结果称为词(Term)。分析完毕后,将分析器输出的词(Term)传递给索引组件,生成倒排和正排索引,再存储到文件系统中。

执行搜索

搜索依赖Lucene完成,对于全文搜索:

  • 对检索字段使用建立索引时相同的分析器进行分析,产生Token列表(词法分析);
  • 根据查询语句的语法规则转换成一棵语法树(语法分析);
  • 查找符合语法树的文档;
  • 对匹配到的文档列表进行相关性评分,评分策略一般使用TF/IDF;
  • 根据评分结果进行排序。

分布式搜索过程(集群视角)

ES中的search请求必须询问所请求的索引中的所有分片的某个副本来得到结果。比如一个索引有5个主分片,每个主分片有1个副分片,则一次搜索会访问共5个分片,但并不一定都是主分片。
search请求由协调节点接收请求,转发到数据节点查询结果,并最终在协调节点中合并结果返回。
搜索相关类:

  • InitialSearchPhase(Query流程)
  • FetchSearchPhase(Fatch流程)

搜索流程中最重要的就是上面两个类,分别负责Query和Fetch流程。

Query流程

代码入口:InitialSearchPhase#run
1、查询广播到索引中每一个分片副本(主分片或副分片)
将请求体解析为SearchRequest
RestSearchAction#prepareRequest
构建本节点的shard列表和远程节点的shard列表,即localShardsIteratorremoteShardIterators,最终通过。
TransportSearchAction#executeSearch
merge过的shard列表传给RestSearchAction的构造
TransportSearchAction#searchAsyncAction
遍历所有shard发送请求
InitialSearchPhase#run

1
2
3
4
5
for (int index = 0; index < maxConcurrentShardRequests; index++) {
final SearchShardIterator shardRoutings = shardsIts.get(index);
assert shardRoutings.skip() == false;
performPhaseOnShard(index, shardRoutings, shardRoutings.nextOrNull());
}

最终需要根据执行情况来判断是否要进入到下一阶段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
executePhaseOnShard(shardIt, shard, new SearchActionListener<FirstResult>(new SearchShardTarget(shard.currentNodeId(),
shardIt.shardId(), shardIt.getClusterAlias(), shardIt.getOriginalIndices()), shardIndex) {
@Override
public void innerOnResponse(FirstResult result) {
maybeFork(thread, () -> onShardResult(result, shardIt));
}

@Override
public void onFailure(Exception t) {
maybeFork(thread, () -> onShardFailure(shardIndex, shard, shard.currentNodeId(), shardIt, t));
}
});

private void onShardResult(FirstResult result, SearchShardIterator shardIt) {

onShardSuccess(result);

successfulShardExecution(shardIt);
}

private void successfulShardExecution(SearchShardIterator shardsIt) {
...
// 计数器累加
final int xTotalOps = totalOps.addAndGet(remainingOpsOnIterator);
// 检查是否收到全部回复
if (xTotalOps == expectedTotalOps) {
// 调用executeNextPhase,从而开始执行取回阶段
onPhaseDone();
} else if (xTotalOps > expectedTotalOps) {
throw new AssertionError("unexpected higher total ops [" + xTotalOps + "] compared to expected ["
+ expectedTotalOps + "]");
} else if (shardsIt.skip() == false) {
maybeExecuteNext();
}
}

2、每个分片在本地执行查询,并使用本地的Term/Document Frequency信息进行打分,添加结果到大小为from+size的本地有序优先队列中;
优先队列是一个存有topN匹配文档的有序列表。优先队列大小为分页参数from + size。
TODO
3、每个分片返回各自优先队列中所有文档的ID和排序值给协调节点,协调节点合并这些值到自己的优先队列中,产生一个全局排序后的列表。
为了避免在协调节点中创建的number_of_shards * (from + size)优先队列过大,应尽量控制分页深度。
TODO

Fetch阶段

Fetch阶段的主要目的是通过文档ID获取完整的文档内容。
1、协调节点向相关节点发送GET请求(即根据ID获取文档);
由上面对Query阶段的分析可知收到所有节点的回复后协调节点会触发Fetch流程(executeNextPhase)。
FetchSearchPhase#run
遍历查询阶段的shard列表,跳过查询结果为空的shard,对特定目标执行executeFetch获取数据。
FetchSearchPhase#innerRun

1
2
3
4
5
6
7
8
9
10
for (int i = 0; i < docIdsToLoad.length; i++) {
...
SearchShardTarget searchShardTarget = queryResult.getSearchShardTarget();
Transport.Connection connection = context.getConnection(searchShardTarget.getClusterAlias(),
searchShardTarget.getNodeId());
ShardFetchSearchRequest fetchSearchRequest = createFetchRequest(queryResult.queryResult().getRequestId(), i, entry,
lastEmittedDocPerShard, searchShardTarget.getOriginalIndices());
executeFetch(i, searchShardTarget, counter, fetchSearchRequest, queryResult.queryResult(),
connection);
}

executeFetch执行查询请求,并在获取后调用countDown。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void executeFetch(final int shardIndex, final SearchShardTarget shardTarget,
final CountedCollector<FetchSearchResult> counter,
final ShardFetchSearchRequest fetchSearchRequest, final QuerySearchResult querySearchResult,
final Transport.Connection connection) {
context.getSearchTransport().sendExecuteFetch(connection, fetchSearchRequest, context.getTask(),
new SearchActionListener<FetchSearchResult>(shardTarget, shardIndex) {
@Override
public void innerOnResponse(FetchSearchResult result) {
counter.onResult(result);
}

@Override
public void onFailure(Exception e) {
try {
logger.debug(() -> new ParameterizedMessage("[{}] Failed to execute fetch phase", fetchSearchRequest.id()), e);
counter.onFailure(shardIndex, shardTarget, e);
} finally {
// the search context might not be cleared on the node where the fetch was executed for example
// because the action was rejected by the thread pool. in this case we need to send a dedicated
// request to clear the search context.
releaseIrrelevantSearchContext(querySearchResult);
}
}
});
}

2、分片所在节点接收到请求后,向协调节点返回数据;
3、协调节点等待所有文档获取完毕后,返回给客户端。
收集器定义如下,包括收到的shard数据存放在哪里,及收到完成后谁来处理:
FetchSearchPhase#innerRun

1
2
3
final CountedCollector<FetchSearchResult> counter = new CountedCollector<>(r -> fetchResults.set(r.getShardIndex(), r),
docIdsToLoad.length, // we count down every shard in the result no matter if we got any results or not
finishPhase, context);

每有一个shard执行完毕就执行一次counter.countDown()

1
2
3
4
5
6
void countDown() {
assert counter.isCountedDown() == false : "more operations executed than specified";
if (counter.countDown()) {
onFinish.run();
}
}

最后一个执行完毕后,执行finishPhase:

1
2
3
final Runnable finishPhase = ()
-> moveToNextPhase(searchPhaseController, scrollId, reducedQueryPhase, queryAndFetchOptimization ?
queryResults : fetchResults);

moveToNextPhase开始执行下一个阶段,可以看到在FetchSearchPhase的构造函数中构建了下一个需要执行的Phase,即ExpandSearchPhase

1
2
3
4
5
6
7
FetchSearchPhase(InitialSearchPhase.SearchPhaseResults<SearchPhaseResult> resultConsumer,
SearchPhaseController searchPhaseController,
SearchPhaseContext context) {
this(resultConsumer, searchPhaseController, context,
(response, scrollId) -> new ExpandSearchPhase(context, response, // collapse only happens if the request has inner hits
(finalResponse) -> sendResponsePhase(finalResponse, scrollId, context)));
}

4、ExpandSearchPhase
判断是否启用字段折叠,然后处理折叠,并将处理后的结果返回给客户端。
5、执行完毕后,调用sendResponsePhase回复客户端。

执行搜索的数据节点流程

上面Query阶段和Fetch阶段都涉及到搜索流程,Query是根据条件过滤文档,Fetch是根据过滤后的结果取对应的文档内容。
1、注册搜索处理器
SearchTransportService#registerRequestHandler

1
org.elasticsearch.action.search.SearchTransportService#registerRequestHandler

2、响应Query请求
调用Lucene执行搜索,及聚合结果
Service#executeQueryPhase
3、响应Fetch请求
即GET操作

QA

  1. 为什么搜索是近实时的?
  2. 为什么文档的 CRUD (创建-读取-更新-删除) 操作是 实时 的?
  3. Elasticsearch 是怎样保证更新被持久化在断电时也不丢失数据?
  4. 为什么删除文档不会立刻释放空间?
  5. refresh, flush, 和 optimize API 都做了什么, 你什么情况下应该使用他们?

相同查询多次执行结果不同

有可能是Bouncing Results问题,也有可能是主分片发生了**强制段合并(forcemerge)**,导致请求落到不同分片上得到的结果得分不同(Elasticsearch:执行同样的查询语句多次结果不一致?!)。

参考

文档

  1. Elasticsearch: 权威指南
  2. Elasticsearch Reference
  3. REST APIs

索引、映射和文档

  1. Elasticsearch 索引的父子关系(index parent-child)
  2. Elasticsearch Versioning Support

搜索

  1. 深入搜索

集群

  1. 扩容设计

源码

  1. elasticsearch 源码编译问题