ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

驯服 PUNKs:ES|QL 如何查询 Elasticsearch 从未被告知的字段 作者来自 Elastic Alexander Spies在 Elasticsearch 9.5 中ES|QL 可以查询未映射的字段。它会从 _source 中读取这些字段或者返回 null因此即使某个字段从映射中消失查询仍然可以正常工作从而避免需要数小时的重新索引。亲自体验 Elasticsearch深入了解 Elasticsearch Labs 仓库中的示例 notebooks开始 免费云试用或者现在就在本地机器上尝试 Elastic。如何让一个分析查询引擎使用它无法知道存在的数据你 “只需要” 读取查询因为用户请求的所有内容都已经写在查询里了。对吧在 Elasticsearch 9.5 中当字段不在映射中时Elasticsearch Query LanguageES|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_fieldsNULLIFY; FROM index | EVAL uppercased TO_UPPER(some_field) // Read data from _source SET unmapped_fieldsLOAD; 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_mapped的EVAL分析器可以看到前一个命令输出了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_fieldsNULLIFY或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_fieldEval[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...;其中使用LOAD或NULLIFYPOST /_query { query: SET unmapped_fieldsLOAD; 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当然这并不是处理未映射字段的唯一方法。下面是一些替代方案我们也可以扫描或探测index中的文档以确定它们的_source中确实存在unmapped_field。我们可以禁用分析器中的验证让计算引擎盲目地将未映射字段传递给各个计算步骤。第一种替代方案会预先执行更多工作以了解索引的实际schema因此通常会增加延迟。它无法扩展到大型、高度分布式的数据集。第二种替代方案不可行因为这意味着需要大规模改变 ES|QL 计算引擎的构建方式因为计算引擎会在不同算子之间传递具有固定列的数据流。相比之下我们选择的方法与 ES|QL 现有的优化流程能够很好地兼容。代价是分析器必须根据实际的索引映射从字段能力 API 获取以及查询内部使用的额外字段正确地推断schema。这并不总是简单的。主要有两个挑战可以使用许多不同的查询形态和命令。该机制需要在所有情况下都能够检测未映射字段、更新正确的FROM命令并正确地将新字段传递给处于部分解析状态的查询计划。我们需要处理许多不同的映射而且除此之外还需要确保我们的功能在映射随时间发生变化时也能正常工作。下面我们将重点讨论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_fieldsNULLIFY和LOAD时通常也应该能够正常工作。当所有字段都已映射时可以正常工作的查询如果某个字段变成未映射使用NULLIFY和LOAD时通常也应该继续正常工作反之亦然。未映射字段的类型以及无意产生的类型冲突让我们通过数据类型来看看这会带来什么复杂性。首先当使用unmapped_fieldsLOAD时我们需要为未映射字段假定一种数据类型。我们选择了KEYWORD这样从_source中读取数据时就可以避免类型冲突。一个文档可以包含unmapped_field: foo另一个文档可以包含unmapped_field: 123.4。这没有问题因为我们会将两者都视为字符串。然而当一个非KEYWORD字段恰好变成未映射时这就违反了第二个原则。考虑下面这个查询SET unmapped_fieldsLOAD; FROM index | WHERE some_field 10如果some_field变成未映射我们就必须假定它是KEYWORD类型而查询会因为类型冲突而失败。类型冲突并不是新问题可以在查询中使用显式类型转换来处理例如SET unmapped_fieldsLOAD; FROM index | WHERE some_field::integer 10如果 ES|QL 能够直接推断出一个有用的类型来进行转换那当然很好但这属于未来的工作。部分未映射字段的类型冲突或者让 PUNKs 表现良好除了完全未映射的字段之外部分未映射字段也非常常见而且它们也应该能够与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_fieldsLOAD时我们希望从index_without_some_field的_source中加载SET unmapped_fieldsLOAD; FROM index, index_without_some_field some_field ------------- foo bar // 从 _source 加载与完全未映射字段一样当some_field在index中映射为KEYWORD时情况很简单。从index_without_some_field的_source中加载时我们也将该字段视为KEYWORD因此不会产生冲突。什么样的字段才是 PUNK当some_field部分未映射而已映射的部分属于KEYWORD以外的类型时情况就不那么明确了。这类字段给我们带来了很多麻烦直到我们找到最佳解决方案而它们的缩写也非常贴切partiallyunmappednon-keyword fields即部分未映射的非KEYWORD字段简称 PUNKs。遗憾的是PUNKs 远非什么罕见情况。例如对 PUNK 进行过滤就非常自然SET unmapped_fieldsLOAD; FROM index, index_without_some_field | WHERE some_field 10如果some_field在index中映射为INTEGER那么类型冲突如下在index中映射为INTEGER。在index_without_some_field中未映射因此被视为KEYWORD。同样可以通过提供显式类型转换来手动解决SET unmapped_fieldsLOAD; FROM index, index_without_some_field | WHERE some_field::integer 10但这远远无法接受。即使是不使用NULLIFY和LOAD时能够正常工作的查询通常也会包含某些PUNKs此时未映射的部分只会被视为null。如果LOAD在这里要求显式类型转换那么两个指导原则都会被违反。隐式转换为已映射的类型解决方案是引入一个向已映射类型进行的隐式类型转换。在这个例子中我们知道some_field在index中是INTEGER因此我们实际上会将其视为用户写出了SET unmapped_fieldsLOAD; FROM index, index_without_some_field | EVAL some_field some_field::integer | WHERE some_field 10这意味着不使用LOAD时能够正常工作的查询仍然可以继续工作。ES|QL 甚至可能返回更多数据因为我们会从_source中加载 PUNKs 的未映射部分。当字段在所有索引中都已映射时能够正常工作的查询如果该字段在部分索引中变成未映射也仍然可以正常工作而无需以任何方式修改查询。行为默认NULLIFYLOAD查询中的未映射字段查询失败查询运行查询运行返回的值无null从_source读取假定类型不适用NULLKEYWORD部分未映射字段PUNK未映射部分为null未映射部分为null转换为已映射的类型下推优化完整完整已完全映射的节点逐节点执行不要全部放弃使用未映射字段时保留 ES|QL 查询优化还有一件事需要正确处理那就是确保LOAD下的优化仍然能够正常工作。考虑之前的查询SET unmapped_fieldsLOAD; 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 索引中获取而 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请求的节点上运行一次初步优化器。然后因为我们需要从各个分片获取文档所以会向所有需要访问的节点进行 fan-out并在每个节点上运行第二次本地优化器。初始优化之后的工作流程更接近下面这样如果当前节点恰好在所有分片中都映射了some_field本地优化器就会检测到这一情况并像处理其他完全映射的字段一样处理some_field包括执行 Lucene 查询从而大幅缩小需要处理的数据集。实际上数据节点会以分片批次的方式处理类似下面这样的LIMIT查询SET unmapped_fieldsLOAD; FROM index, index_without_some_field | WHERE some_field 10 | LIMIT 1000这样做是为了避免过早加载过多数据其中每个批次都会完整运行一次本地优化器。这进一步提高了遇到some_field完全映射的批次的可能性从而允许 ES|QL 执行快速的 Lucene 查询。现在能正常工作了吗针对每一种 ES|QL 查询形态测试unmapped_fields正如我们从上面的优化器问题中看到的即使是非常简单的查询问题也可能隐藏在显而易见的地方。因为unmapped_fieldsLOAD会影响每一种查询因此潜在的 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_fieldsNULLIFY运行时都应该产生完全相同的结果。这也帮助我们在开发过程的早期发现了重大问题尤其是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
返回列表