一、ES 搜索分页机制

1.1 分页查询

ES在作为数据库查询时,少不了使用ES的分页功能。由于ES是一个分布式的文档存储系统,生产环境中,通常使用的是ES集群对应用提供搜索服务,在集群中,一个索引的数据会被分布在不同的shard上,而不同的分片又会被分布在不同的节点上,搜索某一个索引中的数据时,如果涉及到分页操作,ES就会将不同节点上被搜索的索引对应的数据取出来,作为一个全局的结果集,然后对这个全局结果集使用相关度分数排序,并按照分页的参数取出特定页码上的数据,最后返回。Elasticsearch分页搜索采用的是from+size。from表示查询结果的起始下标,size表示从起始下标开始返回文档的个数。

coordinate node向该index的其余的shards 发送同样的请求,等汇总到 (shards * (from + size)) 条数时在coordinate node再做一次排序,最终抽取出真正的 from 后的 size 条结果。size:显示应该返回的结果数量,默认是 10from:显示应该跳过的初始结果数量,默认是 0

1、Query阶段

  • 收到用户请求,交由(coordinating node)协调者节点
  • coordinating node 随机选择所有分片/副本(保证所有都覆盖到),发送search request
  • 被选中的分片分别执行查询并排序,每个查询分片都返回from+size个文档id和相关值的数据。
  • coordinating node 整合数据,根据总体分片数据的相关值再取from+size的文档id

2、Fetch阶段

  • 通过Query阶段拿到总体排序后需要的文档Id列表,随后去对应的shard上获取文档详情数据
  • coordinating node 向相关分片发送multi_get请求
  • 各个分片返回文档详细数据
  • coordinating node拼接结果并返回给客户
GET /index_name/indexType/_search?size=N
GET /index_name/indexType/_search?size=N&from=pageNo
GET /test/_search?from=0&size=1

{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : {
          "field1" : "value1",
          "field2" : "value2"
        }
      }
    ]
  }
}
1.2 深度分页的问题

页越深,处理文档越多,占用内存越多,耗时越长,容易OOM。避免深度分页,es通过index.max_result_window限定最多10000条数据(最多查到前10000条数据)。ES对于from+size的个数是有限制的,二者之和不能超过1w,超过1W会报错。当所请求的数据总量大于1w时,可用scroll来代替from+size。分页搜索的深度越深, 协调节点(负责分发查询、汇总结果的ES节点)上要存储的数据就越多, 协调节点对这些数据整体排序后, 再取对应页的数据. 这个过程既耗费网络资源, 也耗费内存和CPU资源。

在coordinate node汇总 shards* (from+size)条记录。当索引非常非常大(千万或亿),是无法使用from + size 做深分页的,分页越深则越容易OOM,即便不OOM,也很消耗CPU和内存资源

1.3 Search After(实时滚动查询)

一些业务场景,需要进行很深度的分页,但是可以不指定页数翻页,只要可以实时请求下一页就行。比如一些实时滚动的场景。ES为这种场景提供了一种解决方案:search after。可以避免深度分页带来的性能问题,可以实时的获取下一页文档。不支持指定页数(from起始为0),只能向下翻。需要加入排序 sort,并且排序的字段一定要是唯一的。search_after利用实时游标来帮我们解决实时滚动的问题,简单来说前一次查询的结果会返回一个唯一的字符串,下次查询带上这个字符串,进行下一页的查询。

search_after 分页的方式是根据上一页的最后一条数据来确定下一页的位置,同时在分页请求的过程中,如果有索引数据的增删改查,这些变更也会实时的反映到游标上。但是需要注意,因为每一页的数据依赖于上一页最后一条数据,所以无法跳页请求。为了找到每一页最后一条数据,每个文档必须有一个全局唯一值,官方推荐使用 _uid 作为全局唯一值。search_after会通过唯一排序的值定位,每个分片节点只会根据定位值往后查size条数据,避免了内存开销过大的问题.

  • 缺点是不能使用 from 参数,即不能指定页数,无法跳页;
  • 只能下一页,不能上一页
  • 使用简单, 数据更新后会实时滚动。
GET /test/_search
{
  "size" : 2,
  "query": {
    "bool": {
      "must": [
        {"match": {
          "customer_first_name": "Diane"
        }}
      ],
      "filter": {
        "range": {
          "order_date": {
            "gte": "2020-01-03"
          }
        }
      }
    }
  }, 
  "sort": [
    {
      "order_date": "desc",
      "_id": "asc"

    }
  ]
}

首先查询第一页数据,我这里指定取回2条,条件跟上一节一样。唯一的区别在于sort部分我多加了id,这个是为了在order_date字段一样的情况下告诉ES一个可选的排序方案。因为search after的游标是基于排序产生的。注意看查询结果的最后,有个类似下面这样的字符串返回:

"sort" : [
          13235423400,
          "sfsdfdseAsPClqbyw"
        ]

