驯服 PUNKs:ES|QL 如何查询 Elasticsearch 从未被告知的字段

作者:来自 Elastic Alexander Spies

在 Elasticsearch 9.5 中,ES|QL 可以查询未映射的字段。它会从 _source 中读取这些字段,或者返回 null,因此即使某个字段从映射中消失,查询仍然可以正常工作,从而避免需要数小时的重新索引。

亲自体验 Elasticsearch:深入了解 Elasticsearch Labs 仓库中的示例 notebooks,开始 免费云试用,或者现在就在本地机器上尝试 Elastic

如何让一个分析查询引擎使用它无法知道存在的数据?你 "只需要" 读取查询,因为用户请求的所有内容都已经写在查询里了。对吧?

在 Elasticsearch 9.5 中,当字段不在映射中时,Elasticsearch Query Language(ES|QL)查询不再失败。新的`unmapped_fields` 设置允许查询从 _source 中加载值,或者使用 null 填充,因此即使底层索引发生变化并导致某个字段消失,查询仍然可以正常工作,并且可以在无需重新索引的情况下使用未映射的数据。下面介绍我们是如何构建这一功能的:其中的设计选择和边界情况(包括一类我们戏称为 PUNKs 的字段),以及让我们有信心在正式发布(GA)时推出这一功能的测试策略。

为什么字段未映射时 ES|QL 查询会失败

你使用 ES|QL 查询创建了一个可视化。你不断对它进行优化,查询也越来越长。你已经串联了 15 个命令,而且还在增加,但它恰好 能够完成你想要的事情。它能正常工作,而且你的 dashboard 非常有用

你的查询使用了一个远程集群中的索引,比如 my-remote:logs-foo。但实际上,logs-foo 是一个别名,而且在某个时候,远程集群让它指向了另一个底层索引。新索引缺少查询中使用的某个字段,于是你的查询和可视化都失效了。

或者,你可能已经有了一个相当大的索引,在此基础上构建 ES|QL 查询时,你发现希望使用索引文档中的某个字段,但遗憾的是,这个字段从来没有被加入索引映射。你可以重新索引数据,但这需要数小时。

ES|QL 的 unmapped_fields 设置就是为了处理这类情况。

如果你的查询如下所示:

复制代码
FROM index | EVAL uppercased = TO_UPPER(some_field)

some_field 未映射,ES|QL 的默认行为是验证失败,并返回验证异常。

你可以使用 unmapped_fields 设置,让 some_field 使用 null 填充,或者从文档的 _source 中读取它,例如:

复制代码
// Fill some_field with nulls
SET unmapped_fields="NULLIFY";
FROM index | EVAL uppercased = TO_UPPER(some_field)

// Read data from _source
SET unmapped_fields="LOAD";
FROM index | EVAL uppercased = TO_UPPER(some_field)

ES|QL 如何通过字段能力解析查询

在深入了解 unmapped_fields 的内部工作原理之前,我们需要先了解 ES|QL 通常是如何解析查询的。让我们考虑上面的查询:

复制代码
FROM index | EVAL uppercased = TO_UPPER(some_field)

我们说过,如果 some_field 不在 index 的映射中,ES|QL 就会拒绝该查询。它是如何做出这个判断的?

字段能力如何告诉 ES|QL 哪些字段存在

按照典型的写入时定义 schema 的方式,Elasticsearch 集群会为各自的索引维护映射。第一步,ES|QL 会向字段能力 API 发起内部请求,以确定 index 中有哪些字段。然后,它会将查询连同字段能力响应一起传递给查询规划器。查询规划器本质上由查询分析器(与文本字段的 analyzer 无关)和查询优化器组成。分析器会解析 some_field 这样的原始名称,并判断它们是否对应索引字段。如果一切顺利,查询随后会被传递给优化器,由优化器重写查询以提高效率,之后再交给计算引擎执行。

分析器如何解析查询计划中的字段名称

