从数据集中获取数据时分页是绕不开的操作,一下子从数据集中获取过多的数据可能会造成系统抖动、占用带宽等问题。特别是进行全文搜索时,用户只关心相关性最高的那个几个结果,从系统中拉取过多的数据等于浪费资源。

ES 提供了 3 种分页方式:

  1. from + size:最普通、简单的分页方式,但是会产生深分页的问题。
  2. search after:解决了深分页的问题,但只能一页一页地往下翻,不支持跳转到指定页数。
  3. scroll API:会创建数据快照,无法检索新写入的数据,适合对结果集进行遍历的时候使用。

这 3 种方式的分页操作都有其优缺点,适合不同的场合使用。今天我们就来学习这 3 种分页方式,但除了学习这 3 种分页方式外,我们还会介绍 ES 新引入的特性:Point In Time,看看如何使用 Point In Time + search after 的方式来代替 scroll API 进行大量数据的导出。

一、from + size 分页操作与深分页问题

在我们检索数据时,系统会对数据按照相关性算分进行排序,然后默认返回前 10 条数据。我们可以使用 from + size 来指定获取哪些数据。其使用示例如下:

  1. # 简单的分页操作
  2. GET books/_search
  3. {
  4. "from": 0, # 指定开始位置
  5. "size": 10, # 指定获取文档个数
  6. "query": {
  7. "match_all": {}
  8. }
  9. }

如上示例,使用 “from” 指定获取数据的开始位置,使用 “size” 指定获取文档的个数。

但当我们将 from 设置大于 10000 或者 size 设置大于 10001 的时候,这个查询将会报错:

  1. # 返回结果中的部分错误信息
  2. ......
  3. "root_cause" : [
  4. {
  5. "type" : "illegal_argument_exception",
  6. "reason" : "Result window is too large, from + size must be less than or equal to: [10000] but was [10001]. See the scroll api for a more efficient way to request large data sets. This limit can be set by changing the [index.max_result_window] index level setting."
  7. }
  8. ],
  9. "type" : "search_phase_execution_exception",
  10. "reason" : "all shards failed",
  11. "phase" : "query",
  12. "grouped" : true,
  13. ......

从报错信息可以看出,我们要获取的数据集合太大了,系统拒绝了我们的请求。我们可以使用 “index.max_result_window” 配置项设置这个上限:

  1. PUT books/_settings
  2. {
  3. "index": {
  4. "max_result_window": 20000
  5. }
  6. }

如上示例,我们设置了这个上限为 20000。虽然使用这个配置有时候可以解决燃眉之急,但是这个上限设置过大的情况下会产生非常严重的后果,因为 ES 中会存在深分页的问题。

那什么是深分页和为什么会产生深分页的问题呢?

深度分页的原因.png

如上图,ES 把数据保存到 3 个主分片中,当使用 from = 90 和 size = 10 进行分页的时候,ES 会先从每个分片中分别获取 100 个文档,然后把这 300 个文档再汇聚到协调节点中进行排序,最后选出排序后的前 100 个文档,返回第 90 到 99 的文档。

可以看到,当页数变大(发生了深分页)的时候,在每个分片中获取的数据就越多,消耗的资源就越多。并且如果分片越多,汇聚到协调节点的数据也越多,最终汇聚到协调节点的文档数为:shard_amount * (from + size)。

二、search after

使用 search after API 可以避免产生深分页的问题,不过 search after 不支持跳转到指定页数,只能一页页地往下翻

使用 search after 接口分为两步:

  1. 在 sort 中指定需要排序的字段,并且保证其值的唯一性(可以使用文档的 ID)。
  2. 在下一次查询时,带上返回结果中最后一个文档的 sort 值进行访问。

search after 的使用示例如下:

  1. # 第一次调用 search after
  2. POST books/_search
  3. {
  4. "size": 2,
  5. "query": { "match_all": {} },
  6. "sort": [
  7. { "price": "desc" },
  8. { "_id": "asc" }
  9. ]
  10. }
  11. # 返回结果
  12. "hits" : [
  13. {
  14. "_id" : "6",
  15. "_source" : {
  16. "book_id" : "4ee82467",
  17. "price" : 20.9
  18. },
  19. "sort" : [20.9, "6"]
  20. },
  21. {
  22. "_id" : "1",
  23. "_source" : {
  24. "book_id" : "4ee82462",
  25. "price" : 19.9
  26. },
  27. "sort" : [19.9, "1"]
  28. }
  29. ]

