使用 Elasticsearch 时间点读取器获得随时间推移而保持一致的数据视图

总结一下:如果可行,我们推荐您使用 Elasticsearch 的全新时间点功能。对于深度分页,我们不再推荐使用滚动 API(虽然它仍然有效)。

大多数数据都不断变化。在 Elasticsearch 中查询索引,实际上是在一个给定的时间点搜索数据。由于索引不断变化(在大多数可观测性和安全性用例中皆如此),在不同的时间执行两个相同的查询将返回不同的结果,因为数据会随着时间而变化。那么,如果需要消除时间变量的影响,该怎么做呢?

Elasticsearch 7.10 中引入的时间点读取器可以让您反复查询某个索引,仿佛该索引处于某个特定的时间点。

从这个高度概括的介绍看,时间点功能似乎与滚动 API 类似,后者会检索下一批结果以完成滚动搜索。但两者间有一个微妙的差别,可以清楚表明为何时间点在未来将是“有状态”查询不可或缺的部分。

滚动 API:快速回顾

滚动 API 的运行原理如下所述:

常规搜索查询会附带滚动参数执行 每个搜索响应都包含一个 _scroll_id,用于下次查询 在滚动完所有响应后,可以删除 _scroll_id 以释放资源

在您启动初始搜索请求的那一刻,返回的数据基本上都是冻结的。在滚动开始后发生的写入操作将不会成为搜索响应的一部分。这也适用于删除、索引和更新操作。这样可以保证整个数据集在某个时间点上是一致的。

在需要释放资源的后台发生了什么?滚动搜索只在初始滚动搜索创建时考虑数据。这意味着在较低的级别,从初始请求返回数据所需的资源都不会被修改或删除。段(segments)会持续保持可用,即使段可能已经被合并而且不再为实时数据集所需。请注意,使用其他滚动 id 或完全未使用滚动的其他搜索也同时进行,查看与初始滚动搜索不同的数据。这导致更多数据被保持可用,而不仅仅是实时数据集。数据更多意味着段更多、文件句柄更多,从而造成堆更多,因为要将来自段的元数据保存在堆中。

保持实时数据不需要的段可用也意味着需要更多磁盘空间来保持这些段继续存在,因为它们在滚动 id 被删之前不能被删除。这种内部运行方式利用了引用计数。只要有一个组件(如滚动搜索)保有对数据的引用(例如通过与一个索引节点对应的打开文件句柄),就不会最终删除该数据,即使它不再属于实时数据集。这也是滚动 id 存在的原因。通过将其指定为查询的一部分,可指定要查询的语句。

为了尽快释放资源,我们建议使用清除滚动 API。还可以使用切片滚动等优化来并行执行数据检索。

那么时间点又如何呢?

我们已经介绍了滚动搜索的基础知识,现在回到最重要的问题:如果我们已经有了所有这些基础架构,为什么还要使用时间点呢?

目前,滚动搜索及其上下文与查询绑定。这意味着编写一个查询,添加一个滚动参数,来自这个查询的响应数据就会保持一致。然而,这并不总是我们所需要的。有时想对同一固定数据集适时运行不同的查询。这是一个重大的区别。时间点的首批用户之一是 EQL,即用于在时间序列数据中查询的事件查询语言。让我们看一下这个 EQL 查询:

GET /auth-logs/_eql/search
{
  "query": """
  sequence by host.name,source.ip,user.name with maxspan=15s
    [ authentication where event.outcome == "failure" ]
    [ authentication where event.outcome == "failure" ]
    [ authentication where event.outcome == "failure" ]
    [ authentication where event.outcome == "success" ]
  """
}

它会在 15 秒内搜索 3 次失败的登录,然后再搜索一次成功的登录。这是 EQL 的一个完美用例,因为需要不止一个查询。