让我们深入看看分析器。解析后的查询会以树形结构表示,分析器会一次处理一个命令,对其进行部分重写,直到它解析完所有引用,或者无法继续解析。

为了说明这一点,我们使用一个稍微复杂一些的查询,看看分析器会如何解析它:

复制代码
FROM index
| EVAL uppercased_mapped = TO_UPPER(mapped_field)
| EVAL uppercased_unmapped = TO_UPPER(unmapped_field)

这里解析后的树实际上是一条链,大致如下:

复制代码
Eval[TOUPPER(?unmapped_field) AS uppercased_unmapped]
  \_Eval[TOUPPER(?mapped_field) AS uppercased_mapped]
    \_From[mapped_field{f}]

然后,分析器会沿着查询树向上移动,尝试解析每个命令中使用的字段名称。

这是我们在测试和调试时表示解析树的一种简化形式。链的底部对应 FROM 命令,其中包含我们已知的所有已映射字段,这些字段来自字段能力 API。({f} 后缀表示一个实际已映射的字段,以便后面进行区分。)

上面的两个 EVAL 节点对应其余命令,它们的字段仍然未解析,通过名称前面的问号 ? 来表示。此时,分析器仍然需要检查它们是否对应现有的索引字段。

对于定义 uppercased_mappedEVAL,分析器可以看到前一个命令输出了 mapped_field,因此可以将未解析的 ?mapped_field 标记替换为真正的字段引用:

复制代码
Eval[TOUPPER(?unmapped_field) AS uppercased_unmapped]
  \_Eval[TOUPPER(mapped_field{f}) AS uppercased_mapped] // mapped_field:已解析!
    \_From[mapped_field{f}]

接下来,它会遇到最顶层的 EVAL,该节点定义了 uppercased_unmapped。之前的树节点只产生两个字段:[mapped_field, uppercased_mapped]。因此,引用 ?unmapped_field 仍然无法解析。此时,我们会停止解析,并向用户返回验证异常。

unmapped_fields 的 LOAD 和 NULLIFY 如何工作

将未映射字段添加到查询计划

当使用 unmapped_fields="NULLIFY""LOAD" 时,我们会采取不同的处理方式:假装该字段实际上存在于索引中。分析器会将 unmapped_field 添加到 From 节点,并将其标记为未映射,以此告诉计算引擎,它需要从 _source 中读取该字段,或者使用 null 填充。我们使用 {u}(代表 unmapped,即未映射)来表示:

复制代码
Eval[TOUPPER(?unmapped_field) AS uppercased_unmapped]
  \_Eval[TOUPPER(mapped_field{f}) AS uppercased_mapped]
    \_From[mapped_field{f}, unmapped_field{u}]  // 添加 unmapped_field

修改 From 后,分析器就可以继续尝试解析最顶层的 Eval 节点。它发现上游节点产生了字段 [mapped_field, unmapped_field, uppercased_mapped],因此可以正确解析 unmapped_field

复制代码
Eval[TOUPPER(unmapped_field{u}) AS uppercased_unmapped] // 已解析!
  \_Eval[TOUPPER(mapped_field{f}) AS uppercased_mapped]
    \_From[mapped_field{f}, unmapped_field{u}]

现在,查询计划已经完全解析,可以进入常规的优化和执行流程。除了实际的值提取机制之外,其他部分都保持不变。整个工作流程可以概括如下:

示例:使用 SET 指令启用未映射字段

举个例子,让我们启动一个集群,并创建一个使用非动态映射的索引。

复制代码
PUT /index
{                                 
  "mappings": {
    "dynamic": false,
    "properties": {
      "mapped_field": {"type": "keyword"}
    }
  }
}

POST /index/_doc?refresh
{
  "mapped_field":"foo"
  "unmapped_field": "bar"
}

我们可以运行上面的示例查询:

复制代码
POST /_query
{
  "query": """
           FROM index
           | EVAL uppercased_mapped = TO_UPPER(mapped_field)
           | EVAL uppercased_unmapped = TO_UPPER(unmapped_field)
           """
}

