如何让一个分析查询引擎使用它无法预知存在的数据?你“只需”读取查询语句,因为用户请求的所有信息都明明白白地写在那里。对吧?
在Elasticsearch 9.5中,Elasticsearch查询语言(ES|QL)查询不再因字段不在映射中而失败。新的unmapped_fields设置允许查询从_source加载值,或用null填充,这样即使底层索引发生变化且某个字段丢失,查询也能继续工作,并且无需重新索引即可使用未映射的数据。以下是我们的构建过程:设计选择、边缘情况(包括我们昵称为PUNK的一类字段),以及让我们有信心将其以通用可用性(GA)发布的测试策略。
你使用ES|QL查询构建了一个可视化。你不断优化它,查询也变得越来越复杂。你已经有15个链式命令,而且还在增加,但它确实做到了你想要的效果。它运行良好,你的仪表盘也很有用。
你的查询使用了远程集群的索引,例如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设置,改为用null填充some_field,或从文档的_source中读取它,如下所示:
// 用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)在深入探讨unmapped_fields的内部机制之前,我们得先了解ES|QL通常是如何解析查询的。考虑上面的查询:
FROM index | EVAL uppercased = TO_UPPER(some_field)我们说过,如果some_field不在index的映射中,ES|QL会拒绝该查询。它是如何做出这个判断的呢?

在典型的写时模式(schema-on-write)中,Elasticsearch集群为其各自的索引维护映射。作为第一步,ES|QL向字段能力端点发起内部请求,以确定index拥有哪些字段。然后,它将查询连同字段能力响应一起传递给查询规划器,查询规划器主要由查询分析器(与文本字段的分析器无关)和查询优化器组成。分析器负责理解原始名称(如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命令,包含我们从字段能力端点获取的所有已知映射字段的列表。({f}后缀用于标记实际映射的字段,以便后续更好区分。)
它上面的两个EVAL节点对应剩余的命令,它们的字段仍未解析,用名称前的问号?表示。此时,分析器仍需检查它们是否对应现有的索引字段。
对于定义uppercased_mapped的EVAL,分析器可以看到前一个命令输出mapped_field,因此未解析的?mapped_field标记可以被替换为真实的字段引用:
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=”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}]现在查询计划已完全解析,可以传递给常规的优化-执行流水线。除了实际的值提取机制,其他一切保持不变。从示意图上看,工作流程如下:

举个例子,让我们启动一个集群,并创建一个非动态映射的索引。
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=”...”;,使用LOAD或NULLIFY:
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被添加到解析树的底部,正如上面描述的那样。
当然,处理未映射字段并非只有这一种方法。以下是一些替代方案:
index中的文档,以确定它们的_source中确实包含unmapped_field。第一种替代方案预先做了更多工作来理解索引的实际模式,因此通常会增加延迟。它无法扩展到大型、高度分布式的数据集。第二种替代方案不可行,因为它意味着对ES|QL计算引擎的构建方式进行大规模更改,因为计算引擎在操作符之间传递具有固定列的数据流。
相比之下,我们选择的方法与ES|QL现有的优化流水线完美兼容。
权衡之处在于,分析器必须基于实际索引映射(从字段能力端点获取)以及查询中使用的额外字段来正确推断模式。
这并不总是直截了当的。主要有两个挑战:
FROM命令,并在所有情况下正确地将新字段传递过半解析的计划。接下来,我们将重点讨论LOAD,尽管一些问题(通常少很多)也适用于NULLIFY。
为了简要说明第一个问题,这里列举一些我们需要做出的选择:
unmapped_field不能同时归属于两个索引。我们选择了index,因为我们预计其映射比查找索引的映射变化更频繁。FORK命令?在以下示例中:
FROM index| FORK (EVAL uppercased_unmapped = TO_UPPER(unmapped_field)) (WHERE true)
一个分支分支触发了未映射字段的加载。它是否也存在于另一个分支中?(是的,应该是,但这一点并不明显,特别是如果两个FORK被独立的子查询替换时,情况就不一样了。)第二个问题,映射的多样性及其随时间演变,是复杂性的更大驱动因素。我们努力遵循两个基本可用性原则:
unmapped_fields=”NULLIFY”和”LOAD”时通常仍应工作。NULLIFY和LOAD工作,反之亦然。让我们谈谈数据类型,看看这会带来怎样的复杂性。首先,当使用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能自动推断出有用的类型进行转换那就太好了,但这属于未来的工作。
除了完全未映射的字段,部分未映射的字段无处不在,也应该能通过LOAD正常工作。让我们看一个使用多个索引的查询。
假设存在索引index和index_without_some_field,分别包含以下文档:
// index1{ "some_field": "foo"}
// index2{ "some_field": "bar"}现在考虑查询:
FROM index, index_without_some_field假设some_field在index_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_field在index中映射为KEYWORD时,情况很简单。当从index_without_some_field的_source加载时,我们也将该字段视为KEYWORD,因此没有冲突。
当some_field部分未映射,且映射的字段类型不是KEYWORD时,情况就不那么清晰了。这类字段在找到最佳解决方案之前给我们带来了很多麻烦,因此它们的缩写非常贴切:partially unmapped non-keyword fields,即PUNK。
不幸的是,PUNK远非罕见。例如,对PUNK进行过滤是非常自然的:
SET unmapped_fields="LOAD";FROM index, index_without_some_field | WHERE some_field > 10如果some_field在index中映射为INTEGER,类型冲突如下:
index中映射为INTEGER。index_without_some_field中未映射,因此被视为KEYWORD。这可以通过提供显式类型转换来手动解决:
SET unmapped_fields="LOAD";FROM index, index_without_some_field | WHERE some_field::integer > 10但这远非理想。即使在没有NULLIFY和LOAD时工作正常的查询,通常也会有_一些_PUNK;未映射的字段会被简单地当作null处理。如果LOAD要求显式类型转换,那么两个指导原则都被违反了。
解决方案是引入隐式转换为映射类型。在这种情况下,我们知道some_field在index中是INTEGER,因此我们基本上将其视为用户写了以下内容:
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 | 转换为映射类型 |
下推优化 | 完全 | 完全 | 在完全映射的节点上 |
还有一件事需要做好:确保在LOAD下优化仍然正确工作。考虑之前的查询:
SET unmapped_fields="LOAD";FROM index, index_without_some_field | WHERE some_field > 10ES|QL的优化器会积极下推此类WHERE过滤器,并将其转换为Lucene查询,这样计算引擎就不会执行不必要的工作。
对于这个查询,在计算引擎中评估过滤器需要从索引中取出每个文档,也就是全扫描,非常慢。如果some_field在两个索引中都映射为INTEGER,我们将改为执行一个Lucene查询,如下所示:
{ "range": { "some_field": { "gt" : 10, "boost" : 0.0 } }}这样计算引擎就不必单独加载每个文档并检查它是否匹配过滤器。some_field <= 10的文档永远不会从Lucene索引中取出,这对于这种过滤非常高效。很好。
然而,如果some_field在index_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实际上有多个优化器运行:
_query请求的节点上运行一个初步优化器。初始优化后的工作流程更像这样:

如果当前节点恰好在其所有分片中都映射了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查询。
从上面的优化器问题中可以看出,即使对于非常简单的查询,问题也可能隐藏得很明显。因为unmapped_fields=”LOAD”可能影响每一种查询,所以bug的覆盖面基本上是整个ES|QL。
因此,获得良好的测试覆盖率很棘手,也挑战了我们改进测试策略。
方便的是,ES|QL拥有大量的测试查询及其预期结果集;我们称之为规范测试,因为它们是用简单的文本规范语言编写的,大致如下:
simpleEvalrow a = 1 | eval b = 2;
a:integer | b:integer1 | 2;这允许我们通过引入细微变化,从现有测试中创建新测试。例如,任何在没有SET unmapped_fields=”...”的情况下运行的现有测试,当使用SET unmapped_fields=”NULLIFY”运行时,应该产生完全相同的结果。
这也有助于在开发早期发现重大问题,尤其是对于NULLIFY。LOAD设置对查询含义的改变要大得多,限制了这种方法的有效性。然而,ES|QL还使用了我们称之为生成式测试的方法:我们将随机命令串在一起,运行查询,然后检查服务器是否报告了bug。这种方法无法确认结果的正确性,但极大地帮助发现了那些未能正确工作并导致某种错误的查询类型。(基于属性的测试将是未来的改进方向,通过将查询与参考实现进行对比来检查结果正确性。)
最终,最重要的测试维度之一是在同一个查询中使用不同索引和不同映射。(回想一下,上面我们是如何处理类型冲突,最终为PUNK找到一个稳健方案的?事情并没有结束;LOAD下所有类型的类型冲突都更加复杂。)由于我们无法自动生成正确的预期结果,ES|QL的测试套件不得不增加超过10,000行CSV规范测试。幸运的是,添加此类测试非常适合AI代理,大大减少了工作量。(当然,测试结果仍然由人工审查。)
所有测试策略共同为我们提供了信心,将unmapped_fields以GA版本发布在Elasticsearch 9.5中。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。