下一页的查询中,我们带上这个字符串,如下:

GET /test/_search
{
  "size" : 2,
  "query": {
    "bool": {
      "must": [
        {"match": {
          "customer_first_name": "Diane"
        }}
      ],
      "filter": {
        "range": {
          "order_date": {
            "gte": "2020-01-03"
          }
        }
      }
    }
  }, 
  "search_after": 
      [
          1580597280000,
          "RZz1f28BdseAsPClqbyw"
        ],
  "sort": [
    {
      "order_date": "desc",
      "_id": "asc"

    }
  ]
}

就这样一直操作就可以实现不断的查看下一页了。

1.4 Scroll

还有一种查询场景,我们需要一次性或者每次查询大量的文档,但是对实时性要求并不高。ES针对这种场景提供了scroll api的方案。这个方案牺牲了实时性,但是查询效率确实非常高。 scroll 的方式,官方的建议不用于实时的请求(一般用于数据导出),因为每一个 scroll_id 不仅会占用大量的资源,而且会生成历史快照,对于数据的变更不会反映到快照上。

scroll 查询 可以用来对 Elasticsearch 有效地执行大批量的文档查询,而又不用付出深度分页那种代价。游标查询允许我们先做查询初始化,然后再批量拉取结果。 这有点儿像传统数据库中的 cursor 。在查询性能上,Scroll的翻页方式,时间复杂度O(1),空间复杂度O(1)。SearchScroll能够以恒定的速度翻页获取完所有数据,而采用SearchAfter的方式获取数据会随翻页深度增大而吞吐能力大幅下降。

from+size在ES查询数据的方式步骤如下:

  • 1、先将用户指定的关键字进行分词;
  • 2、将词汇去分词库中进行检索,得到多个文档的id;
  • 3、去各个分片中拉取指定的数据,相对耗时较长;
  • 4、将数据根据score进行排序,耗时相对较长;
  • 5、根据from,size的值,截取满足条件的查询到的数据;
  • 6、返回结果;

优点:每次都能获取到最新的记录;
缺点:同一个查询,展示另一页的from+size时,以上步骤需要再来一遍;

scoll+size在ES查询数据的方式:

  • 1、先将用户指定的关键字进行分词;
  • 2、将词汇去分词库中进行检索,得到多个文档的id;
  • 3、将文档的id存放在内存的一个ES的上下文中;
  • 4、根据你指定的size的个数去ES上下文中检索指定个数的数据,拿完了数据的文档id,会从上下文中移除;
  • 5、如果需要下一页数据,直接去ES的上下文中,找后续内容;
  • 6、循环第4步,第五步,直到数据都取完了;

优点:数据缓存进了内存,速度快,同一个查询,展示另一页的scoll+size时,只需要循环4,5步;
缺点:冷加载,不适合做实时,当数据更新时,内存中的上下文id数据不会更新;

启用游标查询可以通过在查询的时候设置参数 scroll 的值为我们期望的游标查询的过期时间。 游标查询的过期时间会在每次做查询的时候刷新,所以这个时间只需要足够处理当前批的结果就可以了,而不是处理查询结果的所有文档的所需时间。 这个过期时间的参数很重要,因为保持这个游标查询窗口需要消耗资源,所以我们期望如果不再需要维护这种资源就该早点儿释放掉。 设置这个超时能够让 Elasticsearch 在稍后空闲的时候自动释放这部分资源。

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

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

GET /_search/scroll
{
    "scroll": "1m", 
    "scroll_id" : "cXVlcnlUaGVuRmV0Y2g7NTsxMDk5NDpkUmpiR2FjOFNhNnlCM1ZDMWpWYnRROzEwOTk1OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MTA5OTM6ZFJqYkdhYzhTYTZ5QjNWQzFqVmJ0UTsxMTE5MDpBVUtwN2lxc1FLZV8yRGVjWlI2QUVBOzEwOTk2OmRSamJHYWM4U2E2eUIzVkMxalZidFE7MDs="
}

后面每次滚屏都把前一个的scroll_id复制过来。注意到,后续请求时没有了index信息,size信息等,这些都在初始请求中,只需要使用scroll_id和scroll两个参数即可。很多人对scroll这个参数容易混淆,误认为是查询的限制时间。这个理解是错误的。这个时间其实指的是es把本次快照的结果缓存起来的有效时间。scroll 参数相当于告诉了 ES我们的search context要保持多久,后面每个 scroll 请求都会设置一个新的过期时间,以确保我们可以一直进行下一页操作。