这应该会返回以下错误消息:

复制代码
Unknown column [unmapped_field], did you mean [mapped_field]?

为了让查询正常工作,我们可以在前面加上 SET unmapped_fields="...";,其中使用 LOADNULLIFY

复制代码
POST /_query
{
  "query": """
           SET unmapped_fields="LOAD";
           FROM index
           | EVAL uppercased_mapped = TO_UPPER(mapped_field)
           | EVAL uppercased_unmapped = TO_UPPER(unmapped_field)
           """
}

 mapped_field  |unmapped_field |uppercased_mapped|uppercased_unmapped
---------------+---------------+-----------------+-------------------
foo            |bar            |FOO              |BAR

检查分析器的重写步骤

如果你想查看查询分析器对解析树进行了哪些操作,可以像下面这样记录查询重写步骤:

复制代码
PUT /_cluster/settings"
{
  "transient" : {
    "logger.org.elasticsearch.xpack.esql.analysis.Analyzer.changes": "TRACE"
  }
}

这会记录一行包含 Rule rules.ResolveUnmapped applied with change... 的日志。你会看到,如上所述,unmapped_field 被添加到了解析树的底部。

为什么我们必须推断 schema

当然,这并不是处理未映射字段的唯一方法。下面是一些替代方案:

  1. 我们也可以扫描或探测 index 中的文档,以确定它们的 _source 中确实存在 unmapped_field

  2. 我们可以禁用分析器中的验证,让计算引擎盲目地将未映射字段传递给各个计算步骤。

第一种替代方案会预先执行更多工作,以了解索引的实际 schema,因此通常会增加延迟。它无法扩展到大型、高度分布式的数据集。第二种替代方案不可行,因为这意味着需要大规模改变 ES|QL 计算引擎的构建方式,因为计算引擎会在不同算子之间传递具有固定列的数据流。

相比之下,我们选择的方法与 ES|QL 现有的优化流程能够很好地兼容。

代价是,分析器必须根据实际的索引映射(从字段能力 API 获取)以及查询内部使用的额外字段,正确地推断 schema。

这并不总是简单的。主要有两个挑战:

  1. 可以使用许多不同的查询形态和命令。该机制需要在所有情况下都能够检测未映射字段、更新正确的 FROM 命令,并正确地将新字段传递给处于部分解析状态的查询计划。

  2. 我们需要处理许多不同的映射,而且除此之外,还需要确保我们的功能在映射随时间发生变化时也能正常工作。

下面我们将重点讨论 LOAD,尽管其中一些问题(通常要少得多)同样适用于 NULLIFY

LOOKUP JOIN 和 FORK 应该从哪个索引加载未映射字段

为了简要说明第一个问题,下面是一些我们需要做出的选择:

  • 使用 lookup joins时,我们应该从哪个索引加载?是这个吗?

    复制代码
    FROM index
    | LOOKUP JOIN lookup-index ON match_field
    | EVAL uppercased_unmapped = TO_UPPER(unmapped_field)

    unmapped_field 无法同时归属于两个索引。我们选择了 index,因为与 lookup 索引相比,我们预计这里的映射发生变化的频率更高。

  • 类似地,我们如何处理子查询和视图,或者`FORK` 命令?在下面的查询中:

    复制代码
    FROM index
    | FORK (EVAL uppercased_unmapped = TO_UPPER(unmapped_field))
    (WHERE true)

    一个 fork 分支触发了未映射字段的加载。那么另一个 fork 分支中是否也存在这个字段?(是的,应该存在,但这一点并不明显,而且如果将两个 FORK 替换为相互独立的子查询,则情况并非如此。)

让查询持续工作的两个原则

第二个问题,即映射的多样性及其随时间的演变,是复杂性的一个更大来源。我们努力遵循两个基本的可用性原则:

  • 在默认模式下可以正常工作的查询,使用 unmapped_fields="NULLIFY""LOAD" 时通常也应该能够正常工作。

  • 当所有字段都已映射时可以正常工作的查询,如果某个字段变成未映射,使用 NULLIFYLOAD 时通常也应该继续正常工作,反之亦然。

