分页

size:每页显示多少条
from:当前页起始索引, **int start = (pageNum - 1) * size**
from从哪里开始, size截取的数量

  1. POST /book/_search
  2. {
  3. "query": {
  4. "match_all": {}
  5. },
  6. "size": 2,
  7. "from": 0
  8. }
  9. # 先排序, 再分页
  10. POST /book/_search
  11. {
  12. "query": {
  13. "match_all": {}
  14. },
  15. "sort": [
  16. {
  17. "price": {
  18. "order": "desc"
  19. }
  20. }
  21. ],
  22. "size": 2,
  23. "from": 2
  24. }

分布式搜索流程

整体来说,ES 的搜索过程可以分为两个阶段:

  • Query 阶段:查询阶段,主要获取文档的排序值和文档 ID 到协调节点。并且协调节点通过排序确定要获取的文档 ID 列表。
  • Fetch 阶段:通过 multi get 的方式到对应的分片上获取文档数据。

因此,接下来我们就来详细剖析下这两个阶段,除此之外,还会讲解下由 Query 和 Fetch 这种方式带来的深度分页和相关性算法偏离的问题。

Query 阶段

Query 阶段会根据搜索条件遍历每个分片(主分片或者副分片中的其一)中的数据,返回符合条件的前 N 条数据的 ID 和排序值,然后在协调节点中对所有分片的数据进行排序,获取前 N 条数据的 ID。
image.png
Query 阶段的流程如上图,其中 1、2、3 步解析如下:

  1. 客户端发起 search 请求到 Node1;
  2. 协调节点 Node1 将查询请求转发到索引的每个主分片或者副分片中,每个分片执行本地查询并且将查询结果打分排序,然后将 from + size 个结果保存到 from + size 大小的有序队列中。
  3. 每个分片将查询结果返回到 Node1(协调节点)中,Node1 对所有结果进行排序,并且把排序后结果放到一个全局的排序列表中。

需要注意的是,在协调节点转发搜索请求的时候,如果有 N 个 Shard 位于同一个节点时,并不会合并这些请求,而是发生 N 次请求!

Fetch 阶段

在 Fetch 阶段,协调节点会从 Query 阶段产生的全局排序列表中确定需要取回的文档 ID 列表,然后通过路由算法计算出各个文档对应的分片,并且用 multi get 的方式到对应的分片上获取文档数据。
image.png
Fetch 阶段的流程如上图,其中 1、2、3 步解析如下:

  1. 协调节点(Node1)确定哪些文档需要获取,然后向相关节点发起 multi get 请求;
  2. 分片所在节点读取文档数据,并且进行 _source 字段过滤、处理高亮参数等,然后把处理后的文档数据返回给协调节点;
  3. 协调节点等待所有数据被取回后返回给客户端。

Query + Fetch 的方式看似很合理,但也会产生一些问题:

  • 每个分片上都要取回 from + size 个文档(不是 from 到 size,而是 from + size);
  • 协调节点需要处理 shard_amount * ( from + size ) 个文档。

不知道其他分片上, 能读取多少, 如果自己只返回from 到 size, 可能结果数据都不在里面.
然后这些数据都汇总到协调者节点中

深度分页的问题

ES 默认采用的分页方式是 from+ size 的形式,类似于mysql的分页limit。当请求数据量比较大时, Elasticsearch会对分页做出限制,因为此时性能消耗会很大。举个例子,一个索引 分10个 shards,然 后,一个搜索请求,from=990,size=10,这时候,会带来严重的性能问题:
CPU, 内存, IO, 网络带宽

image.png
CPU、内存和IO消耗容易理解,网络带宽问题稍难理解一点。在 query 阶段,每个shard需要返回 1000条数据给 coordinating node,而 coordinating node 需要接收 10*1000 条数据,即使每条数据 只有 _doc _id 和 _score,这数据量也很大了,而且,这才一个查询请求,那如果再乘以100呢?

