ElasticSearch 深度分页详解与场景选择

617 阅读10分钟

最近APP部门又提了新需求,文章分页要改成上滑分页。我对原来的es搜索分页方案做了重新调研评估,把es中的分页方式整理一下分享给大家。

es查询流程

1、Query阶段

1700452061141.png 如上图所示,Query 阶段大致分为 3 步:

  • 第一步:Client 发送查询请求到 Server 端,Node1 接收到请求然后创建一个大小为 from + size 的优先级队列用来存放结果,此时 Node1 被称为 coordinating node(协调节点);
  • 第二步:Node1 将请求广播到涉及的 shard 上,每个 shard 内部执行搜索请求,然后将执行结果存到自己内部的大小同样为 from+size 的优先级队列里;
  • 第三步:每个 shard 将暂存的自身优先级队列里的结果返给 Node1,Node1 拿到所有 shard 返回的结果后,对结果进行一次合并,产生一个全局的队列,存在 Node1 的优先级队列中。(如上图中,Node1 会拿到 (from + size) * 3 条数据,这些数据只包含 doc 的唯一标识_id 和用于排序的_score不包括文档内容,然后 Node1 会对这些数据合并排序,选择前 from + size 条数据存到优先级队列);

2、Feach阶段

1700452216630.png 当 Query 阶段结束后立马进入 Fetch 阶段,Fetch 阶段也分为 3 步:

  • 第一步:Node1 根据刚才合并后保存在优先级队列中的 from+size 条数据的 id 集合,发送请求到对应的 shard 上查询 doc 数据详情;
  • 第二步:各 shard 接收到查询请求后,查询到对应的数据详情并返回为 Node1;(Node1 中的优先级队列中保存了 from + size 条数据的_id,但是在 Fetch 阶段并不需要取回所有数据,只需要取回从 from 到 from + size 之间的 size 条数据详情即可,这 size 条数据可能在同一个 shard 也可能在不同的 shard,因此 Node1 使用 multi-get 来提高性能)
  • 第三步:Node1 获取到对应的分页数据后,返回给 Client;

分页方式

上面介绍了es的查询流程,现在说一下es中的分页都有哪些

  • form+size分页
  • search after分页
  • scroll分页

from+size分页方式

from + size 分页方式是 ES 最基本的分页方式,类似于关系型数据库中的 limit 方式。from 参数表示:分页起始位置;size 参数表示:每页获取数据条数。

场景例举: 1700903180376.png

代码样例:

GET abc_alias/_search 
{
    "query": {
        "content": "chatgpt"
    },
    "size": 10,
    "from": 100
}

form+size深度分页问题

假设在一个有 5 个主分片的索引中搜索。 当我们请求结果的第一页(结果从 1 到 10 ),每一个分片产生前 10 的结果,并且返回给 协调节点 ,协调节点对 50 个结果排序得到全部结果的前 10 个。 现在假设我们请求第 1000 页—​结果从 10001 到 10010 。所有都以相同的方式工作除了每个分片不得不产生前10010个结果以外。 然后协调节点对全部 50050 个结果排序最后丢弃掉这些结果中的 50040 个结果。可以看到,在分布式系统中,对结果排序的成本随分页的深度成指数上升。

ES 对from+size 是有限制的,默认值为 10000 ES 官方文档给出的解释是:对于深度分页,ES 的每个 shard 都会查询 TopN(文档信息)(查询流程,注意 N=from+size)的结果,即查询 [from, from+size) 的结果实际上数据节点会查询 from+size 个结果,也就是将 [0, from) 的结果也一并查出来了,这样将会导致 CPU 和 内存的开销较大,导致系统卡顿甚至 OOM(特别是协调节点,要收集多个 shard 返回的结果,内存开销更大)。因此,from+size 常常应用于分页深度较小的场景,不适合深分页场景。es默认index.max_result_window =10000,如果超过最大数量会报错提示

1700903326834.png

查询流程

1700903361016.png 在这个示例中,from参数设置为2,size参数设置为2,要返回第二页的2条数据。

  • 协调节点广播请求,每个节点收到请求,各自对数据排序,取【from+size】条数据,返回给协调接口
  • 协调节点拿到数据节点返回数据(文档的唯一标识_id 和用于排序的字段不包括文档内容),对数据进行重新排序,根据分页参数取得相应的文档id。
  • 根据相应的文档id再请求数据节点,数据节点根据文档id返回完整的数据给协调节点。