未映射字段的类型以及无意产生的类型冲突

让我们通过数据类型来看看这会带来什么复杂性。首先,当使用 unmapped_fields="LOAD" 时,我们需要为未映射字段假定一种数据类型。我们选择了 KEYWORD,这样从 _source 中读取数据时就可以避免类型冲突。一个文档可以包含 "unmapped_field": "foo",另一个文档可以包含 "unmapped_field": 123.4。这没有问题,因为我们会将两者都视为字符串。

然而,当一个非 KEYWORD 字段恰好变成未映射时,这就违反了第二个原则。考虑下面这个查询:

复制代码
SET unmapped_fields="LOAD";
FROM index | WHERE some_field > 10

如果 some_field 变成未映射,我们就必须假定它是 KEYWORD 类型,而查询会因为类型冲突而失败。

类型冲突并不是新问题,可以在查询中使用显式类型转换来处理,例如:

复制代码
SET unmapped_fields="LOAD";
FROM index | WHERE some_field::integer > 10

如果 ES|QL 能够直接推断出一个有用的类型来进行转换,那当然很好,但这属于未来的工作。

部分未映射字段的类型冲突,或者:让 PUNKs 表现良好

除了完全未映射的字段之外,部分未映射 字段也非常常见,而且它们也应该能够与 LOAD 一起正常工作。让我们看看一个使用多个索引的查询。

假设存在 indexindex_without_some_field 两个索引,其中只包含下面这些文档。

复制代码
// index1
{
  "some_field": "foo"
}

// index2
{
  "some_field": "bar"
}

现在考虑这个查询:

复制代码
FROM index, index_without_some_field

并假设 some_fieldindex_without_some_field 中未映射。这会返回:

复制代码
some_field
-------------
foo
null

因为 ES|QL 默认不会加载未映射字段。

当然,当设置 unmapped_fields="LOAD" 时,我们希望从 index_without_some_field_source 中加载:

复制代码
SET unmapped_fields="LOAD";
FROM index, index_without_some_field

some_field
-------------
foo
bar             // 从 _source 加载

与完全未映射字段一样,当 some_fieldindex 中映射为 KEYWORD 时,情况很简单。从 index_without_some_field_source 中加载时,我们也将该字段视为 KEYWORD,因此不会产生冲突。

什么样的字段才是 PUNK

some_field 部分未映射,而已映射的部分属于 KEYWORD 以外的类型时,情况就不那么明确了。这类字段给我们带来了很多麻烦,直到我们找到最佳解决方案,而它们的缩写也非常贴切:p artially u nmapped n on-k eyword fields,即部分未映射的非 KEYWORD 字段,简称 PUNKs。

遗憾的是,PUNKs 远非什么罕见情况。例如,对 PUNK 进行过滤就非常自然:

复制代码
SET unmapped_fields="LOAD";
FROM index, index_without_some_field | WHERE some_field > 10

如果 some_fieldindex 中映射为 INTEGER,那么类型冲突如下:

  • index 中映射为 INTEGER

  • index_without_some_field 中未映射,因此被视为 KEYWORD

同样可以通过提供显式类型转换来手动解决:

复制代码
SET unmapped_fields="LOAD";
FROM index, index_without_some_field | WHERE some_field::integer > 10

但这远远无法接受。即使是不使用 NULLIFYLOAD 时能够正常工作的查询,通常也会包含某些 PUNKs;此时,未映射的部分只会被视为 null。如果 LOAD 在这里要求显式类型转换,那么两个指导原则都会被违反。

隐式转换为已映射的类型

解决方案是引入一个向已映射类型进行的隐式类型转换。在这个例子中,我们知道 some_fieldindex 中是 INTEGER,因此我们实际上会将其视为用户写出了:

复制代码
SET unmapped_fields="LOAD";
FROM index, index_without_some_field
| EVAL some_field = some_field::integer
| WHERE some_field > 10

这意味着,不使用 LOAD 时能够正常工作的查询仍然可以继续工作。(ES|QL 甚至可能返回更多数据,因为我们会从 _source 中加载 PUNKs 的未映射部分。)当字段在所有索引中都已映射时能够正常工作的查询,如果该字段在部分索引中变成未映射,也仍然可以正常工作,而无需以任何方式修改查询。

行为 默认 NULLIFY LOAD
查询中的未映射字段 查询失败 查询运行 查询运行
返回的值 null _source 读取
假定类型 不适用 NULL KEYWORD
部分未映射字段(PUNK) 未映射部分为 null 未映射部分为 null 转换为已映射的类型
下推优化 完整 完整 已完全映射的节点逐节点执行

不要全部放弃:使用未映射字段时保留 ES|QL 查询优化

还有一件事需要正确处理,那就是确保 LOAD 下的优化仍然能够正常工作。考虑之前的查询:

复制代码
SET unmapped_fields="LOAD";
FROM index, index_without_some_field | WHERE some_field > 10

ES|QL 的优化器会积极地将这样的 WHERE 过滤器下推,并将其转换为 Lucene 查询,从而避免计算引擎执行不必要的工作。

对于这个查询,如果在计算引擎中执行过滤,就需要从索引中获取每一个文档,也就是说需要进行完整扫描,速度会非常慢。如果 some_field 在两个索引中都映射为 INTEGER,我们则会执行一个类似下面这样的 Lucene 查询:

复制代码
{
  "range": {
    "some_field": {
      "gt": 10,
      "boost": 0.0
    }
  }
}

这样,计算引擎就不需要单独加载每个文档并检查它是否符合过滤条件。some_field <= 10 的文档根本不会从 Lucene 索引中获取,而 Lucene 非常擅长执行这种类型的过滤。这很好。

为什么对未映射字段进行过滤下推是不安全的

然而,如果 some_fieldindex_without_some_field 中未映射,那么使用相同的 Lucene 查询来缩小文档范围就是错误的,因为 Lucene 会将未映射的 some_field 解释为 null,因此 index_without_some_field 中不会有任何文档匹配。

这个边界情况很容易被忽略,而且还有多个类似的下推方式,使问题更加复杂。例如,在下面的查询中:

复制代码
FROM index, index_without_some_field | STATS COUNT(some_field)

计算引擎甚至会将计数操作也下推到 Lucene。同样,只有当 some_field 完全映射时,这才是正确的。

这意味着,这类优化无法应用于未映射字段。如果一个查询使用了数百个索引,而其中只有一个索引恰好没有映射 some_field,却导致整个查询都无法进行优化,那将非常令人失望。

本地优化器如何恢复快速路径

幸运的是,这个问题同样有解决方案。ES|QL 实际上会运行多次优化器:

  1. 首先,在处理 _query 请求的节点上运行一次初步优化器。

  2. 然后,因为我们需要从各个分片获取文档,所以会向所有需要访问的节点进行 fan-out,并在每个节点上运行第二次本地优化器。

初始优化之后的工作流程更接近下面这样:

如果当前节点恰好在所有分片中都映射了 some_field,本地优化器就会检测到这一情况,并像处理其他完全映射的字段一样处理 some_field,包括执行 Lucene 查询,从而大幅缩小需要处理的数据集。实际上,数据节点会以分片批次的方式处理类似下面这样的 LIMIT 查询:

复制代码
SET unmapped_fields="LOAD";
FROM index, index_without_some_field
| WHERE some_field > 10
| LIMIT 1000

这样做是为了避免过早加载过多数据,其中每个批次都会完整运行一次本地优化器。这进一步提高了遇到 some_field 完全映射的批次的可能性,从而允许 ES|QL 执行快速的 Lucene 查询。

现在能正常工作了吗?针对每一种 ES|QL 查询形态测试 unmapped_fields

