【Elastic Engineering】Elasticsearch:分页搜索结果

作者:刘晓国


在我之前的文章:


Elasticsearch:运用 scroll 接口对大量数据实现更好的分页

Elasticsearch:运用 search_after 来进行深度分页


我讲述了如何针对大量搜索结果进行分页的描述。随着时间点 API(Point in time API)的推出,根据 Elastic 的官方博客 “使用 Elasticsearch 时间点读取器获得随时间推移而保持一致的数据视图”,Scroll 接口将不被推荐作为对搜索结果的分页。


默认情况下,搜索会返回前 10 个匹配的匹配项。 要翻阅更大的结果集,你可以使用搜索 API 的 from 和 size 参数。 from 参数定义要跳过的命中数,默认为 0。 size 参数是要返回的最大命中数。 这两个参数共同定义了一页结果。比如:

GET /twitter/_search
{
  "from": 5,
  "size": 20,
  "query": {
    "match": {
      "city": "北京"
    }
  }
}

避免使用 from 和 size 来分页太深或一次请求太多结果。 搜索请求通常跨越多个分片。 每个分片必须将其请求的命中和任何先前页面的命中加载到内存中。 对于深页面或大型结果集,这些操作会显着增加内存和 CPU 使用率,从而导致性能下降或节点故障。这里的原因是 index.max_result_window 的默认值是 10K,也就是说 from+size 的最大值是1万。搜索请求占用堆内存和时间与 from+size 成比例,这限制了内存。假如你想 hit 从 990 到 1000,那么每个 shard 至少需要 1000 个文档:

【Elastic Engineering】Elasticsearch:分页搜索结果

默认情况下,你不能使用 from 和 size 来翻阅超过 10,000 次点击。 此限制是由 index.max_result_window 索引设置设置的保护措施。 如果你需要翻阅超过 10,000 次点击,请改用 search_after 参数。


警告:Elasticsearch 【Elastic Engineering】Elasticsearch:分页搜索结果使用 Lucene 的内部文档 ID 作为 tie_breaker。 这些内部文档 ID 可以在相同数据的副本之间完全不同。 当分页搜索命中时,你可能偶尔会看到具有相同排序值的文档排序不一致。


Search after


你可以使用 search_after 参数使用上一页中的一组 sort values 来检索下一页的命中。


使用 search_after 需要具有相同查询和排序值的多个搜索请求。 如果在这些请求之间发生刷新,结果的顺序可能会发生变化,从而导致跨页面的结果不一致。 为防止出现这种情况,你可以创建一个时间点 (PIT) 以保留搜索中的当前索引状态。

POST /my-index-000001/_pit?keep_alive=1m

上面的命令返回一个 PIT id:

{
  "id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA=="
}

要获取第一页结果,请提交带有排序参数的搜索请求。 如果使用 PIT,请在 pit.id 参数中指定 PIT id,并从请求路径中省略目标数据流或索引。


重要:所有 PIT 搜索请求都添加了一个名为 _shard_doc 的隐式排序 tiebreaker 字段,该字段也可以显式提供。 如果 你不能使用 PIT,我们建议你在排序中包含一个 tiebreaker 字段。 此 tiebreaker 字段应包含每个文档的唯一值。 如果你不包含 tiebreaker 字段,你的分页结果可能会丢失或重复命中。


注意:当排序顺序为 _shard_doc 且未跟踪总命中数(total hits)时,请求后搜索进行了优化,使它们更快。 如果你想遍历所有文档而不考虑顺序,这是最有效的选择。


重要:如果排序字段在某些目标数据流或索引中是 date,但在其他目标中是 date_nanos 字段,请使用 numeric_type 参数将值转换为单一分辨率,并使用 format 参数为排序字段指定日期格式。 否则,Elasticsearch 将无法正确解释每个请求中的 search after 参数。