为什么每个节点要查询所有数据返回给协调节点?

1700903453049.png 上图表示每个 shard 返回 [2, 2) 的文档集合并后的结果。很明显,这个结果是不正确的,原因便是每个 shard 并不知道全局的排序结果。

Search After分页

对于需要深度分页的场景,Elasticsearch提供了另一种分页方式search_after。他是一种更有效的分页方法,可以在不加载整个数据集的情况下快速地获取下一页数据。

search_after 是一种基于游标的分页方法,使用 search_after 查询时必须指定排序字段(可以有多个),它使用排序字段值作为游标,从而能够更快地获取下一页的数据。在进行第一次搜索时,ES 会返回第一页的结果,当需要获取下一页数据时,可以使用上一页最后一个文档的排序字段值作为游标进行搜索。通过这种方式,可以逐步遍历整个数据集而无需一次性加载所有数据。

使用 search_after 机制需要注意以下几点:

  1. 搜索请求必须指定排序字段,用于指定搜索结果的顺序
  2. 搜索第一页不必指定 search_after 参数,从第二页开始必须指定 search_after 为上一页的最后一个游标
  3. 游标必须是唯一的,否则可能会出现重复的数据
  4. sort字段必须是keyword类型,开启doc_value可用于排序

什么是doc_value?

倒排索引可以提供全文检索能力,但是无法提供对排序和数据聚合的支持。doc_values 本质上是一个序列化的列式存储结构,适用于聚合(aggregations)、排序(Sorting)、脚本(scripts access to field)等操作。默认情况下,ES几乎会为所有类型的字段存储doc_value,但是 text 等可分词字段不支持 doc values 。如果不需要对某个字段进行排序或者聚合,则可以关闭该字段的doc_value存储。

场景例举:谷歌,知乎等上滑分页

用法举例:

请求:
GET abc_index/_search
{
    "query": {
        "match": {
            "title": "测试"
        }
    },
    "size": 2,
    "sort": [
        {
            "publishTime": "asc"
        },
        {
            "articleId": "desc"
        }
    ]
}
响应:
{
    "publishTime": 0,
    "recommendColumn": [],
    "siteId": 1,
    "source": "隆众资讯",
    "sourceType": 0,
    "stickExpireTime": 0,
    "stickTime": 0,
    "subTitle": "",
    "summary": "",
    "articleId": 9740049,
    "title": "2023年01月28日1.28新增测试33",
    "sort": [
        0,
        9740049
    ]
}

第二次查询:
GET abc_index/_search
{
    "query": {
        "match": {
            "title": "测试"
        }
    },
    "size": 2,
    "sort": [
        {
            "publishTime": "asc"
        },
        {
            "articleId": "desc"
        }
    ],
    "search_after": [
        0,
        9740049
    ]
}

响应:
{
    "publishTime": 0,
    "recommendColumn": [],
    "siteId": 1,
    "source": "隆众资讯",
    "sourceType": 0,
    "stickExpireTime": 0,
    "stickTime": 0,
    "subTitle": "",
    "summary": "",
    "articleId": 9740042,
    "title": "2023年01月28日1.28新增测试",
    "sort": [
        0,
        9740042
    ]
}

查询流程

1700911514122.png 第一次查询流程:

  • 协调节点广播请求,数据节点收到请求后根据请求参数对数据进行排序,取前2条返回给协调节点
  • 协调节点收到数据节点返回后,对数据排序后取前2条文档id,再进行2次请求拿到详细文档数据返回给客户端

第二次查询流程:

  • 协调节点广播请求到数据节点,数据节点根据请求参数进行排序,根据search_after参数直接取符合条件的数据返回给协调节点
  • 协调节点收到返回数据后,对数据进行排序取前2条数据返回给客户端

scroll分页

Scroll 查询是一种在 ES 中扫描大量数据的常用方法。它通过在搜索结果中建立一个保持状态的 scroll_id 来实现。当您开始滚动时,ES 会返回第一批结果,并返回一个保持状态的 ID。使用此 ID,可以执行下一个滚动请求,以检索下一批结果。此过程可以重复进行,直到所有数据都被扫描完毕为止。

