问题的产生

遇到一个线上问题,es深度分页导致内存溢出,进而导致es节点挂掉。本人在项目里使用的是from+size这种分页方式,这种方式有一个弊端,就是会导致深度分页。这里简单介绍下啥是深度分页:
首先es是分布式的,数据分布在各个节点上,当某个节点接收到客户端查询请求的时候,它会把请求广播到其他节点,接收客户端请求的这个节点称之为请求节点(requesting node),它负责收集汇总其他节点的数据。
通常情况下一个节点上会有一个索引的主分片,现在假设在一个有5个主分片(也即5个节点)的索引中搜索,当我们请求结果的第一页(结果1到10)时,每个分片产生自己最顶端10个结果然后返回它们给请求节点,请求节点再排序这所有的50个结果以选出顶端的10个结果。
现在假设我们请求第1000页——结果10001到10010。工作方式都相同,不同的是每个分片都必须产生顶端的10010个结果,然后汇总到请求节点,请求节点排序这50050个结果并丢弃50040个!
你可以看到在分布式系统中,排序结果的花费随着分页的深入而成倍增长。这也是为什么网络搜索引擎中任何语句不能返回多于1000个结果的原因。

解决方案

scroll(滚屏)

一个滚屏搜索允许我们做一个初始阶段搜索并且持续批量从es里拉取结果直到没有结果剩下。这有点像传统数据库里的游标(cursors)。
滚屏搜索会及时制作快照,这个快照不会包含任何在初始阶段搜索请求后对索引做的修改。它通过将旧的数据文件保存在手边,所以可以保护索引的样子看起来像搜索开始时的样子。
scroll的第一次请求的语法如下:

GET /call_chain_detail/_doc/_search?scroll=5m
{
  "query": {
    "match_all": {}
  },
  "size": 10
}

scroll参数告诉es滚屏应该持续多长时间(可以理解成滚屏的过期时间),比如这里scroll=5m告诉es滚屏应该持续5分钟。
响应:

#! Deprecation: [types removal] Specifying types in search requests is deprecated.
{
  "_scroll_id" : "DnF1ZXJ5VGhlbkZldGNoAwAAAAAAAC8YFjRkQ0haQXFoUnp1TW1iN3RSVTlLencAAAAAAAAvGRY0ZENIWkFxaFJ6dU1tYjd0UlU5S3p3AAAAAAADBOUWZXR6YjZzT3RTM1N1N29yMlhkNks2QQ==",
  "took" : 912,
  "timed_out" : false,
  "_shards" : {
    "total" : 3,
    "successful" : 3,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 36343,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "call_chain_detail",
        "_type" : "_doc",
        "_id" : "mY389XcBYLSsDIqvD8hJ",
        "_score" : 1.0,
        "_source" : {
          "endTime" : "2021-03-03 09:42:48:640",
          "result" : 0,
          "startTime" : "2021-03-03 09:42:48:638",
          "timeline" : 1614735768640
        }
      },
      {
        "_index" : "call_chain_detail",
        "_type" : "_doc",
        "_id" : "m4389XcBYLSsDIqvD8io",
        "_score" : 1.0,
        "_source" : {
          "endTime" : "2021-03-03 09:44:09:031",
          "result" : 0,
          "startTime" : "2021-03-03 09:44:09:003",
          "timeline" : 1614735849031
        }
      }
    ]
  }
}

这个scroll请求的应答包含了第一批次的结果还有_scroll_id,_scroll_id很重要,后续scroll请求需要带上这个id:

GET _search/scroll
{
  "scroll": "5m",
  "scroll_id": "DnF1ZXJ5VGhlbkZldGNoAwAAAAAAAwaPFmV0emI2c090UzNTdTdvcjJYZDZLNkEAAAAAAAMGkBZldHpiNnNPdFMzU3U3b3IyWGQ2SzZBAAAAAAADUdIWaVdQamZUaFhTMC12UDFVcWFvVEVkdw=="
}

注意,要再次指定scroll为多长时间。滚屏的终止时间会在我们每次执行滚屏请求时刷新,所以它只需要给我们足够的时间来处理当前批次的结果而不是所有的被查询条件匹配到的文档。
我们可以一直执行上述请求,直到返回的数据为空(数据全部查询完了)。
深度分页代价最高的部分是对结果的全局排序,但如果禁用排序,就能以很低的代价获得全部返回结果。在es5.0之前可以使用scan搜索模式提高性能,该模式下es对搜索结果不排序。es5.0之后scan模式废除了,可以使用按_doc排序代替,按_doc排序是经过es优化的,优化后的scroll查询语法如下:

GET /call_chain_detail/_doc/_search?scroll=5m
{
  "query": {
    "match_all": {}
  },
  "size": 10,
  "sort": [
    "_doc"
  ]
}

search_after

search_after是一种假分页方式,根据上一页的最后一条数据来确定下一页的位置,同时在分页请求的过程中,如果有索引数据的增删改查,这些变更也会实时的反映到游标上。为了找到每一页最后一条数据,每个文档必须有一个全局唯一值,可以使用 _id 作为全局唯一值,但是只要能表示其唯一性就可以。
具体使用方式如下:

GET /call_chain_detail/_doc/_search
{
  "query": {
    "match_all": {}
  },
  "size": 10,
  "sort": [
    {
      "_id": "asc"
    }
  ]
}

这样我们会得到一个数据列表,我们取列表中最后一条数据的_id当做search_after参数:

GET /call_chain_detail/_doc/_search
{
  "query": {
    "match_all": {}
  },
  "size": 10,
  "search_after":["-40hFngBYLSsDIqv9elU"],
  "sort": [
    {
      "_id": "asc"
    }
  ]
}

这样虽然能排序,但是使用起来不太友好,尤其是当文档中有时间字段时,查出来的数据以时间来衡量是乱糟糟的,因为你是根据_id排序的,而_id是随机字符串,没啥规律。其实我们可以根据多字段排序,比如先根据时间戳排序,当时间戳一样时再根据唯一字段_id排序,这样会大大提高用户体验,查询语法如下:

GET /call_chain_detail/_doc/_search
{
  "query": {
    "match_all": {}
  },
  "size": 10,
  "sort": [
    {
      "timeline": "desc",
      "_id": "asc"
    }
  ]
}
GET /call_chain_detail/_doc/_search
{
  "query": {
    "match_all": {}
  },
  "size": 10,
  "search_after":[1617932029578, "VLJetHgBgBLvM6lrIFqW"],
  "sort": [
    {
      "timeline": "desc",
      "_id": "asc"
    }
  ]
}

search_after不是自由跳转到随机页面而是并行滚动多个查询的解决方案。它与滚动API非常相似,但与它不同,search_after参数是无状态的,它始终针对最新版本的搜索器进行解析。因此,排序顺序可能会发生变化,具体取决于索引的更新和删除。