跳过 mapping 爆炸:ES|QL 无需动态 mapping 即可查询无 schema JSON key

作者:来自 Elastic Jordan PowersDima Leontyev

Flattened 字段让 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

将一个字段映射为 flattened:

markdown 复制代码
`

1.  PUT logs
2.  {
3.    "mappings": {
4.      "properties": {
5.        "labels": { "type": "flattened" }
6.      }
7.    }
8.  }

`AI写代码

然后向其中写入任意嵌套 JSON:

bash 复制代码
`

1.  POST logs/_doc
2.  {
3.    "labels": {
4.      "region": "us-east-1",
5.      "k8s": { "pod": "web-7f9", "node": "ip-10-0-0-3" },
6.      "retries": 4
7.    }
8.  }

`AI写代码

无论你的文档中出现多少个 key,mapping 中始终只有一个字段,即 labels。当出现新的 key 时,集群状态不会增长。子 key 仍然可以单独搜索。你可以在查询中引用 labels.regionlabels.k8s.pod,即使它们从未被显式声明过。

这里的限制,也是核心的权衡:每个叶子值都是 keyword。上面的数字 4 会被作为字符串 "4" 进行索引。动态 key 没有数值类型、日期解析,也不能进行范围计算。Flattened 字段用每个字段的丰富类型能力换取 schema 灵活性。这一点解释了后续几乎所有的设计决策。

Elasticsearch 如何在磁盘上存储 flattened 字段的 JSON key

flattened 字段有两种查询方式:针对根 flattened 字段的无 key 查询,以及针对特定子字段的带 key 查询。沿用之前的示例,labels: "us-east-1" 形式的查询会匹配_任意_ key 下的值,而 labels.region: "us-east-1" 会匹配特定 region key 下的值。

为了支持这两种不同的查询形式,flattened mapper 会将每个叶子值写入两个不同的 Lucene 字段。

以这个文档为例:

json 复制代码
`{ "labels": { "region": "us-east-1", "k8s": { "pod": "web-7f9" } } }`AI写代码

mapper 会生成:

  • labels 下的根字段,保存不带 key 的值:
markdown 复制代码
`

1.  us-east-1
2.  web-7f9

`AI写代码
  • labels._keyed 下的带 key 字段,保存与值连接后的 flattened key:
r 复制代码
`

1.  region\0us-east-1
2.  k8s.pod\0web-7f9

`AI写代码

在带 key 的字段中,嵌套对象会被使用点号压平为单一 key(k8s.pod),然后使用一个保留的 NULL 字节(\0)作为分隔符,将 key 与值连接起来。包含 NULL 字节的 key 会在解析时被拒绝,因此这个分隔符始终不会产生歧义。要找到值,只需要在第一个 NULL 字节处分割即可。

这两个字段使两种查询形式都能够正常工作:

  • labels: "us-east-1" 匹配_任意_ key 下的值,因此它搜索根字段。

  • labels.region: "us-east-1" 匹配_特定_ key 下的值。它会将查询重写为 term region\0us-east-1,然后搜索带 key 的字段。

flattened 字段子 key 的查询限制

由于每个 key 的 term 都位于同一个有序列表中,带 key 的字段无法像普通 keyword 字段一样支持所有查询形式。由此产生三个限制:

  1. 特定子 key 不支持 fuzzy、regexp 或 wildcard。字段的存储布局本身并不意味着这些查询无法实现,但查询模式必须与 key 前缀结合,以确保查询不会越过 key 的边界。在当前版本中,mapper 并没有这样做。

  2. 子 key 上的任何范围查询都至少需要一个边界。Elasticsearch 已经有一个用于查询"这个字段在这里存在某个值,无论值是什么"的查询:exists 查询。对于 flattened 子 key,它会作为针对 key\0 的 prefix 查询运行,从而扫描属于该 key 的所有 term。没有任何边界的范围查询实际上会扫描完全相同的 term。因此,mapper 不支持没有边界的范围查询,而是要求使用 exists 查询。

  3. 子 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 查询:

sql 复制代码
`

1.  FROM logs
2.  | WHERE FIELD_EXTRACT(labels, "region") == "us-east-1"

`AI写代码

下面这个查询则不会,因此过滤会针对提取出来的 keyword 逐行执行:

sql 复制代码
`

1.  FROM logs
2.  | WHERE FIELD_EXTRACT(labels, "region") LIKE "us-*"

`AI写代码

结果相同,但需要执行更多工作。这一区别正是本文后半部分要讨论的主题。

底层原理:范围查询如何保持在 key 的边界内

这一部分属于内部实现。使用该字段并不需要了解这些内容,但它可以解释为什么会有边界规则。

对于一个 segment 中的少量文档,共享的 term 列表看起来是这样的:

r 复制代码
`

1.  k8s.pod\0web-7f9
2.  region\0us-east-1
3.  region\0us-west-2
4.  tenant_id\0acme

`AI写代码

每个 key 都拥有这个列表中的一个连续片段。例如,region key 的所有值都会集中在一个有序的子列表中。一个同时设置上下边界的范围查询,会使用与 term 相同的方式对每个边界进行编码,因此 region key 的下界 "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 前缀的情况。如果一个索引同时包含 regionregionx,那么 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_arrays,flattened 字段过去一直使用额外的 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 key

Search 多年来一直支持 flattened 字段。现在,基于列式计算引擎、较新的管道式查询语言 ES|QL 也可以读取 flattened 字段。该支持从 Elasticsearch 9.5.0 的技术预览版本开始提供。它包含两个不同的部分。