es中有个设置 index.max_result_window ,默认是10000条数据,如果分页的数据超过第1万条,就拒 绝返回结果了。如果你觉得自己的集群还算可以,可以适当的放大这个参数,比如100万。

我们意识到,有时这种深度分页的请求并不合理,因为我们是很少人为的看很后面的请求的,在很多的 业务场景中,都直接限制分页,比如只能看前100页。

不过,这种深度分页确实存在,比如有1千万粉丝的微信大V,要给所有粉丝群发消息,或者给某省粉丝 群发,这时候就需要取得所有符合条件的粉丝,而最容易想到的就是利用 from + size 来实现,但这是 不现实的,我们需要使用下面的解决方案。

分页条数限制

分页太多会报错

  1. "error": {
  2. "root_cause": [
  3. {
  4. "type": "query_phase_execution_exception",
  5. "reason": "Result window is too large, from + size must be less than or equal to: [1000000] but was [1000000099]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting."
  6. }
  7. ]

可以控制修改

PUT /index/_settings
{ "index.max_result_window" :"1000000"}

经过测试,在match_all的情况下,当from值>20w的时候查询的速度将会超过1s(1核2g)

解决深度分页

利用 scroll 遍历方式

scroll 分为初始化和遍历两步,初始化时将所有符合搜索条件的搜索结果缓存起来,可以想象成快照, 在遍历时,从这个快照里取数据,也就是说,在初始化后对索引插入、删除、更新数据都不会影响遍历结果。因此,scroll 并不适合用来做实时搜索,而更适用于后台批处理任务,比如群发
没有from参数
尽量不要使用复杂的sort条件,使用_doc最高效
初始化

POST /book/_search?scroll=1m&size=2 
{ 
    "query": { "match_all": {}} 
}

初始化时需要像普通 search 一样,指明 index 和 type (当然,search 是可以不指明 index 和 type 的),然后,加上参数 scroll,表示暂存搜索结果的时间,其它就像一个普通的search请求一样。
scroll=1m表示该scroll快照的有效时间

初始化返回一个scroll_id,scroll_id用来下次取数据用。

遍历

GET /_search/scroll 
{ 
    "scroll": "1m",
    "scroll_id" : "步骤1中查询出来的值"
}

这里的 scroll_id 即 上一次遍历取回的 _scroll_id 或者是初始化返回的 _scroll_id,同样的,需要带 scroll 参数。 重复这一步骤,直到返回的数据为空,即遍历完成。注意,每次都要传参数 scroll,刷新搜索结果的缓存时间。另外,不需要指定 index 和 type。设置scroll的时候,需要使搜索结果缓存到下 一次遍历完成,同时,也不能太长,毕竟空间有限

清理scroll

过多的调用scroll会产生大量的内存, 可以通过clear api删除过多的scroll快照

DELETE /_search/scroll
{
    "scroll_id": [
      "ASD8DHQN22..",
    "JBW245GF9.."
  ]
}

DELETE /_search/scroll/_all

search after方式

满足实时获取下一页的文档信息,search_after 分页的方式是根据上一页的最后一条数据来确定下一页的
只能上一页, 下一页, 不能使用from参数, 不能指定页数

位置,同时在分页请求的过程中,如果有索引数据的增删改,这些变更也会实时的反映到游标上,这种 方式是在es-5.X之后才提供的。为了找到每一页最后一条数据,每个文档的排序字段必须有一个全局唯 一值 使用 _id 就可以了。

GET /book/_search 
{
    "query":{ 
        "match_all": {} 
    }, 
    "size":2, 
    "sort":[
        {
            "_id": "desc"
        } 
    ]
}
GET /book/_search 
{
    "query":{ 
        "match_all": {} 
    }, 
    "size":2, 
    "search_after":[3],
    "sort":[
        {
            "_id": "desc"
        } 
    ]
}

下一页的数据依赖上一页的最后一条的信息 所以不能跳页。

原理就是用了唯一的排序值, 返回在这个值后面的文档就行了
image.png

Point In Time

Point In Time(PIT)是 ES 7.10 中引入的新特性,PIT 是一个轻量级的数据状态视图,用户可以利用这个视图反复查询某个索引,仿佛这个索引的数据集停留在某个时间点上。也就是说,在创建 PIT 之后更新的数据是无法被检索到的。
当我们想要获取、统计以当前时间节点为准的数据而不考虑后续数据更新的时候,PIT 就显得非常有用了。使用 PIT 前需要显式使用 _pit API 获取一个 PID ID:

# 使用 pit API 获取一个 PID ID
POST /books/_pit?keep_alive=20m

# 结果
{
  "id": "46ToAwMDaWR5BXV1aWQy......=="
}

如上示例,使用 _pit 接口获取了一个 PIT ID,keep_alive 参数设置了这个视图的有效时长。有了这个 PIT ID 后续的查询就可以结合它来进行了。
PIT 可以结合 search after 进行查询,能有效保证数据的一致性。 PIT 结合 search after 的流程与前面介绍的 search after 差不多,主要区别是需要在请求 body 中带上 PIT ID,其示例如下:

# 第一次调用 search after,因为使用了 PIT,这个时候搜索不需要指定 index 了。
POST _search
{
  "size": 2,
  "query": { "match_all": {} },
  "pit": {
    "id":  "46ToAwMDaWR5BXV1aWQy......==", # 添加 PIT id
    "keep_alive": "5m" # 视图的有效时长
  },
  "sort": [
    { "price": "desc" } # 按价格倒序排序
  ]
}

# 结果
{
  "pit_id" : "46ToAwMDaWR5BXV1aWQy......==",
  "hits" : {
    "hits" : [
      {
        "_id" : "6",
        "_source" : {
          "book_id" : "4ee82467",
          "price" : 20.9
        },
        "sort" : [20.9, 8589934593]
      },
      {
        "_id" : "1",
        "_source" : {
          "book_id" : "4ee82462"
          "price" : 19.9
        },
        "sort" : [19.9, 8589934592]
      }
    ]
  }
}

如上示例,在 pit 字段中指定 PIT ID 和设置 keep_alive 来指定视图的有效时长。需要注意的是,使用了 PIT 后不再需要在 sort 中指定唯一的排序值了,也不需要在路径中指定索引名称了。
在其返回结果中,sort 数组中包含了两个元素,其中第一个是我们用作排序的 price 的值,第二个值是一个隐含的排序值。所有的 PIT 请求都会自动加入一个隐式的用于排序的字段称为:_shard_doc,当然这个排序值可以显式指定。这个隐含的字段官方也称它为:tiebreaker(决胜字段),其代表的是文档的唯一值,保证了分页不会丢失或者分页结果的数据不会重复,其作用就好像原 search after 的 sort 字段中要指定的唯一值一样。

在进行翻页的时候和原 search after 一样,需要把上次结果中最后一个文档的 sort 值带上:

# 第二次调用 search after,因为使用了 PIT,这个时候搜索不需要指定 index 了。
POST _search
{
  "size": 2,
  "query": {
    "match_all": {}
  },
  "pit": {
    "id":  "46ToAwMDaWR5BXV1aWQy......==", # 添加 PIT id
    "keep_alive": "5m" # 视图的有效时长
  },
  "search_after": [19.9, 8589934592], # 上次结果中最后一个文档的 sort 值
  "sort": [
    { "price": "desc" }
  ]
}

search after + PIT 实现的功能似乎和 scroll API 类似,那它们间有啥区别呢?其实你会发现使用 scroll API 的时候,scroll 产生的上下文是与本次查询绑定的,很明显的一点就是,生成一个 scroll id 后,其他查询无法重用这个 id,scroll 的翻页也只能一直向下翻。而 PIT 可以允许用户在同一个固定数据集合上运行不同的查询,例如多个请求可以使用同一个 PIT 视图而互不影响

应用场景

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