使用 from and size 的深度分页,比如说 ?size=10&from=10000 是非常低效的,因为 100,000 排序的结果必须从每个分片上取出并重新排序最后返回 10 条。这个过程需要对每个请求页重复。scroll API 保持了哪些结果已经返回的记录,所以能更加高效地返回排序的结果。但是,按照默认设定排序结果仍然需要代价。深度分页的代价根源是结果集全局排序,如果去掉全局排序的特性的话查询结果的成本就会很低。 如果目的是为了遍历所有结果,而不关心结果的顺序,那么可以按_doc排序来提高性能。游标查询若用字段 doc来排序。 这个游标查询返回的下一批结果。 尽管我们指定字段 size 的值,我们有可能取到超过这个值数量的文档。 当查询的时候, 字段 size 作用于单个分片,所以每个批次实际返回的文档数量最大为size * number_of_primary_shards 。对于用户高并发访问的场景,不推荐用这种方式,scroll 更适用于批处理类的后台任务。

scroll这种方式为什么会比较高效?:ES的检索分为查询(query)和获取(fetch)两个阶段,query阶段比较高效,只是查询满足条件的文档id汇总起来。fetch阶段则基于每个分片的结果在coordinating节点上进行全局排序,然后最终计算出结果。scroll查询的时候,在query阶段把符合条件的文档id保存在前面提到的search context里。然后只需根据设置的size,在每个shard内部按照一定顺序(默认doc_id), 取回这个size数量的文档即可。 后面每次scroll分批取回只是根据scroll_id定位到游标的位置,然后抓取size大小的结果集即可。使用scroll,每次只能获取一页的内容,然后会返回一个scroll_id。根据返回的这个scroll_id可以不断地获取下一页的内容,所以scroll并不适用于有跳页的情景。

  • 缺点是不能使用 from 参数,即不能指定页数,无法跳页;
  • 数据结果缓存在快照中,实时性差,不适合实时交互。
  • 使用简单, 数据提前缓存,查询翻页速度最快。
1.5 分页机制总结:
  • from/size方案的优点是简单,缺点是在深度分页的场景下系统开销比较大,占用较多内存。
  • search after基于ES内部排序好的游标,可以实时高效的进行分页查询,但是它只能做下一页这样的查询场景,不能随机的指定页数查询。
  • scroll方案也很高效,但是它基于快照,不能用在实时性高的业务场景,建议用在类似报表导出,或者ES内部的reindex等场景。

二、相关性评分机制

相关性评分:

默认情况下,ES的搜索结果是排序的,是按相关性倒序排列的------相关性最高的排在最前面。每个文档都有相关性评分,用一个正浮点数字段 _score 来表示 。_score 的评分越高,相关性越高。查询语句会为每个文档生成一个 _score 字段。

es5.x之前默认使用TF-IDF算法打分,5.x之后默认使用BM-25算法打分

2.1 TF-IDF算法

ElasticSearch的相似度算法被定义为 TF/IDF,即检索词频率/反向文档频率,包括一下内容:

  • TF(检索词频率) : term frequency ,检索词在文档中出现的频率 => 检索词 / 文档词总数。检索词在该字段出现的频率越高,相关性也越高。比如说我们检索关键字“es”,“es”在文档A中出现了10次,在文档B中只出现了1次。我们不会认为文档B与“es”的相关性更高,而是文档A。
  • DF : document frequency ,检索词在所有文档中出现的频率。IDF : inverse document frequency , 即 log(全部文档数/检索词出现过的文档数)。每个检索词在索引中出现的频率越高,相关性越低。 常用词如 and 或 the 对相关度贡献很少,因为它们在多数文档中都会出现,一些不常见词如 elastic 或 hippopotamus 可以帮助我们快速缩小范围找到感兴趣的文档。检索词出现在多数文档中会比出现在少数文档中的权重更低, 即检验一个检索词在文档中的普遍重要性。(检索词字段在当前文档出现次数与索引中其他文档的出现总数的比率),即越罕见的词权重越高。
  • 文档字段长度准则: 文档字段的长度越长,相关性越低,文档字段越短相关性越高。 检索词出现在一个短的title 要比同样的词出现在一个长的 content字段的相关性高。短文档更容易和查询主题相吻合。
2.2 BM25算法

整体而言BM25 就是对 TF-IDF 算法的改进,对于 TF-IDF 算法,检索词频率TF(t) 部分的值越大,整个公式返回的值就会越大。 BM25 就针对这点进行来优化,随着TF(t) 的逐步加大,该算法的返回值会趋于一个数值。TF/IDF会随着关键词出现的次数得分逐渐增高,BM25随着关键词出现的次数,得分会有一个极限(用两个参数可以进行调节 k1[默认1.2],b[默认0.75])。目前ES5.0以后版本默认使用BM25。

k1:这个参数控制着词频结果在**词频饱和度中的上升速度。默认值为1.2。值越小饱和度变化越快,值越大饱和度变化越慢。
b:这个参数
控制着字段长归一值所起的作用,**0.0会禁用归一化,1.0会启用完全归一化。默认值为0.75。

es分页模糊查询 es 分页查询_es分页模糊查询