选择 flattened 字段时 ES|QL 返回什么

ES|QL 为 flattened 字段使用专用数据类型,而不是将其归入 keyword。当你选择 root 时,会以 JSON 字符串的形式返回整个对象:

css 复制代码
`FROM logs | KEEP labels`AI写代码
css 复制代码
`

1.  labels:flattened
2.  {"k8s.pod":"web-7f9","region":"us-east-1"}

`AI写代码

返回的 key 会经过排序。你可以在查询中继续传递这个值、对它进行计数、按它分组,以及对它运行多值函数和比较函数。但一个不透明的 JSON 数据块通常不是你想要用来过滤或聚合的对象。为此,你需要深入其中并处理它的内容。

FIELD_EXTRACT 如何从 flattened 字段读取 JSON key

ES|QL 不支持用于动态 key 的点号路径语法。对于未进行 mapping 的 key,你不能写 labels.region,因为对于计算引擎来说,flattened root 是一个单独的叶子值,而不是一组列。相反,你需要使用函数:

ini 复制代码
`

1.  FROM logs
2.  | EVAL region = FIELD_EXTRACT(labels, "region")
3.  | STATS count = COUNT(*) BY region
4.  | SORT region

`AI写代码

缺少点号路径语法目前属于暂定限制。Keyed 字段已经可以处理单独的子 key,因此,对于访问 flattened root 中内容而言,更自然的语法是我们计划支持的功能。

FIELD_EXTRACT(field, path) 接收一个 flattened 字段和一个 key,并返回一个 keyword。通过一个文档可以更容易地理解它的规则。以下面的文档为例:

bash 复制代码
`

1.  POST logs/_doc
2.  {
3.    "labels": {
4.      "region": "us-east-1",
5.      "k8s": { "pod": "web-7f9", "node": "ip-10-0-0-3" },
6.      "tags": ["prod", "canary"],
7.      "retries": 4
8.    }
9.  }

`AI写代码

以及这个查询:

ini 复制代码
`

1.  FROM logs
2.  | EVAL region    = FIELD_EXTRACT(labels, "region")
3.  | EVAL pod       = FIELD_EXTRACT(labels, "k8s.pod")
4.  | EVAL k8s       = FIELD_EXTRACT(labels, "k8s")
5.  | EVAL tags      = FIELD_EXTRACT(labels, "tags")
6.  | EVAL retries   = FIELD_EXTRACT(labels, "retries")
7.  | EVAL namespace = FIELD_EXTRACT(labels, "k8s.namespace")

`AI写代码

结果为:

java 复制代码
`

1.  region     | pod     | k8s  | tags           | retries | namespace
2.  us-east-1  | web-7f9 | null | [prod, canary] | 4       | null

`AI写代码

需要注意以下四点:

  • 点号是 key 的一部分,而不是导航操作符。Mapper 已经将嵌套对象压平为 k8s.pod 这个扁平 key,因此 "k8s.pod" 是直接查找,而不是从 k8s 再导航到 pod

  • 匹配是精确的。"k8s" 返回 null,因为不存在存储在 k8s 下的叶子值,只有 k8s.podk8s.node。同样,"host" 不会找到 "host.name",并且匹配区分大小写,因此 "Region" 不会找到 "region"

  • 数组会以多值形式返回。"tags" 会产生一个多值 keyword,你可以对它使用 MV_EXPAND、进行计数或过滤。不存在的 key 会返回 null。

  • 所有值都是 keyword。"retries" 返回字符串 "4",Boolean 叶子值则返回 "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 key:

vbnet 复制代码
`

1.  FROM logs
2.  | EVAL host_name = FIELD_EXTRACT(resource.attributes, "host.name")
3.  | LOOKUP JOIN host_info ON host_name

`AI写代码

ES|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-1 term。因此,对提取出的子 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

相关推荐
Elasticsearch12 小时前
AI 整合:为什么平台将胜出,而产品组合将瓦解
elasticsearch
七牛开发者13 小时前
为什么 Go 很适合 AI 辅助开发?
数据库·人工智能·python·elasticsearch·log4j
Aphelios38013 小时前
一次锁内网络IO引发的Tomcat线程池“饿死”事故
java·开发语言·spring boot·elasticsearch·tomcat·网络io阻塞·线程池耗尽
Elasticsearch19 小时前
向数据源提问:使用 Elasticsearch 和 Elastic Agent Builder 将代码搜索扩展到十亿行代码规模
elasticsearch
-今昭-21 小时前
AnsibleVault加密配置及zabbix部署
大数据·elasticsearch·搜索引擎
Elastic 中国社区官方博客1 天前
跳过有状态的 OTel Collector:Elasticsearch 9.5 原生存储两种指标时间类型
大数据·人工智能·elasticsearch·搜索引擎·重构·全文检索
PC2005-cloud2 天前
Elasticsearch 学习笔记:集群实战(3 控制节点 + 3 数据节点部署与故障转移)
笔记·学习·elasticsearch
Devin~Y2 天前
从内容社区到AI智能客服:Spring Boot + Spring Cloud + Spring AI 全栈实战面试拆解
java·spring boot·redis·elasticsearch·spring cloud·kafka·mybatis
凤山老林2 天前
复杂检索引擎落地:Spring Boot + Elasticsearch 数据同步与高阶查询实战
spring boot·后端·elasticsearch