GET /_search
{
  "size": 10000,
  "query": {
    "match": {
      "user.id": "elkbee"
    }
  },
  "pit": {
    "id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA==",
    "keep_alive": "1m"
  },
  "sort": [
    {
      "@timestamp": {
        "order": "asc",
        "format": "strict_date_optional_time_nanos",
        "numeric_type": "date_nanos"
      }
    }
  ]
}

在上面,我们使用 pit.id 来进行搜索。在 sort 里,在 _shard_doc 升序上使用隐式 tiebreaker 对搜索的命中进行排序。


搜索响应包括每个命中的 sort 值数组。 如果你使用了 PIT,则将包含一个 tiebreaker 作为每个命中的最后一个排序值。 这个名为 _shard_doc 的 tiebreaker 会自动添加到使用 PIT 的每个搜索请求中。 _shard_doc 值是 PIT 中的分片索引和 Lucene 的内部文档 ID 的组合,它在每个文档中是唯一的,并且在 PIT 中是常量。 你还可以在搜索请求中显式添加 tiebreaker 以自定义顺序:

GET /_search
{
  "size": 10000,
  "query": {
    "match": {
      "user.id": "elkbee"
    }
  },
  "pit": {
    "id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA==",
    "keep_alive": "1m"
  },
  "sort": [
    {
      "@timestamp": {
        "order": "asc",
        "format": "strict_date_optional_time_nanos"
      }
    },
    {
      "_shard_doc": "desc"
    }
  ]
}

在上面,我们是有 pit.id 来进行搜索。同时,我们在 _shard_doc 降序上使用显式 tiebreaker 对搜索进行排序。

{
  "pit_id" : "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA==", 
  "took" : 17,
  "timed_out" : false,
  "_shards" : ...,
  "hits" : {
    "total" : ...,
    "max_score" : null,
    "hits" : [
      ...
      {
        "_index" : "my-index-000001",
        "_id" : "FaslK3QBySSL_rrj9zM5",
        "_score" : null,
        "_source" : ...,
        "sort" : [                                
          "2021-05-20T05:30:04.832Z",
          4294967298                              
        ]
      }
    ]
  }
}

从上面的返回结果中,我们可以看出来一个被更新的 pit.id。在 sort 里,它定义了最近返回命中的 sort 值。上面的 4294967298 是一个 tiebreaker 值。在 pit_id 中每个文档都是唯一的。


要获得下一页结果,请使用最后一次命中的排序值(包括 tiebreaker)作为 search_after 参数重新运行先前的搜索。 如果使用 PIT,请在 pit.id 参数中使用最新的 PIT ID。 搜索的查询和排序参数必须保持不变。 如果提供,则 from 参数必须为 0(默认值)或 -1。

GET /_search
{
  "size": 10000,
  "query": {
    "match": {
      "user.id": "elkbee"
    }
  },
  "pit": {
    "id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA==",
    "keep_alive": "1m"
  },
  "sort": [
    {
      "@timestamp": {
        "order": "asc",
        "format": "strict_date_optional_time_nanos"
      }
    }
  ],
  "search_after": [
    "2021-05-20T05:30:04.832Z",
    4294967298
  ],
  "track_total_hits": false
}


请注意:


在上面的请求中,pit.id 是上一个请求返回来的 pit.id 值

在 search_after 里定义的是是上一次请求最后一个命中返回的 sort 值

我们在这个请求中把 track_total_hits 设置为 false 来 禁用对总点击数的跟踪以加快分页速度

你可以重复此过程以获取其他页面的结果。 如果使用 PIT,你可以使用每个搜索请求的 keep_alive 参数延长 PIT 的保留期。


完成后,你应该删除 PIT。

DELETE /_pit
{
    "id" : "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA=="
}


参考:


【1】https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html

上一篇:【Elastic Engineering】Elasticsearch:运用 search_after 来进行深度分页


下一篇:Noip前的大抱佛脚----根号对数算法