如上示例,在第一次调用 search after 时指定了 sort 的值,并且 sort 中指定以 price 倒序排序。为了保证排序的唯一性,我们指定了文档 _id 作为唯一值。

可以看到,第一次调用的返回结果中除了文档的信息外,还有 sort 相关的信息,在下一次调用的时候需要带上最后一个文档的 sort 值,示例中其值为:[19.9, “1”]。

下面的示例是第二次调用 search after 接口进行翻页操作:

  1. # 第二次调用 search after
  2. POST books/_search
  3. {
  4. "size": 2,
  5. "query": {
  6. "match_all": {}
  7. },
  8. "search_after":[19.9, "1"], # 设置为上次返回结果中最后一个文档的 sort 值
  9. "sort": [
  10. { "price": "desc" },
  11. { "_id": "asc" }
  12. ]
  13. }

如上示例,进行翻页操作的时候在 search after 字段中设置上一次返回结果中最后一个文档的 sort 值,并且保持 sort 的内容不变。

那为啥 search after 不会产生深度分页的问题呢?其关键就是 sort 中指定的唯一排序值。

search_after原理.png

如上图,因为有了唯一的排序值做保证,所以每个分片只需要返回比 sort 中唯一值大的 size 个数据即可。例如,上一次的查询返回的最后一个文档的 sort 为 a,那么这一次查询只需要在分片 1、2、3 中返回 size 个排序比 a 大的文档,协调节点汇总这些数据进行排序后返回 size 个结果给客户端。

而 from + size 的方式因为没有唯一排序值,所以没法保证每个分片上的排序就是全局的排序,必须把每个分片的 from + size 个数据汇总到协调节点进行排序处理,导致出现了深分页的问题。

因为 sort 的值是根据上一次请求结果来设置的 ,所以 search after 不支持跳转到指定的页数,甚至不能返回前一页,只能一页页往下翻。但我们可以结合缓存中间件,把每页返回的 sort 值缓存下来,即可实现往前翻页的功能。

三、scroll API

当我们想对结果集进行遍历的时候,例如做全量数据导出时,可以使用 scroll API。scroll API 会创建数据快照,后续的访问将会基于这个快照来进行,所以无法检索新写入的数据

scroll API 的使用示例如下:

  1. # 第一次使用 scroll API
  2. POST books/_search?scroll=10m
  3. {
  4. "query": {
  5. "match_all": {}
  6. },
  7. "sort": { "price": "desc" },
  8. "size": 2
  9. }
  10. # 结果
  11. {
  12. "_scroll_id" : "FGluY2x1ZGVfY29udGV4dF9......==",
  13. "hits" : {
  14. "hits" : [
  15. {
  16. "_id" : "6",
  17. "_source" : {
  18. "book_id" : "4ee82467",
  19. "price" : 20.9
  20. }
  21. },
  22. ......
  23. ]
  24. }
  25. }

如上示例,在第一次使用 scroll API 时需要初始化 scroll 搜索并且创建快照,使用 scroll 查询参数指定本次“查询上下文”(快照)的有效时间,本示例中为 10 分钟。

其返回的结果中除了匹配文档的列表外还有 _scroll_id,我们需要在翻页请求中带上这个 _scroll_id:

  1. # 进行翻页
  2. POST /_search/scroll
  3. {
  4. "scroll" : "5m",
  5. "scroll_id" : "FGluY2x1ZGVfY29udGV4dF9......=="
  6. }

如上示例,我们把上一次返回结果中的 _scroll_id 值放到本次请求的 scroll_id 字段中,并且指定“查询上下文”的有效时间为 5 分钟。同样此次的返回结果也会带有新的 _scroll_id。

其实在 ES 7.10 中引入了 Point In Time 后,scroll API 就不建议被使用了。下面我们来看看如何使用 Point In Time 结合 search after 来做分页和数据遍历的。

四、Point In Time

Point In Time(PIT)是 ES 7.10 中引入的新特性,PIT 是一个轻量级的数据状态视图,用户可以利用这个视图反复查询某个索引,仿佛这个索引的数据集停留在某个时间点上。也就是说,在创建 PIT 之后更新的数据是无法被检索到的。

当我们想要获取、统计以当前时间节点为准的数据而不考虑后续数据更新的时候,PIT 就显得非常有用了。使用 PIT 前需要显式使用 _pit API 获取一个 PID ID:

  1. # 使用 pit API 获取一个 PID ID
  2. POST /books/_pit?keep_alive=20m
  3. # 结果
  4. {
  5. "id": "46ToAwMDaWR5BXV1aWQy......=="
  6. }

