首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >驯服PUNK:ES|QL如何查询Elasticsearch从未告知的字段

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

原创
作者头像
点火三周
发布2026-08-28 15:35:19
发布2026-08-28 15:35:19
110
举报
文章被收录于专栏:Elastic Stack专栏Elastic Stack专栏

如何让一个分析查询引擎使用它无法预知存在的数据?你“只需”读取查询语句,因为用户请求的所有信息都明明白白地写在那里。对吧?

在Elasticsearch 9.5中,Elasticsearch查询语言(ES|QL)查询不再因字段不在映射中而失败。新的unmapped_fields设置允许查询从_source加载值,或用null填充,这样即使底层索引发生变化且某个字段丢失,查询也能继续工作,并且无需重新索引即可使用未映射的数据。以下是我们的构建过程:设计选择、边缘情况(包括我们昵称为PUNK的一类字段),以及让我们有信心将其以通用可用性(GA)发布的测试策略。

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

你使用ES|QL查询构建了一个可视化。你不断优化它,查询也变得越来越复杂。你已经有15个链式命令,而且还在增加,但它确实做到了你想要的效果。它运行良好,你的仪表盘也很有用。

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

或者,你有一个已经相当大的索引,在基于它构建ES|QL查询时,你意识到想要使用索引文档中的某个字段,但该字段不幸地从未被纳入索引映射。你可以重新索引数据,但这需要花费数小时。

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

如果你的查询如下所示:

代码语言:javascript
复制
FROM index | EVAL uppercased = TO_UPPER(some_field)

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

你可以使用unmapped_fields设置,改为用null填充some_field,或从文档的_source中读取它,如下所示:

代码语言:javascript
复制
// 用null填充some_fieldSET unmapped_fields="NULLIFY";FROM index | EVAL uppercased = TO_UPPER(some_field)
// 从_source读取数据SET unmapped_fields="LOAD";FROM index | EVAL uppercased = TO_UPPER(some_field)

ES|QL如何通过字段能力(field caps)解析查询

在深入探讨unmapped_fields的内部机制之前,我们得先了解ES|QL通常是如何解析查询的。考虑上面的查询:

代码语言:javascript
复制
FROM index | EVAL uppercased = TO_UPPER(some_field)

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

ES|QL查询解析流程:分析器检查索引映射,未解析的字段以“未知列”错误失败
ES|QL查询解析流程:分析器检查索引映射,未解析的字段以“未知列”错误失败

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

在典型的写时模式(schema-on-write)中,Elasticsearch集群为其各自的索引维护映射。作为第一步,ES|QL向字段能力端点发起内部请求,以确定index拥有哪些字段。然后,它将查询连同字段能力响应一起传递给查询规划器,查询规划器主要由查询分析器(与文本字段的分析器无关)和查询优化器组成。分析器负责理解原始名称(如some_field),并识别它们是否对应索引字段(或不对应)。如果一切顺利,查询会被传递给优化器,优化器会为了提高效率重写查询,然后再交给计算引擎执行。

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

让我们聚焦分析器。解析后的查询以树状结构表示,分析器逐步重写它,一次一个命令,直到所有引用都得到解析或无法解析。

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

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

解析后的树实际上是一个链,看起来像这样:

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

然后分析器向上遍历查询树,尝试解析每个命令中使用的字段名。

这是我们在测试和调试时表示解析树的简化版本。链的底部对应FROM命令,包含我们从字段能力端点获取的所有已知映射字段的列表。({f}后缀用于标记实际映射的字段,以便后续更好区分。)

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

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

代码语言:javascript
复制
Eval[TOUPPER(?unmapped_field) AS uppercased_unmapped]\_Eval[TOUPPER(mapped_field{f}) AS uppercased_mapped] // mapped_field: resolved!  \_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)来表示:

代码语言:javascript
复制
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可以正确解析:

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

现在查询计划已完全解析,可以传递给常规的优化-执行流水线。除了实际的值提取机制,其他一切保持不变。从示意图上看,工作流程如下:

ES|QL未映射字段流程:分析器在未解析字段上使用NULLIFY或LOAD重试,而不是直接失败
ES|QL未映射字段流程:分析器在未解析字段上使用NULLIFY或LOAD重试,而不是直接失败

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

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

代码语言:javascript
复制
PUT /index{                                   "mappings": {    "dynamic": false,    "properties": {      "mapped_field": {"type": "keyword"}    }  }}
POST /index/_doc?refresh{  "mapped_field":"foo"  "unmapped_field": "bar"}

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

代码语言:javascript
复制
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

代码语言:javascript
复制
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

检查分析器的重写步骤

如果你想查看查询分析器对解析树做了什么,可以记录查询重写步骤,如下所示:

代码语言:javascript
复制
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. 我们可以禁用分析器中的验证,让计算引擎盲目地将未映射字段传递过各个计算步骤。

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

相比之下,我们选择的方法与ES|QL现有的优化流水线完美兼容。

权衡之处在于,分析器必须基于实际索引映射(从字段能力端点获取)以及查询中使用的额外字段来正确推断模式。

这并不总是直截了当的。主要有两个挑战:

  1. 可以使用许多不同的查询形状和命令。该机制需要检测未映射字段,更新正确的FROM命令,并在所有情况下正确地将新字段传递过半解析的计划。
  2. 我们需要处理许多不同的映射,尤其需要确保我们的功能在映射随时间变化时也能正确工作。

接下来,我们将重点讨论LOAD,尽管一些问题(通常少很多)也适用于NULLIFY

对于LOOKUP JOIN和FORK,从哪个索引加载未映射字段

为了简要说明第一个问题,这里列举一些我们需要做出的选择:

  • 当使用查找连接时,我们从哪个索引加载?这个? FROM index| LOOKUP JOIN lookup-index ON match_field| EVAL uppercased_unmapped = TO_UPPER(unmapped_field) unmapped_field不能同时归属于两个索引。我们选择了index,因为我们预计其映射比查找索引的映射变化更频繁。
  • 类似地,如何处理子查询和视图,或者FORK命令?在以下示例中: FROM index| FORK (EVAL uppercased_unmapped = TO_UPPER(unmapped_field)) (WHERE true) 一个分支分支触发了未映射字段的加载。它是否也存在于另一个分支中?(是的,应该是,但这一点并不明显,特别是如果两个FORK被独立的子查询替换时,情况就不一样了。)

保持查询工作的两个原则

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

  • 在默认模式下工作的查询,在使用unmapped_fields=”NULLIFY””LOAD”时通常仍应工作。
  • 当所有字段都已映射时工作的查询,在某个字段变为未映射时,通常仍应能通过NULLIFYLOAD工作,反之亦然。

未映射字段的类型与无意中的类型冲突

让我们谈谈数据类型,看看这会带来怎样的复杂性。首先,当使用unmapped_fields=”LOAD”时,我们需要为未映射字段假设一个数据类型。我们选择了KEYWORD,这允许我们在从_source读取时避免类型冲突。一个文档可能包含”unmapped_field”: “foo”,另一个文档可能包含”unmapped_field”: 123.4。这没问题,因为我们把两者都当作字符串处理。

然而,当非KEYWORD类型的字段恰好变为未映射时,这就违反了第二个原则。考虑这个查询:

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

如果some_field变为未映射,我们将不得不假设该字段是KEYWORD类型,查询将因类型冲突而失败。

类型冲突并非新鲜事,可以通过在查询中使用显式类型转换来解决,如下所示:

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

如果ES|QL能自动推断出有用的类型进行转换那就太好了,但这属于未来的工作。

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

除了完全未映射的字段,部分未映射的字段无处不在,也应该能通过LOAD正常工作。让我们看一个使用多个索引的查询。

假设存在索引indexindex_without_some_field,分别包含以下文档:

代码语言:javascript
复制
// index1{  "some_field": "foo"}
// index2{  "some_field": "bar"}

现在考虑查询:

代码语言:javascript
复制
FROM index, index_without_some_field

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

代码语言:javascript
复制
some_field------------- foo null

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

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

代码语言:javascript
复制
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时,情况就不那么清晰了。这类字段在找到最佳解决方案之前给我们带来了很多麻烦,因此它们的缩写非常贴切:partially unmapped non-keyword fields,即PUNK。

不幸的是,PUNK远非罕见。例如,对PUNK进行过滤是非常自然的:

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

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

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

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

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

但这远非理想。即使在没有NULLIFYLOAD时工作正常的查询,通常也会有_一些_PUNK;未映射的字段会被简单地当作null处理。如果LOAD要求显式类型转换,那么两个指导原则都被违反了。

隐式转换为映射类型

解决方案是引入隐式转换为映射类型。在这种情况下,我们知道some_fieldindex中是INTEGER,因此我们基本上将其视为用户写了以下内容:

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

这意味着没有LOAD时能工作的查询,使用LOAD后仍然能工作。(ES|QL甚至可能给你更多数据,因为我们从_source加载了PUNK的未映射分支。)当一个字段在部分(但不是全部)索引中变为未映射时,以前在该字段完全映射时能工作的查询,也无需以任何方式修改查询即可继续工作。

行为

默认

NULLIFY

LOAD

查询中的未映射字段

查询失败

查询运行

查询运行

返回的值

null

从_source读取

假设的类型

NULL

KEYWORD

部分未映射字段(PUNK)

未映射分支为null

未映射分支为null

转换为映射类型

