深度分页

# 什么是深度分页

深度分页 指的是在排序的情况下,查询很“深”(大页码)的页面数据。

首先要知道分页发生时,如查询排名在第 10000 条到第 10010 的文档,ES 需要经历两步:

  1. 排序(堆维护)获取到前 10010 个文档
  2. 分页返回处在 [10000, 10010] 的结果

这很好理解,要得到排名在 [a,b] 区间的元素,那至少要排了才能给吧,所以一次分页的复杂度考量的是 b 的大小。

考虑一下分布式,ES 存在 5 个分片,这个时候 ES 的步骤为:

  1. 每个分片都要取出排序后前 10010 个文档
  2. 将这 50050 个文档排序(堆维护)出前 10010 个文档
  3. 分页返回处在 [10000, 10010] 的结果

每个分片都要拿出前 10010 个元素是因为文档在分布式内分发的时候考虑负载均衡,是比较均匀地放置的。而对于排序规则,可能一个分片内的数据就算拿完了排名也不一定在前 10010 位。
而一个分片排序后的第 10011 位是一定不在综合前 10010 位的,毕竟比它靠前的都有 10010 个了,故每个分片都要排出前 10010 个元素。

# 负面影响

因为这些排序都是在堆中进行的,因此深度分页可能会导致 OOM 或者 FGC 的产生,对机器内存、性能、系统响应速度都产生较大压力。

max_result_window 参数

之前说过 ES 的分页复杂度应考量区间右端点的大小,而 ES 也给了右端点的限制 max_result_window,这个参数默认是 10000,也就是说当右端点超过 max_result_window 时,是会被拒绝的。
这个参数是可以调的,具体的设置不应是业务要查到哪了就定到哪,而是应该结合数据量、内存大小进行设置的。

又不想一次响应或者在本地内存中存储太大的数据,又想把大量排序后的数据都处理一下,从系统层次(本篇不关心业务上怎么提供数据给用户)来讲,最常见的做法就是分批,而 ES 对于分批获取的方式即为 “滚动查询”,对于排序好的数据每次滚动一段进行响应,就满足上面说的需求了,具体滚动策略有下面几种。

# 解决方案1:Scroll

Scroll 查询的本质是在第一次查询时保存一个快照,第一次 scroll 查询后返回一个 id,之后就拿着这个 id 进行查询直到最后没有数据或者 id 超时为止。
这种快照查询的优点在于查询过程中即使数据变动,也不会对查询结果有任何影响,存在较强的一致性,但这也有可能会成为缺点,因为实时性较差。

使用方式为第一次调用 GET /${索引名}/_search?scroll=${索引id超时时间} 然后请求体内包含查询条件、排序规则、分页大小等...,如下在我创建的 bloggers 索引内查询

20240903185231

在拿到响应 json 根路径下的 _scroll_id 后,查询方式要发生更改,变为调用 GET /_search/scroll ,在请求体内传 scroll_id 和超时时间 scroll,如下

20240903185326

之后的查询便都一样了,因为我这边索引内只有两条数据,因此这第三次查询会是空结果,之后的也会是空的

20240903185343

快照清理

scroll 上下文快照是很占用系统资源的,因此在超时之前查询完最好可以手动清理,ES 也提供了两种清理请求:

  • 清除指定 scroll_id:DELETE /_search/scroll/${scroll_id}
  • 清除所有快照:DELETE /_search/scroll/_all

scroll 适合导出场景

# 解决方案2:Search After

search after 采用实时的请求形式,只需要每次提交上次排序值(该排序值最好唯一),本次请求处理会根据提交的排序值获取它后面的 size 个元素。
相比于 scroll 查询,不用维护上下文且实时性会更强,且每次查询可以采用不同的查询语法,相比 scroll 查询更加灵活。
但新数据或者改动的数据可能会对查询结果产生影响,故需要更细致合理的业务操作来避免。

使用方式为第一次按正常查询构造 GET /${索引名}/_search

20240903190531

而第二次要在请求内添加字段 search_after 内部按照 sort 字段内的顺序,填充上次查询出来的结果(其实就是按照 hits.hits 最后一条的 sort 字段写就行)

20240903190754

若排序值不唯一

若排序值不唯一,如按 key 排序查询,有三个文档(id=1,id=2,id=3,id=4) key 都为 3,一次查询后刚好查到 id=2 的文档, sort 值为 3。
此时下次查询设定 search_after 为 3,那么查询结果

  1. 可能从 id=2 开始,发生重复
  2. 可能从 id=4 开始,发生缺失

为应对这种,从业务处理层面可以在排序内多添加一个唯一 uuid 作为最后一个关键字(这也是官方推荐的做法)

而其实数据重复和缺失的情况不止出现在排序值重复上,还可能因数据更新导致顺序变化导致。如对 key 升序查询,有四个文档 (id=0,key=0),(id=1,key=1),(id=2,key=2),(id=3,key=3)。
此时滚动查询 size 为 1,查完 id=2 后 search_after=2,若此时 (id=0,key=0) 的文档将 key 更新为 4,那么在后面的查询会再查询出来一次该文档。
这是从前往后更新的情况发生重复,而对于从后往前更新,则同理发生缺失。
为了针对这种问题,ES 引入了 PIT 查询。

search_after 适合分页查询场景

# 优化:PIT(point in time)

针对 search_after 的实时性问题,ES (v7.10 +) 通过保留索引当前状态(依旧类似于快照),来保证 search_after 每次查询都是一样的内容。
这种方案结合了 scroll 一致性的优点和 search_after 的灵活性优点,虽然会带上 scroll 的耗费资源的缺点。

查询首先创建一个 PIT 时间点 POST /${索引名}/_pit?keep_alive=${PIT超时时长}

20240903194006

拿到 id 后,填写进分页查询中(注意不再填写索引名了,打 PIT 的时候就已经保存索引了)

GET /_search
{
    /*
     * search_after 语法
     */

    "pit": {
        "id": "(String) ${上一步获取的pit_id}",
        "keep_alive": "$(String) ${超时时间}"
    }
}
1
2
3
4
5
6
7
8
9
10
11

如我根据刚刚创建的 pit_id 进行我之前的 search_after 查询。

20240903194428

而相对的,PIT 也有自己的释放方式:

DELETE /_pit
{
    "id": "${PIT_ID}"
}
1
2
3
4

20240903194848

Last Updated: 9/3/2024, 8:11:39 PM