如上示例,使用 _pit 接口获取了一个 PIT ID,keep_alive 参数设置了这个视图的有效时长。有了这个 PIT ID 后续的查询就可以结合它来进行了。

PIT 可以结合 search after 进行查询,能有效保证数据的一致性。 PIT 结合 search after 的流程与前面介绍的 search after 差不多,主要区别是需要在请求 body 中带上 PIT ID,其示例如下:

  1. # 第一次调用 search after,因为使用了 PIT,这个时候搜索不需要指定 index 了。
  2. POST _search
  3. {
  4. "size": 2,
  5. "query": { "match_all": {} },
  6. "pit": {
  7. "id": "46ToAwMDaWR5BXV1aWQy......==", # 添加 PIT id
  8. "keep_alive": "5m" # 视图的有效时长
  9. },
  10. "sort": [
  11. { "price": "desc" } # 按价格倒序排序
  12. ]
  13. }
  14. # 结果
  15. {
  16. "pit_id" : "46ToAwMDaWR5BXV1aWQy......==",
  17. "hits" : {
  18. "hits" : [
  19. {
  20. "_id" : "6",
  21. "_source" : {
  22. "book_id" : "4ee82467",
  23. "price" : 20.9
  24. },
  25. "sort" : [20.9, 8589934593]
  26. },
  27. {
  28. "_id" : "1",
  29. "_source" : {
  30. "book_id" : "4ee82462"
  31. "price" : 19.9
  32. },
  33. "sort" : [19.9, 8589934592]
  34. }
  35. ]
  36. }
  37. }

如上示例,在 pit 字段中指定 PIT ID 和设置 keep_alive 来指定视图的有效时长。需要注意的是,使用了 PIT 后不再需要在 sort 中指定唯一的排序值了,也不需要在路径中指定索引名称了。

在其返回结果中,sort 数组中包含了两个元素,其中第一个是我们用作排序的 price 的值,第二个值是一个隐含的排序值。所有的 PIT 请求都会自动加入一个隐式的用于排序的字段称为:_shard_doc,当然这个排序值可以显式指定。这个隐含的字段官方也称它为:tiebreaker(决胜字段),其代表的是文档的唯一值,保证了分页不会丢失或者分页结果的数据不会重复,其作用就好像原 search after 的 sort 字段中要指定的唯一值一样。

在进行翻页的时候和原 search after 一样,需要把上次结果中最后一个文档的 sort 值带上:

  1. # 第二次调用 search after,因为使用了 PIT,这个时候搜索不需要指定 index 了。
  2. POST _search
  3. {
  4. "size": 2,
  5. "query": {
  6. "match_all": {}
  7. },
  8. "pit": {
  9. "id": "46ToAwMDaWR5BXV1aWQy......==", # 添加 PIT id
  10. "keep_alive": "5m" # 视图的有效时长
  11. },
  12. "search_after": [19.9, 8589934592], # 上次结果中最后一个文档的 sort 值
  13. "sort": [
  14. { "price": "desc" }
  15. ]
  16. }

search after + PIT 实现的功能似乎和 scroll API 类似,那它们间有啥区别呢?其实你会发现使用 scroll API 的时候,scroll 产生的上下文是与本次查询绑定的,很明显的一点就是,生成一个 scroll id 后,其他查询无法重用这个 id,scroll 的翻页也只能一直向下翻。而 PIT 可以允许用户在同一个固定数据集合上运行不同的查询,例如多个请求可以使用同一个 PIT 视图而互不影响

五、总结

今天为你介绍了 ES 提供的 3 种分页方式。

from + size 是最普通、简单的分页方式,其支持在不同页面间随机跳转,但是会产生深分页的问题。默认的情况下可以支持 Top 10000 条数据内的分页场景,可以使用 max_result_window 进行调整,但不建议调得过大。

search after:通过在 sort 中指定唯一排序值解决了深分页的问题,但导致其只能一页一页地往下翻,不支持跳转到指定页数。当然我们也介绍了可以使用缓存等手段实现向前翻页的功能。

scroll API:会创建数据快照,无法检索新写入的数据,其适合遍历结果集的时候使用。scroll API

产生的快照与本次查询是绑定的,其他查询无法复用。

最后我们还介绍了 ES 在 7.10 中引入的特性:Point In Time(PIT),PIT 简单来说就是一个视图,多个查询可以复用一个 PIT,使得用户可以重复检索某个时间点的数据。在新版的 ES 官方文档中建议使用 PIT + search after 代替 scroll API,即使 scroll API 还可以使用。

好了今天的内容到此为止,更多关于分页 API 的使用实例可以参考官方文档