下推优化

完全

完全

在完全映射的节点上

不要丢弃一切:在未映射字段下保持ES|QL查询优化

还有一件事需要做好:确保在LOAD下优化仍然正确工作。考虑之前的查询:

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

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

对于这个查询,在计算引擎中评估过滤器需要从索引中取出每个文档,也就是全扫描,非常慢。如果some_field在两个索引中都映射为INTEGER,我们将改为执行一个Lucene查询,如下所示:

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

这样计算引擎就不必单独加载每个文档并检查它是否匹配过滤器。some_field <= 10的文档永远不会从Lucene索引中取出,这对于这种过滤非常高效。很好。

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

然而,如果some_fieldindex_without_some_field中未映射,那么使用相同的Lucene查询来缩小文档范围是错误的,因为Lucene将未映射的some_field解释为null,因此来自index_without_some_field的文档永远不会匹配。这个边缘情况很容易被忽略,而且类似的下推方式有多种变体,这并没有帮助。例如,在查询中:

代码语言:javascript
复制
FROM index, index_without_some_field | STATS COUNT(some_field)

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

这意味着此类优化不能应用于未映射字段。如果查询使用了数百个索引,而只有一个索引恰好没有映射some_field,导致整个查询运行未经优化,那将令人失望。

局部优化器如何恢复快速路径

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

  1. 首先,在处理_query请求的节点上运行一个初步优化器。
  2. 然后,在我们分散到的每个节点上运行第二个局部优化器,因为我们需要从其分片中获取文档。

初始优化后的工作流程更像这样:

如果当前节点恰好在其所有分片中都映射了some_field,局部优化器会检测到这种情况,并将some_field视为任何其他完全映射的字段,包括执行Lucene查询以大幅缩小需要处理的数据集。实际上,数据节点会分批处理LIMIT查询(例如:

代码语言:javascript
复制
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下复用规范测试

方便的是,ES|QL拥有大量的测试查询及其预期结果集;我们称之为规范测试,因为它们是用简单的文本规范语言编写的,大致如下:

代码语言:javascript
复制
simpleEvalrow a = 1 | eval b = 2;
a:integer | b:integer1         | 2;

这允许我们通过引入细微变化,从现有测试中创建新测试。例如,任何在没有SET unmapped_fields=”...”的情况下运行的现有测试,当使用SET unmapped_fields=”NULLIFY”运行时,应该产生完全相同的结果。

这也有助于在开发早期发现重大问题,尤其是对于NULLIFYLOAD设置对查询含义的改变要大得多,限制了这种方法的有效性。然而,ES|QL还使用了我们称之为生成式测试的方法:我们将随机命令串在一起,运行查询,然后检查服务器是否报告了bug。这种方法无法确认结果的正确性,但极大地帮助发现了那些未能正确工作并导致某种错误的查询类型。(基于属性的测试将是未来的改进方向,通过将查询与参考实现进行对比来检查结果正确性。)

测试不同映射下的类型冲突

最终,最重要的测试维度之一是在同一个查询中使用不同索引和不同映射。(回想一下,上面我们是如何处理类型冲突,最终为PUNK找到一个稳健方案的?事情并没有结束;LOAD下所有类型的类型冲突都更加复杂。)由于我们无法自动生成正确的预期结果,ES|QL的测试套件不得不增加超过10,000行CSV规范测试。幸运的是,添加此类测试非常适合AI代理,大大减少了工作量。(当然,测试结果仍然由人工审查。)

所有测试策略共同为我们提供了信心,将unmapped_fields以GA版本发布在Elasticsearch 9.5中。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 为什么当字段未映射时ES|QL查询会失败
  • ES|QL如何通过字段能力(field caps)解析查询
    • 字段能力如何告诉ES|QL哪些字段存在
    • 分析器如何在查询计划中解析字段名
  • unmapped_fields的LOAD和NULLIFY如何工作
    • 将未映射字段添加到查询计划
    • 示例:使用SET指令启用未映射字段
    • 检查分析器的重写步骤
  • 为什么我们必须推断模式(schema)
    • 对于LOOKUP JOIN和FORK,从哪个索引加载未映射字段
    • 保持查询工作的两个原则
    • 未映射字段的类型与无意中的类型冲突
    • 部分未映射字段的类型冲突,或:让PUNK表现良好
    • 什么使字段成为PUNK
    • 隐式转换为映射类型
  • 不要丢弃一切:在未映射字段下保持ES|QL查询优化
    • 为什么对未映射字段进行过滤器下推是不安全的
    • 局部优化器如何恢复快速路径
  • 现在能正常工作了吗?在所有ES|QL查询形状上测试unmapped_fields
    • 在unmapped_fields下复用规范测试
    • 测试不同映射下的类型冲突
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档