跳过 mapping 爆炸:ES|QL 无需动态 mapping 即可查询无 schema JSON key
作者来自 Elastic Jordan Powers 及 Dima LeontyevFlattened 字段让 Elasticsearch 变成一个读取时定义 schema 的数据存储你可以在一个 mapping 下索引无 schema 数据然后使用 ES|QL 的 FIELD_EXTRACT 提取所需的任意 JSON key用于过滤、分组或 join同时将谓词下推到列式存储中。ES|QL 现在可以读取 flattened 字段。FIELD_EXTRACT 可以从无 schema 的 JSON 对象中提取任意 key因此你可以针对从未进行 mapping 的 key 进行过滤、分组、排序和 join。查询规划器会将这些谓词下推到列式存储而不是针对每一行解析整个 JSON 数据块这意味着来自 OTel attributes、日志 labels、用户 metadata 或其他你不希望单独进行 mapping 的动态 JSON key都可以被查询同时不会导致 mapping 爆炸。为什么动态 mapping 在无 schema 数据面前会失效Elasticsearch 需要 schema。你索引的每个字段都有一个 mapping用于定义其类型、analyzer对于 text 字段以及存储方式。这种 schema 让存储压缩更加高效同时提供快速搜索和低成本聚合。但当你的数据不再像数据库表那样具有固定结构时它就会变成一种负担。考虑一下真实系统中经常出现的数据形态日志事件中每个服务都会添加自己的 attributes。一个服务产生labels.region另一个产生labels.k8s.pod还有一个产生labels.tenant_id。OpenTelemetry resource attributes其 key 集合取决于发送该 span 的具体 agent。用户提供的 metadata bags、feature flags 或 tagging systems其中 key 从设计上就是开放的。如果将每个 key 都映射为独立字段mapping 就会无限增长。这就是经典的 “mapping 爆炸”。数千个动态创建的字段会膨胀集群状态使每次 mapping 更新都变慢并最终触及字段数量限制。每个字段还会在索引中产生额外开销。你需要为那些从未计划使用、甚至可能只查询一次的 key 支付结构性成本。最直接的替代方案是将整个对象存储为字符串并放弃查询其中的内容。这只是用一个问题换另一个问题你保留了数据却失去了对其中内容进行过滤或分组的能力。flattened字段类型是更好的方案。你可以将整个 JSON 对象索引到一个经过 mapping 的字段下同时让其中的 key 保持可查询并几乎不产生 mapping 爆炸的成本。本文将介绍 flattened 字段如何在磁盘上存储数据以及 ES|QL 如何读取这些数据。flattened 字段如何在一个 mapping 下索引动态 JSON key将一个字段映射为 flattenedPUT logs { mappings: { properties: { labels: { type: flattened } } } }然后向其中写入任意嵌套 JSONPOST logs/_doc { labels: { region: us-east-1, k8s: { pod: web-7f9, node: ip-10-0-0-3 }, retries: 4 } }无论你的文档中出现多少个 keymapping 中始终只有一个字段即labels。当出现新的 key 时集群状态不会增长。子 key 仍然可以单独搜索。你可以在查询中引用labels.region或labels.k8s.pod即使它们从未被显式声明过。这里的限制也是核心的权衡每个叶子值都是 keyword。上面的数字4会被作为字符串4进行索引。动态 key 没有数值类型、日期解析也不能进行范围计算。Flattened 字段用每个字段的丰富类型能力换取 schema 灵活性。这一点解释了后续几乎所有的设计决策。Elasticsearch 如何在磁盘上存储 flattened 字段的 JSON keyflattened 字段有两种查询方式针对根 flattened 字段的无 key 查询以及针对特定子字段的带 key 查询。沿用之前的示例labels: us-east-1形式的查询会匹配任意key 下的值而labels.region: us-east-1会匹配特定regionkey 下的值。为了支持这两种不同的查询形式flattened mapper 会将每个叶子值写入两个不同的 Lucene 字段。以这个文档为例{ labels: { region: us-east-1, k8s: { pod: web-7f9 } } }mapper 会生成labels下的根字段保存不带 key 的值us-east-1 web-7f9labels._keyed下的带 key 字段保存与值连接后的 flattened keyregion\0us-east-1 k8s.pod\0web-7f9在带 key 的字段中嵌套对象会被使用点号压平为单一 keyk8s.pod然后使用一个保留的 NULL 字节\0作为分隔符将 key 与值连接起来。包含 NULL 字节的 key 会在解析时被拒绝因此这个分隔符始终不会产生歧义。要找到值只需要在第一个 NULL 字节处分割即可。这两个字段使两种查询形式都能够正常工作labels: us-east-1匹配任意key 下的值因此它搜索根字段。labels.region: us-east-1匹配特定key 下的值。它会将查询重写为 termregion\0us-east-1然后搜索带 key 的字段。flattened 字段子 key 的查询限制由于每个 key 的 term 都位于同一个有序列表中带 key 的字段无法像普通 keyword 字段一样支持所有查询形式。由此产生三个限制特定子 key 不支持 fuzzy、regexp 或 wildcard。字段的存储布局本身并不意味着这些查询无法实现但查询模式必须与 key 前缀结合以确保查询不会越过 key 的边界。在当前版本中mapper 并没有这样做。子 key 上的任何范围查询都至少需要一个边界。Elasticsearch 已经有一个用于查询“这个字段在这里存在某个值无论值是什么”的查询exists查询。对于 flattened 子 key它会作为针对 key\0 的 prefix 查询运行从而扫描属于该 key 的所有 term。没有任何边界的范围查询实际上会扫描完全相同的 term。因此mapper 不支持没有边界的范围查询而是要求使用exists查询。子 key 上的范围查询要求该字段已建立索引。Flattened 字段可以使用index: false进行 mapping这会跳过倒排索引只保留 doc values也就是下一节将介绍的按文档存储的列式数据。精确匹配查询仍然可以正常工作。由于没有 term 可以进行查找Elasticsearch 会改为扫描 doc values 列这种方式更慢但可以得到相同的结果。范围查询没有等价的后备方案因此对未建立索引的 flattened 字段子 key 执行范围查询会抛出 Exception。这三个限制都是对 mapper 愿意构建的 Lucene 查询的限制而你是否会注意到这些限制取决于你使用哪种查询方式。在 Search API 中当你直接指定labels.region时这些限制会以错误的形式返回。在 ES|QL 中你根本不会看到这些错误。在那里相同的限制只决定谓词是被下推到 Lucene还是在提取后的列上由计算引擎执行。下面这个查询会被下推为针对带 key 字段的 term 查询FROM logs | WHERE FIELD_EXTRACT(labels, region) us-east-1下面这个查询则不会因此过滤会针对提取出来的 keyword 逐行执行FROM logs | WHERE FIELD_EXTRACT(labels, region) LIKE us-*结果相同但需要执行更多工作。这一区别正是本文后半部分要讨论的主题。底层原理范围查询如何保持在 key 的边界内这一部分属于内部实现。使用该字段并不需要了解这些内容但它可以解释为什么会有边界规则。对于一个 segment 中的少量文档共享的 term 列表看起来是这样的k8s.pod\0web-7f9 region\0us-east-1 region\0us-west-2 tenant_id\0acme每个 key 都拥有这个列表中的一个连续片段。例如regionkey 的所有值都会集中在一个有序的子列表中。一个同时设置上下边界的范围查询会使用与 term 相同的方式对每个边界进行编码因此regionkey 的下界us-east会变成region\0us-east上界us-west会变成region\0us-west。两个端点都已经带有 key 前缀因此扫描不会离开region的片段。无需任何特殊处理。只有一侧有边界的情况就比较棘手。向 Lucene 提供一个下界region\0us-east却没有上界会一直扫描到 term 列表的末尾直接穿过tenant_id\0acme以及所有其他排序在region之后的 key。因此mapper 会为缺失的那一侧使用一个哨兵值缺失的下界会变成 key\0并且包含该边界。这是空值的编码也是该 key 片段中的第一个 term。缺失的上界会变成 key\1并且不包含该边界。字节0x01是分隔符0x00之后的下一个字节因此它的排序位置高于所有 key\0value term同时低于其他 key 的第一个 term。因此单侧范围查询会被限制在[key\0, key\1)中这使它与一个双侧范围查询一样安全。上界哨兵还可以处理一个 key 是另一个 key 前缀的情况。如果一个索引同时包含region和regionx那么region\1仍然会排在regionx\0eu-west-1之前因为0x01小于共享region前缀之后的x。因此对region的范围查询不会泄漏到regionx。flattened 字段如何使用倒排索引和 doc values每个叶子值都可以写入两个 Lucene 数据结构。这两者默认都启用但可以通过 mapping 配置禁用。倒排索引字段已建立索引时。每个值会在 root path 和 keyed path 上分别索引一个未分词的 keyword term。这为 term、prefix 和 range 搜索提供支持。Doc values启用doc_values时。一种按文档顺序组织的列式结构为排序、聚合和 ES|QL 读取提供支持。倒排索引回答的是“哪些文档包含这个 term”Doc values 回答的是另一个问题“对于这个文档它有哪些值”Doc values 按列进行布局因此扫描时只需要访问所需的字节。Flattened 字段同时使用倒排索引和 doc values因此可以通过同一个字段同时提供搜索和分析能力。索引的这种特性意味着 root 字段只会在某些情况下存在。当 mapping 禁用了倒排索引时任何值搜索都需要对 doc values 中的目标值执行线性扫描。在这种情况下搜索 root 字段与直接扫描 keyed 字段并忽略 key 标记相比并不会产生太多额外开销。因此flattened mapper 会跳过 root 字段的写入而依赖 keyed 字段同时支持 root 查询和 keyed 查询。为什么 flattened 字段从 dictionary 切换到了 binary doc values历史上flattened 字段的 doc values 使用 Lucene 的SortedSetDocValues这是一种 dictionary 压缩格式。这意味着一个 segment 中所有文档索引的每个key\0value值都会被存储在一个大型、有序且去重的值集合中。每个文档则保存一个指向该值集合的 ordinal 列表。这种 dictionary 方法对于低基数字段非常适合因为这些字段通常会重复出现相同的值。只存储一次值然后通过一个整数值引用它可以实现非常高效的字节存储。不过构建和维护这个值 dictionary 本身存在开销而对于不会重复出现值的高基数字段来说这些开销都是浪费的。由于 flattened 字段是一种通用类型其基数通常非常高。因此虽然 dictionary 方法可以正常工作但对于这种数据而言它并不是最紧凑、也不是最适合扫描的布局尤其是在 flattened bags 很常见且存储压力真实存在的 time-series indices 中。近期版本已经切换为使用 Lucene 的BinaryDocValues进行存储。这种格式会为每个文档存储一个字面二进制数据块并由我们的 doc values codec 在写入磁盘时使用 Zstandard 进行压缩。这种新的二进制格式还带来了一个额外优势它可以在没有额外开销的情况下保留原始数组顺序。Flattened 字段支持 mapping 参数 preserve_leaf_arrays该参数会影响使用 synthetic source 时多值字段的返回方式。当配置为preserve_leaf_arrays: exact时返回的值会保留原始 source 值中的顺序、重复值和 null。Sorted-set doc values 固有的 dictionary 编码意味着返回的值会经过排序、去重并移除 null。为了实现preserve_leaf_arraysflattened 字段过去一直使用额外的 sidecar 字段记录重建原始 source 值所需的 metadata。不过由于 binary doc values 的特性现在不再需要这个 sidecar 字段。值会按照索引时的形式直接存储和返回。flattened 字段读取时定义 schema 的限制下面这些限制都来自仅支持 keyword 的规则以及共享的 keyed 字段动态 key 不支持数值、日期或 Boolean 类型。100和100是相同的 term。特定子 key 不支持 fuzzy、regexp 或 wildcard。Flattened 字段不支持多字段fields或copy_to。对象可以嵌套的深度存在depth_limit限制默认值为 20。如果你需要为某个已知key 提供真正的类型支持flattened 现在支持显式的mapped subfields你可以在properties块下声明具有真实类型的独立 key这些 key 会由它们自己的类型化 mapper 进行索引而不是使用 keyed 字段。这样你可以对labels.status_code执行数值范围查询同时labels中的其他内容仍然保持动态且仅支持 keyword。这就是针对少数你真正了解的 key 所提供的后备方案。读取时定义 schema在 ES|QL 中查询 flattened 字段的 JSON keySearch 多年来一直支持 flattened 字段。现在基于列式计算引擎、较新的管道式查询语言 ES|QL 也可以读取 flattened 字段。该支持从 Elasticsearch 9.5.0 的技术预览版本开始提供。它包含两个不同的部分。选择 flattened 字段时 ES|QL 返回什么ES|QL 为 flattened 字段使用专用数据类型而不是将其归入keyword。当你选择 root 时会以 JSON 字符串的形式返回整个对象FROM logs | KEEP labelslabels:flattened {k8s.pod:web-7f9,region:us-east-1}返回的 key 会经过排序。你可以在查询中继续传递这个值、对它进行计数、按它分组以及对它运行多值函数和比较函数。但一个不透明的 JSON 数据块通常不是你想要用来过滤或聚合的对象。为此你需要深入其中并处理它的内容。FIELD_EXTRACT 如何从 flattened 字段读取 JSON keyES|QL 不支持用于动态 key 的点号路径语法。对于未进行 mapping 的 key你不能写labels.region因为对于计算引擎来说flattened root 是一个单独的叶子值而不是一组列。相反你需要使用函数FROM logs | EVAL region FIELD_EXTRACT(labels, region) | STATS count COUNT(*) BY region | SORT region缺少点号路径语法目前属于暂定限制。Keyed 字段已经可以处理单独的子 key因此对于访问 flattened root 中内容而言更自然的语法是我们计划支持的功能。FIELD_EXTRACT(field, path) 接收一个 flattened 字段和一个 key并返回一个 keyword。通过一个文档可以更容易地理解它的规则。以下面的文档为例POST logs/_doc { labels: { region: us-east-1, k8s: { pod: web-7f9, node: ip-10-0-0-3 }, tags: [prod, canary], retries: 4 } }以及这个查询FROM logs | EVAL region FIELD_EXTRACT(labels, region) | EVAL pod FIELD_EXTRACT(labels, k8s.pod) | EVAL k8s FIELD_EXTRACT(labels, k8s) | EVAL tags FIELD_EXTRACT(labels, tags) | EVAL retries FIELD_EXTRACT(labels, retries) | EVAL namespace FIELD_EXTRACT(labels, k8s.namespace)结果为region | pod | k8s | tags | retries | namespace us-east-1 | web-7f9 | null | [prod, canary] | 4 | null需要注意以下四点点号是 key 的一部分而不是导航操作符。Mapper 已经将嵌套对象压平为k8s.pod这个扁平 key因此k8s.pod是直接查找而不是从k8s再导航到pod。匹配是精确的。k8s返回 null因为不存在存储在k8s下的叶子值只有k8s.pod和k8s.node。同样host不会找到host.name并且匹配区分大小写因此Region不会找到region。数组会以多值形式返回。tags会产生一个多值 keyword你可以对它使用 MV_EXPAND、进行计数或过滤。不存在的 key 会返回 null。所有值都是 keyword。retries返回字符串4Boolean 叶子值则返回true或false。JSONPath 语法会直接被拒绝而且是在解析时拒绝而不是逐行执行时拒绝。FIELD_EXTRACT(labels, [k8s.pod])和FIELD_EXTRACT(labels, tags[0])都会失败并返回field_extract path must be a literal flattened sub-field name。提取之后这个值就是一个普通的keyword列。你可以对它进行过滤、分组、排序或者将它作为 LOOKUP JOIN 的 join keyFROM logs | EVAL host_name FIELD_EXTRACT(resource.attributes, host.name) | LOOKUP JOIN host_info ON host_nameES|QL 如何将 flattened 字段谓词下推到列式存储实现 FIELD_EXTRACT 最直观的方式是从存储中读取整个 flattened root将其渲染成 JSON 字符串然后交给计算引擎并针对每一行解析一次 JSON从中提取一个 key。但这意味着为了使用一次某个 key却必须读取所有 key并且还要为每个文档付出一次 JSON 解析的成本。因此这种直观的实现方式性能并不好。ES|QL 会尽可能避免这种情况。在存储和计算引擎之间存在一个block loader它负责将存储的数据转换为计算引擎运行所需的列式数据块。FIELD_EXTRACT 会挂接到这一步而不是在这一步之后执行。当 ES|QL 加载一个列时flattened 字段类型会检查请求。如果请求是提取一个单独的常量 key并且该字段具有 doc values它会直接路由到 keyed doc values loader只从列式结构中读取属于该 key 的 key\0value 条目。到达计算引擎的列已经只包含该 key 的值。JSON 字符串从未被构建也从未被解析对象中的其他 key 也完全不会被读取。当提取无法与 block loader 融合时例如 key 是按行计算的或者 root 是另一个函数例如 CASE的输出ES|QL 会回退到逐行解析的路径。两种方式得到的结果完全相同只有成本不同。比较操作还可以进一步下推。像FIELD_EXTRACT(labels, region) us-east-1这样的谓词可以被下推到 Lucene作为针对 synthetic keyed 字段的 term 查询也就是 Search 路径使用的同一个region\0us-east-1term。因此对提取出的子 key 进行过滤时可以由倒排索引如果存在直接回答而投影则可以由 doc values 回答就像它是一个一等字段一样即使这个 key 从未出现在 mapping 中。排序比较也可以下推。四种单侧比较操作符、、、以及闭区间 BETWEEN 风格的范围都会转换为针对同一个 synthetic keyed 字段的范围查询。这正是 key\0 / key\1 哨兵发挥作用的地方单侧形式只有在 mapper 能够将开放边界限制在 key 内部时才能被下推。下推的范围会被视为候选结果之后谓词还会在提取出的 keyword 列上重新计算因此多值 key 不会错误地通过过滤。这些值都是 keyword因此排序采用字典序而不是数值排序。FIELD_EXTRACT(labels, retries) 10比较的是字符串这意味着9大于10。如果你需要对某个 key 执行数值范围查询可以在properties下显式进行 mapping或者在 ES|QL 中对该值进行类型转换。显式 mapping 的子字段会有意采用不同的处理方式。因为它们具有真实类型其比较语义与 keyword 路径不同因此会通过它们自己的类型化 mapper 进行加载和比较而不是融合到 keyed loader 中。而当你选择一个包含已 mapping 子字段的 flattened root 时ES|QL 会从_source中加载它因此每个叶子值都会被渲染为字符串并且不会静默丢弃任何 key。什么时候应该在 Elasticsearch 中使用 flattened 字段而不是动态 mapping在以下情况下使用flattened字段key 集合是开放的或者无法提前确定。否则可能导致 mapping 爆炸。对值进行基于 keyword 的过滤和分组就足够并且不需要对动态 key 使用数值或日期语义。有少量 key确实需要真实类型。将这些 key 在properties下显式进行 mapping让其余 key 保持动态。当 schema 稳定并且你全面需要全文分析、数值聚合或日期计算时应避免使用 flattened或者按照常规方式对字段进行 mapping。记住flattened 并不是一个用来倾倒你已经放弃查询的 JSON 数据的垃圾场。它是真正用于无 schema 数据的列式存储和倒排存储。随着 ES|QL 的支持它现在已经成为一等分析数据类型。你可以让数据中杂乱、未进行 mapping 的部分继续保持这种状态同时仍然可以根据需要对它们进行过滤、分组、join 和聚合。原文Schema on read in Elasticsearch: query unmapped JSON keys | Elasticsearch Labs