在此情况中,关键点是时间点读取器实际上与搜索请求相分离。时间点结构在专用操作中创建,因此可用于任意搜索请求。您可使用时间点 API 做到这一点。来自此类请求的结果包括一个 id,其现在可用于您即将执行的任何搜索请求。 让我们看看在调用时间点 API 时,机房中发生了什么。基本上,这将执行一个分片操作,该操作会调用 SearchService.openReaderContext()。然而,这并不会为索引中的所有分片调用,而只会为命中搜索请求的分片调用。让我们看一个例子,它要求一个集群中至少有两个节点:

PUT test?wait_for_active_shards=all
{
  "settings": {
    "number_of_shards":5,
    "number_of_replicas":1
  }
}

POST test/_pit?keep_alive=1m

GET test/_stats?filter_path=**.open_contexts

最后一个调用返回

{
  "_all" : {
    "primaries" : {
      "search" : {
        "open_contexts" :2
      }
    },
    "total" : {
      "search" : {
        "open_contexts" :5
      }
    }
  },
  "indices" : {
    "test" : {
      "primaries" : {
        "search" : {
          "open_contexts" :2
        }
      },
      "total" : {
        "search" : {
          "open_contexts" :5
        }
      }
    }
  }
}

正如您在此所见,我们打开的上下文与主分片的数量一样多,但与常规搜索一样,上下文分布在主分片和副本分片之间。

您可以通过下面的例子了解时间点查询如何运行:

PUT test/_doc/1
{
  "name" :"Alex"
}

PUT test/_doc/2?refresh
{
  "name" :"David"
}

# 记下 id 并在下面再次使用
POST test/_pit?keep_alive=1m

DELETE test/_doc/1

# 这将返回 David doc
GET /_search
{
  "size":1,
  "from":0,
  "query": {
    "match_all": {}
  },
  "pit": {
        "id": "ID_RETURNED_FROM_PIT_REQUEST",
        "keep_alive":"1m"
  },
  "sort": [
    {
      "name.keyword": {
        "order": "desc"
      }
    }
  ]
}

# 这将返回 Alex doc
# 因为时间点读取器比这次删除更早
GET /_search
{
  "size":1,
  "query": {
    "match_all": {}
  },
  "pit": {
        "id": "ID_RETURNED_FROM_PIT_REQUEST",
        "keep_alive":"1m"
  },
  "sort": [
    {
      "name.keyword": {
        "order": "desc"
      }
    }
  ],
  "search_after" : ["David", 1]
}

上面的代码片段在创建时间点读取器之后会执行文档的删除。所以,不论何时您采用添加时间点的方式运行搜索请求,被删除的文档都会成为结果集的一部分。

但精彩不止如此!除了添加时间点架构以外,Elasticsearch 7.12 版还有其他改进之处。通过将分片的上下文 id 纳入考虑范围, Elasticsearch 建立了一种机制,可以在原始分片副本不再可用的情况下,在另一个分片副本上重新尝试时间点查询。不过,这一机制只会在两个分片都含有完全相同的段时才会发挥作用,只适用于可搜索快照或只读数据。

并且,与可搜索滚动的一样,Elasticsearch 客户端将为时间点提供帮助工具。

那么,现在应该一直使用时间点吗?关于滚动搜索的相同规则仍然适用。如果对于一个不断变化的索引有着很高的搜索负载,那么为每个请求创建一个新的时间点查询恐怕并不是一个好的主意,因为相当多的资源需要保持开放状态。但是,您可以通过使用一个后台进程每隔几分钟创建一个时间点 id 并将其用于所有搜索请求的方式来避免这种情况。通过这种方式,可以对所有请求保持一致的数据视图,代价就是不会考虑最新的数据。开发者已经规划了更进一步的改进方案,例如在使用时间点读取器时,结合切片查询

总结

您现在理解为何时间点是“有状态”搜索的重要组成部分,因为针对相同的时间点数据集运行不同的查询对于正在提取的数据的一致性非常重要,无论是用于分析工作还是执行像 EQL 这样的查询语言。

如果关于时间点您还有其他疑问,请通过我们的讨论论坛Elastic 社区 Slack 联系我们。当然,如果您想现在就试用时间点,可在 Elastic Cloud 上快速部署一个集群。