用法

第一次查询要指定 scroll 参数,参数值代表 scroll 上下文的保留时长,保留时间过期后,scroll_id 将失效。 
请求: 
POST /abc_index/_search?scroll=1m
{
    "query": {
        "match": {
            "keyWords": "能源"
        }
    }
}
响应:
{
    "_scroll_id": "DnF1ZXJ5VGhlbkZldGNoCgAAAABaWjYnFnRCMFFzT3BoUkMtQl9rWWhaWDI2NlEAAAAAWj6JfhZGMVdxancyNVRyaVg2NHNObDA5dkx3AAAAAFo-iX8WRjFXcWp3MjVUcmlYNjRzTmwwOXZMdwAAAACoeYaFFmlvZFlNcU5OVE5hUVJ5UHFJVFk2R0EAAAAAWv3GaxZEMjJTYUl5dVN3ZVNUMDJQQW1sTDhnAAAAAKh5hoYWaW9kWU1xTk5UTmFRUnlQcUlUWTZHQQAAAACZD7aRFm02OE44M015UWN5U3pNQktSeEV2X3cAAAAAWlo2KBZ0QjBRc09waFJDLUJfa1loWlgyNjZRAAAAAJkPtosWbTY4TjgzTXlRY3lTek1CS1J4RXZfdwAAAABa_cZsFkQyMlNhSXl1U3dlU1QwMlBBbWxMOGc=",
    "took": 378,
    "timed_out": false,
    "_shards": {
        "total": 10,
        "successful": 10,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": 989055,
        "max_score": 1.431006,
        "hits": [
            {
                "_index": "abc_index",
                "_type": "site",
                "_id": "12049713",
                "_score": 1.431006,
                "_source": {
                    "areaCodes": [
                        "1957"
                    ],
                    "id": "12049713",
                    "informationTypes": [
                        "250"
                    ],
                    "isRecommend": "1",
                    "isShow": "0",
                    "publishTime": 1583374357559,
                    "status": "2",
                    "title": "测试能源",
                    "unitId": "31360"
                }
            }
        ]
    }
}
第二次请求:
GET /_search/scroll
{
    "scroll": "1m",
    "scroll_id": "DnF1ZXJ5VGhlbkZldGNoCgAAAABaWjYnFnRCMFFzT3BoUkMtQl9rWWhaWDI2NlEAAAAAWj6JfhZGMVdxancyNVRyaVg2NHNObDA5dkx3AAAAAFo-iX8WRjFXcWp3MjVUcmlYNjRzTmwwOXZMdwAAAACoeYaFFmlvZFlNcU5OVE5hUVJ5UHFJVFk2R0EAAAAAWv3GaxZEMjJTYUl5dVN3ZVNUMDJQQW1sTDhnAAAAAKh5hoYWaW9kWU1xTk5UTmFRUnlQcUlUWTZHQQAAAACZD7aRFm02OE44M015UWN5U3pNQktSeEV2X3cAAAAAWlo2KBZ0QjBRc09waFJDLUJfa1loWlgyNjZRAAAAAJkPtosWbTY4TjgzTXlRY3lTek1CS1J4RXZfdwAAAABa_cZsFkQyMlNhSXl1U3dlU1QwMlBBbWxMOGc="
}

如果超过缓存时间再用过期的scroll_id去请求,es会抛出没有上下异常

1700911776042.png

总结

分页方式性能优点缺点场景
from+size灵活性好,实现简单,支持跳页深度分页,资源消耗过多,效率越往后越慢适合翻页灵活且数据量不大的场景
srcoll解决了深度分页,内部维护快照,效率高无法反应数据实时性,需要足够的上下文内存空间,维护快照成本高,需要维护scroll_id,只能向后翻页,不够灵活海量数据查询,深度翻页,后台数据批量处理等任务
search after解决深度分页,性能高,数据实时更新实现复杂,需要全局唯一字段排序,并且每次查询都需要上次查询的结果。不支持跳页深度翻页,用户实时查询需求,例如手机APP下拉滑动分页