原文链接:https://zhuanlan.zhihu.com/p/609576187
Elasticsearch 是一款分布式的搜索引擎,提供了灵活的分页技术。本文主要介绍 Elasticsearch(简称 ES) 的几种分页技术,并深入分析各种分页技术的优缺点及应用场景。
介绍
搜索引擎分页是指在搜索引擎中,将搜索结果分成多个页面展示,用户可以通过点击分页按钮或滚动页面来浏览不同页数的搜索结果。搜索引擎通常会在页面底部显示数字标记或其他标记以提示用户当前的位置和可用的页数。如下所示:
搜索引擎分页的目的是提高用户体验,让用户可以更轻松地从大量搜索结果中获取更精准的信息。当用户在搜索框中输入关键字并点击搜索按钮时,搜索引擎将返回与关键字相关的成千上万个搜索结果。如果将所有这些结果显示在一个页面上,页面加载时间会变得非常长,用户体验也会变得糟糕。因此,搜索引擎将搜索结果分页,相关度越高的结果越靠前,这样用户可以更快的找到想要的结果,同时减少页面加载时间和流量消耗。
本文主要关注分页技术,对于搜索引擎其他相关技术不在本文讨论范围,下面将介绍 ES 中的分页技术。
ES 分页
在讲分页技术前,这里简单介绍下 ES 的查询流程,如图:
Query_Then_Search
这里画的是最常见 Query_Then_Fetch 查询流程,整个步骤为:
协调节点
接收查询请求协调节点
转发QueryRequest
请求给数据节点数据节点
执行QueryPhrase
,查询满足条件的TopN
文档信息(包括id、score,不包括文档内容)返回给协调节点协调节点
接收到数据节点返回的QueryResults
,然后从k*TopN
的文档信息中选出最终的TopN
的结果协调节点
发送FetchRequest
给相关数据节点数据节点
执行FetchPhrase
,根据文档id获取文档内容,然后返回给协调节点协调节点
将最终的查询结果返回给客户端
到这里,我们大体了解了 ES 的查询流程,下面来看下 ES 的分页技术。Elasticsearch 提供了三种分页技术:
From+Size
Search After
Scroll
下面详细介绍这三种分页技术并深入分析它们的实现原理。
From+Size
介绍
在 ES 中,from
和 size
是两个控制分页查询的参数。from
参数用于指定从第几个文档开始返回结果,size
参数用于指定返回的结果集的大小。
具体来说,from
参数定义了结果集的起始点,而 size
参数定义了结果集的大小。如果想返回结果集中的前 10 个文档,可以将 from
参数设置为 0,size
参数设置为 10。
以下是一个使用 from
和 size
进行分页查询的示例:
GET /my_index/_search
{
"from": 100,
"size": 10,
"query": {
"match": {
"content": "chatgpt"
}
}
}
在这个示例中,我们将 from
参数设置为 100,size
参数设置为 10,以便返回结果集中 [100,110)
共 10 个匹配 content
字段的文档,相当于返回第 11 页的结果集。那是不是我们可以在任何分页场景下都可以使用这种分页方式呢,答案是否定的,ES 对from+size
是有限制的,默认值为 10000。
那问题来了,为什么会有这样的限制呢?这个问题也是本文的重点之一。ES 官方文档给出的解释是:对于深度分页,ES 的每个 shard 都会查询 TopN(查询流程的步骤3,注意 N=from+size
)的结果,即查询 [from, from+size)
的结果实际上数据节点会查询 from+size
个结果,也就是将 [0, from)
的结果也一并查出来了,这样将会导致 CPU 和 内存的开销较大,导致系统卡顿甚至 OOM(特别是协调节点,要收集多个 shard 返回的结果,内存开销更大)。因此,from+size
常常应用于分页深度较小的场景,不适合深分页场景。
好了,那问题又来了,明明只查询 size 个结果,为什么每个 shard 偏要将 [0, from+size)
的结果都查出来呢,直接返回 [from, from+size]
的结果不就完了吗?要回答这个问题,首先要来看个简单的例子,如下:
如上图,假设有 3 个shard,每个 shard 的文档按照 value
字段逆序排序,查询的 from
设置为 2,size
设置为 3。那么按照 ES 的处理逻辑,每个 shard 都会返回 [0, 5)
的文档(注意不包含文档内容),协调节点将收到 15 条文档,然后对这 15 条文档按照 value
排序,取前 [2, 5) 的文档为结果。如上图的 result
即为正确结果。
那么如果每个 shard 只返回各自的 [2, 5) 的文档,结果将会如何呢?请看下图:
上图表示每个 shard 返回 [2, 5)
的文档集合并后的结果。很明显,这个结果是不正确的,原因便是每个 shard 并不知道全局的排序结果,因此为了保证能够得到正确的结果,必须返回 [0, 5]
的结果集,因为它们中的任意一个都可能在全局序的 [2, 5)
范围内。
实现原理
上一节介绍了 from+size
的用法和基本原理,这一节来分析下 from+size
在 ES 中是如何实现的,只有真正明白了实现原理,才能更好地掌握使用。下面分几种情况讨论:
- 不打分场景(打分的特殊场景,打分为常量)
- 打分场景
- 排序场景
第一种情况不打分场景
, DSL 如下:
{
"from": 0,
"size": 5,
"query": { // 这里如果没有 query 其实也相当于不打分,因为每个 doc 的打分都是常量 1
"bool": {
"filter": [ // filter 查询不打分
{
"term": {
"clientip.keyword": "110.47.202.158"
}
}
]
}
}
}
我们来看下上述 DSL 执行的关键流程,为了便于说明,引用部分源码:
// ContextIndexSearcher.java 该类继承自 lucene 的 IndexSearcher
protected void search(List<LeafReaderContext> leaves, Weight weight, Collector collector) throws IOException {
for (LeafReaderContext ctx : leaves) { // search each subreader
searchLeaf(ctx, weight, collector);
}
}
leaves 代表 lucene 的 segment,在该方法中会遍历查询每个 segment。本文的重点不是源码解析,这里只贴少量代码,我们直接看查询 segment 的关键代码:
// DefaultBulkScorer.scoreRange 方法部分代码
while (currentDoc < end) {
if (acceptDocs == null || acceptDocs.get(currentDoc)) {
collector.collect(currentDoc);
}
currentDoc = iterator.nextDoc();
}
这里开始遍历 segment 中的 doc,然后用 collector(shard级,所有segment共用) 取收割 doc,这里的 collector 实际是 SimpleTopScoreDocCollector
.getLeafCollector 获取的匿名类对象。collector.collect 的逻辑如下:
public void collect(int doc) throws IOException {
float score = scorer.score();
totalHits++;
hitsThresholdChecker.incrementHitCount();
if (minScoreAcc != null && (totalHits & minScoreAcc.modInterval) == 0) {
updateGlobalMinCompetitiveScore(scorer);
}
// pqTop 的doc和score初始为<2147483647, 负无穷>,只有当pq满后,才会将其挤出去
if (score <= pqTop.score) {
if (totalHitsRelation == TotalHits.Relation.EQUAL_TO) {
updateMinCompetitiveScore(scorer);
}
return;
}
// 把 doc 加入 pqTop
pqTop.doc = doc + docBase;
pqTop.score = score;
pqTop = pq.updateTop();
// 关键,更新文档所需最小的 score
updateMinCompetitiveScore(scorer);
}
所有 collector 都会维护一个 堆 pq 来收集 topN 的 doc
这里关注下方法 updateMinCompetitiveScore,当 pq 满
,下一个 doc 进来会走到第 14 行,里面会更新所需 doc 的最小 score 值为大于 pqTop.score 的第一个 float 值,而且由于是不打分场景,所以还会调用 DocIdSetIterator.empty 重新生成一个 iterator,这个 iterator 的 nextDoc 方法永远只返回一个常量 NO_MORE_DOCS(2147483647),因此外层 while 继续遍历时将终止循序。如果当前 segment 遍历完毕后,pq 还没满,那么将继续查询下一个 segment。
好了,如果查询完当前 segment,pq 已经满了,那剩下的 segment 还需要继续遍历吗?答案是否定的,原因是,查询下一个 segment 时,调用 collector.setScorer 内部会调用 updateMinCompetitiveScore 生成一个永远只返回 NO_MORE_DOCS 的 DocIdSetIterator,因此,scoreRange 里面的 while 循环永远走不进去。
通过上述的分析,from+size
在这种不打分的场景下,性能是最好的。
第二种情况打分场景
,DSL 如下:
GET kibana_sample_data_logs_1/_search
{
"from": 0,
"size": 5,
"query": {
"match": {
"message": "GET"
}
}
}
这种场景也是使用 SimpleTopScoreDocCollector
来收割 doc,所以也是走的第一种情况的 collect 方法,不同的是,当 pq 满后,还会继续遍历 doc,因为后面可能还有 doc 的 score 大于 pqTop.score。因此,打分场景需要遍历完所有 segment 的所有 doc 才能确定最终的结果。不难看出,该场景的性能会比上一种场景性能差,所以,如果没有打分需求,应该避免使用打分的 query 语句。
第三种场景排序场景
,DSL 如下:
GET kibana_sample_data_logs_1/_search
{
"from": 0,
"size": 5,
// query(打分或不打分) 省略
"sort": [
{
"bytes": {
"order": "desc"
}
}
]
}
查询时指定了排序字段,那么便需要按排序字段的顺序返回 topN 的结果。这种场景会使用 SimpleFieldCollector
来收割 doc,代码太长,这里不贴代码了,collect 的逻辑和上面场景类似,不同的是比较当前 doc 和 pqTop 时,比较的是排序字段的 docValue。
如果文档写入时不是按查询时的排序字段排序的,那么将会遍历完 shard 的所有 doc 才能得到最终结果。反之,如果文档本来是按排序字段排序的,那么查询时 collect 里面有个优化分支,当 pq 满后,会触发提前终止,裁剪掉当前 segment 剩下的 doc,查询后面的 segment 时,逻辑一样。
Search After
介绍
上一小节介绍了 from+size
的原理,也分析了它的弊端 - 即不适合深分页。好了,如果我们需要获取前 1000 页,每页 10 条文档怎么办?针对这种深分页场景,ES 提供了一种新的分页方式 – search_after
。Elasticsearch 中的 search_after
机制是一种更有效的分页方法,它可以在不加载整个数据集的情况下快速地获取下一页数据。
search_after
是一种基于游标的分页方法,使用 search_after
查询时必须指定排序字段(可以有多个),它使用排序字段值作为游标,从而能够更快地获取下一页的数据。在进行第一次搜索时,ES 会返回第一页的结果,当需要获取下一页数据时,可以使用上一页最后一个文档的排序字段值作为游标进行搜索。通过这种方式,可以逐步遍历整个数据集而无需一次性加载所有数据。
使用 search_after
机制需要注意以下几点:
- 搜索请求必须指定排序字段,用于指定搜索结果的顺序
- 搜索第一页不必指定
search_after
参数,从第二页开始必须指定search_after
为上一页的最后一个游标 - 游标必须是唯一的,否则可能会出现重复的数据
search_after
用法举例:
GET twitter/_search
{
"query": {
"match": {
"title": "elasticsearch"
}
},
"sort": [
{"date": "asc"},
{"tie_breaker_id": "asc"}
]
}
tie_breaker_id 是 _id 的拷贝,开启 doc_values 用于排序
tie_breaker_id 的目的是保证游标的唯一性,继续搜索下一页:
GET twitter/_search
{
"query": {
"match": {
"title": "elasticsearch"
}
},
"search_after": [1463538857, "654323"], // 上一页最后一个排序字段的值
"sort": [
{"date": "asc"},
{"tie_breaker_id": "asc"}
]
}
可以看到,search_after
的使用特别灵活,只要指定了游标值,便能根据游标值查询下一页文档。由于查询过程中,可能还会有数据写入,那么多次查询使用一个游标可能得到的结果不一致,如果业务有一致性需求,需要使用 point in time(PIT)
来创建一个临时的快照,查询时使用该快照保证数据一致性。
实现原理
search_after
使用 PagingFieldCollector
来收割 doc,原理和 from+size
的第三种情况类似,收割从 doc 0 开始
,不同的是,collect 时会多一次过滤,即会比较当前 doc 的排序字段值和 search_after 值的大小,如果不满足条件则直接过滤掉,也就意味着 pq 操作更少。因此,整体来看性能会更好。
当然,如果写入的数据已按排序字段排序,那么当 pq 满后,会走优化分支触发提前终止,裁剪掉当前 segment 剩下的 doc。
Scroll
介绍
上一节讲了 search_after
可以用来做深分页,ES 还提供了一种分页技术 – Scroll
查询,Scroll
查询是一种在 ES 中扫描大量数据的常用方法。它通过在搜索结果中建立一个保持状态的 scroll_id
来实现。当您开始滚动时,ES 会返回第一批结果,并返回一个保持状态的 ID。使用此 ID,可以执行下一个滚动请求,以检索下一批结果。此过程可以重复进行,直到所有数据都被扫描完毕为止。
Scoll
查询的使用例子如下:
POST /my-index-000001/_search?scroll=1m
{
"size": 100,
"query": {
"match": {
"message": "foo"
}
}
}
第一次查询要指定 scroll 参数,参数值代表 scroll 上下文的保留时长,保留时间过期后,scroll_id 将失效。接着继续查询下一批数据:
POST /_search/scroll
{
"scroll" : "1m",
"scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}
scroll 的用法很简单,除了第一次查询,后面的查询每次传入 scroll_id 便可以拉取下一页数据。由此可以看出,scroll 只能顺序往下翻页,不能往回翻。
实现原理
分三种情况讨论:
- 不带 query,不带 sort
- 带 query,不带 sort
- 带 sort
Scroll 查询会保留一个上下文 ScrollContext,上下文中包含了快照信息和上一页文档信息。
第一种情况其实就是将全量数据按存储顺序往后翻页,这种情况使用 PagingTopScoreDocCollector
来收割 doc,特殊的是收割的 doc 不再时从 0 开始,而是从上一页最后一个 doc+1 开始
,往后收割 size 个 doc 即完成本次 scoll。由此可以看出,对于这种情况,查询效率非常高。
第二种情况会使用 PagingTopScoreDocCollector
来收割 doc,收割从 doc 0 开始
,由于 Scroll 上下文保存了上一页 doc 信息(pq中存储了上一页的doc),会过滤掉分数低于 pqTop 的 doc(如果当前 doc 分数和 pqTop 分数相同,则比较 doc 值)。
第三种情况会使用 PagingFieldCollector
来收割 doc,收割从 doc 0 开始
,收割的逻辑和 search_after 类似,collect 时会比较上一页最后一个 doc 的排序字段值,如果不满足条件则过滤掉当前 doc。
小结
这节介绍了三种分页方式的概念和原理,可以得出下面结论:
from+size
适合翻页灵活且页数不大的场景,即适合浅分页场景search_after
适合深分页,也可以来回翻页(需要业务存储每页所需的search_after)scroll
可以做分页,但是只能顺序往后翻页,不够灵活。所以它最适合做大量数据的导出(对顺序无要求,scan完所有数据即可)
总结
本文主要介绍了 Elasticsearch 中三种分页方式的概念,并详细分析了它们各自的实现原理,最后分析了它们各自适合的使用场景。