作者:刘晓国
在我之前的文章:
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 个文档:
默认情况下,你不能使用 from 和 size 来翻阅超过 10,000 次点击。 此限制是由 index.max_result_window 索引设置设置的保护措施。 如果你需要翻阅超过 10,000 次点击,请改用 search_after 参数。
警告: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