正如我们从上面的优化器问题中看到的,即使是非常简单的查询,问题也可能隐藏在显而易见的地方。因为 unmapped_fields="LOAD" 会影响每一种查询,因此潜在的 bug 范围实际上覆盖了整个 ES|QL。

因此,要获得良好的测试覆盖率并不容易,这也促使我们不断改进测试策略。

复用带有 unmapped_fields 的 spec 测试

ES|QL 很方便地拥有大量测试查询以及对应的预期结果集;我们称之为 spec tests,因为它们使用一种简单的文本规范语言编写,大致如下:

复制代码
simpleEval
row a = 1 | eval b = 2
;
a:integer | b:integer
1         | 2
;

这让我们可以通过引入细微的变化,从现有测试中创建新的测试。例如,任何一个不使用 SET unmapped_fields="..." 就能运行的现有测试,在使用 SET unmapped_fields="NULLIFY" 运行时,都应该产生完全相同的结果。

这也帮助我们在开发过程的早期发现了重大问题,尤其是 NULLIFY 方面的问题。LOAD 设置会更加显著地改变查询的含义,因此这种方法的作用受到更多限制。不过,ES|QL 还使用了我们称为 generative testing(生成式测试)的方法;也就是说,我们随机串联命令,运行查询,然后检查服务器是否报告 bug。这种方法无法确认结果是否正确,但对于发现无法正常工作的查询类型以及由此产生的某种错误,仍然非常有帮助。(未来可以通过针对参考实现运行这些查询来进一步完善为基于属性的测试。这样也可以检查结果的正确性。)

测试不同映射之间的类型冲突

最终,最重要的测试维度之一,是在同一个查询中使用具有各种不同映射的不同索引。(还记得上面我们为了给 PUNKs 找到可靠的处理方案,必须处理类型冲突吗?这还没有结束;使用 LOAD 时,各种类型冲突都会变得更加复杂。)

由于我们无法自动生成正确的预期结果,ES|QL 的测试套件不得不通过增加超过 10,000 行 CSV spec 测试来扩充。幸运的是,添加这类测试非常适合由 AI agent 完成,这大幅减少了工作量。(当然,测试结果仍然由人工进行审核。)

所有这些测试策略结合起来,让我们对 unmapped_fields 随 Elasticsearch 9.5 正式发布(GA)拥有了充分的信心。

原文:https://www.elastic.co/search-labs/blog/esql-unmapped-fields-deep-dive

相关推荐
拓人间精准客28 分钟前
ToB 销售获客避坑指南:如何用全维度大数据实现降本增效
大数据·数据结构·单例模式
TDengine (老段)32 分钟前
TDengine 应用案例 — 能源与电力监控
大数据·数据库·物联网·能源·时序数据库·tdengine·涛思数据
电商API_1800790524734 分钟前
自动化获取淘宝评论数据技术分享之API调用示例
大数据·运维·人工智能·自动化·网络爬虫
福建佰胜张工38 分钟前
西门子 ULTRAMAT23 分析仪 7MB2337‑0NH10‑3PW1 原理、调试、故障处理与现场运维全记录
大数据·运维·人工智能·自动化
jkyy201441 分钟前
从商品售卖到健康服务,数字化重构大健康品牌会员运营底层逻辑
大数据·人工智能·信息可视化·健康医疗
anxiao_m1 小时前
电商云桌面解决方案哪个好?主流品牌深度测评,选型不踩坑
大数据
存在morning1 小时前
【Flink SQL 学习笔记 二】实时聚合:用窗口和 Watermark 把订单按时段统计
sql·学习·flink
大模型码小白1 小时前
Spring AI 框架中集成 MCP 的完整指南:从服务端到客户端的全流程实践
大数据·运维·数据库·人工智能·python·sql·spring
wear工程师1 小时前
MySQL 死锁怎么排查:把 innodb status 里的 HOLDS 和 WAITING 对上
sql·mysql