Apache Druid JSON Flatten Spec 完全指南:在摄入期将嵌套 JSON 字段扁平化为列
2026/9/23 20:23:14 网站建设 项目流程
  • 数据库
  • 数据分析
  • OLAP
  • 大数据
  • 实时分析
  • 数据仓库
  • 后端

【免费下载链接】druid

Apache Druid: a high performance real-time analytics database.

项目地址:https://gitcode.com/gh_mirrors/druid7/druid
点击查看免费下载

JSON Flatten Spec(扁平化规范)是 Apache Druid 在数据摄入(ingestion)阶段对嵌套 JSON 输入进行字段展开的核心配置。本文基于仓库中的官方文档 flatten-json.md,并结合apijava-util模块中JSONParseSpecJSONPathSpecJSONPathParser等类的源码实现,系统讲解 flattenSpec 的字段语义、配置写法、自动字段发现规则、聚合器配合方式及底层运行原理,帮助你为批式与流式摄入任务编写出正确、高效、可复现的扁平化配置。

什么是 JSON Flatten Spec,为什么需要它

Druid 中的列(column)来源于摄入时的"平面化"输入行:每个输入字段对应一个维度列或指标列。然而真实的业务事件(如点击流、埋点日志、Kafka 中的订单事件)常常是嵌套 JSON,例如:

{ "foo": {"bar": "abc"}, "nestmet": {"val": 42}, "thing": {"food": ["sandwich", "pizza"]}, "world": [{"hey": "there"}, {"tree": "apple"}] }

如果直接摄入,foothing这类 Map 值无法成为普通列。JSON Flatten Spec 正是为了解决这个问题:它允许在摄入期(ingestion time)将嵌套 JSON 字段"拉平",让$.foo.bar这样的深层值直接成为独立的列foo.bar,同时可以重命名、选取数组中的特定元素等。

需要强调的一个前提是:只有 JSON 格式的 ParseSpec(format: "json")支持扁平化,其他格式(如 CSV、TSV)不适用。这一点在 flatten-json.md 中有明确说明,也体现在源码中:flattenSpec 是JSONParseSpec独有的属性,见 JSONParseSpec.java。

flattenSpec 的两个顶层配置项

flattenSpec 挂载在parseSpec之下,包含两个可选字段,完整参数定义如下:

FieldTypeDescriptionRequired
useFieldDiscoveryBoolean若为 true,则将根级别(root level)所有单值字段(非 map 或 list)以及扁平列表(单值列表)自动解释为列。no(默认 == true)
fieldsJSON Object 数组指定感兴趣的字段及其访问方式。no(默认 == [])

从源码看,这两个默认值由 JSONPathSpec.java 实现:

this.useFieldDiscovery = useFieldDiscovery == null ? true : useFieldDiscovery; this.fields = fields == null ? ImmutableList.<JSONPathFieldSpec>of() : fields;

即:省略useFieldDiscovery时默认开启字段自动发现;省略fields时为空列表。也就是说,一个"最简"的 JSON parseSpec 不写 flattenSpec 也能工作——此时等价于{"useFieldDiscovery": true, "fields": []},所有根级单值字段都会被自动发现(这正是普通非嵌套 JSON 摄入的默认行为)。更进一步,JSONParseSpec的构造器在 flattenSpec 为 null 时也会自动回退为new JSONPathSpec(true, null)(见 JSONParseSpec.java)。

JSON Field Spec:fields 数组中每个字段的语义

fields是 JSON 对象数组,每个对象描述一个目标字段的"名字"和"访问路径":

FieldTypeDescriptionRequired
typeString字段类型,"root" 或 "path"。yes
nameString该字符串将作为数据摄入后的列名。yes
exprString定义访问 JSON 对象内字段的表达式,使用 JsonPath 记法(该仓库通过 jayway JsonPath 库实现)。仅 type 为 "path" 时使用,否则被忽略。仅 type 为 "path" 时需要

三种字段类型的行为对应关系为:

  • type: "root":直接取 JSON 根对象的某个键,expr被忽略。对应源码 JSONPathParser.java 中的document.get(fieldName)直接取值分支。
  • type: "path":通过 JsonPath 表达式(如$.foo.bar$.hello[0])访问深层或数组元素,取值分支为path.read(document, jsonPathConfig)
  • 便捷写法:定义根级字段时,可以直接用字符串代替完整对象,例如"dim2"等价于{"type": "root", "name": "dim2"}。这一行为由 JSONPathFieldSpec.java 中的@JsonCreator fromString(String name)实现,它会把字符串反序列化为JSONPathFieldSpec.createRootField(name)(即 type=ROOT、expr=null 的对象)。

需要留意的是,JsonPath 表达式只在字段被显式定义时才会被编译。JSONPathParser在构造时对每个 PATH 类型字段调用JsonPath.compile(fieldSpec.getExpr())(见 JSONPathParser.java),因此非法表达式会在解析器初始化阶段就暴露。

完整示例:从嵌套事件到扁平列

文档给出了一个典型的事件 JSON。假设输入事件形如:

{ "timestamp": "2015-09-12T12:10:53.155Z", "dim1": "qwerty", "dim2": "asdf", "dim3": "zxcv", "ignore_me": "ignore this", "metrica": 9999, "foo": {"bar": "abc"}, "foo.bar": "def", "nestmet": {"val": 42}, "hello": [1.0, 2.0, 3.0, 4.0, 5.0], "mixarray": [1.0, 2.0, 3.0, 4.0, {"last": 5}], "world": [{"hey": "there"}, {"tree": "apple"}], "thing": {"food": ["sandwich", "pizza"]} }

按文档的设计意图:metrica是 Long 型指标列,hello是 Double 数组指标列,nestmet.val是嵌套的 Long 指标列,其余字段是维度列。

对应的 parseSpec 完整定义如下(这是本文的核心可复制配置,来自原文档并保持原样):

"parseSpec": { "format": "json", "flattenSpec": { "useFieldDiscovery": true, "fields": [ { "type": "root", "name": "dim1" }, "dim2", { "type": "path", "name": "foo.bar", "expr": "$.foo.bar" }, { "type": "root", "name": "foo.bar" }, { "type": "path", "name": "path-metric", "expr": "$.nestmet.val" }, { "type": "path", "name": "hello-0", "expr": "$.hello[0]" }, { "type": "path", "name": "hello-4", "expr": "$.hello[4]" }, { "type": "path", "name": "world-hey", "expr": "$.world[0].hey" }, { "type": "path", "name": "worldtree", "expr": "$.world[1].tree" }, { "type": "path", "name": "first-food", "expr": "$.thing.food[0]" }, { "type": "path", "name": "second-food", "expr": "$.thing.food[1]" } ] }, "dimensionsSpec" : { "dimensions" : [], "dimensionsExclusions": ["ignore_me"] }, "timestampSpec" : { "format" : "auto", "column" : "timestamp" } }

对这个示例做逐字段拆解:

  • "dim1""dim2":根级字段的两种写法。"dim1"使用完整对象{"type": "root", "name": "dim1"}"dim2"使用便捷字符串写法,两者等价。
  • 两个foo.bar:这里演示了一个非常有用的技巧——事件中同时存在foo.bar这样的字面根键(值为"def")和嵌套路径foo.bar(值为"abc")。通过{"type": "path", "name": "foo.bar", "expr": "$.foo.bar"}{"type": "root", "name": "foo.bar"}两条定义,可以分别把深层值和根级同名键都映射到名为foo.bar的列上(注意两者不能同时被自动发现,需要显式指定)。
  • path-metric$.nestmet.val:把嵌套对象中的val提出来,重命名成一个新列名,之后可以作为 Long 指标参与聚合。
  • hello-0/hello-4$.hello[0]/$.hello[4]:JsonPath 支持数组下标访问。这里取hello数组的第 1 个和第 5 个元素作为独立列,说明"数组不一定要整体摄入,可以按元素选取"。
  • world-hey$.world[0].heyworldtree$.world[1].tree:对象数组的访问,$.world[i].key取第 i 个元素中的指定键。
  • first-food/second-food$.thing.food[0]/$.thing.food[1]:嵌套数组的组合访问。

字段dim3ignore_memetrica因为useFieldDiscovery为 true 会被自动发现,因此不必出现在 field spec 列表中;ignore_me虽被自动发现,但通过dimensionsExclusions: ["ignore_me"]被显式排除。

useFieldDiscovery:自动字段发现的精确语义

自动发现是 flattenSpec 中最重要的行为开关,其精确语义如下:

  1. 只自动发现根级单值字段:即值不是 map 也不是 list 的字段。上面的示例中,dim1dim2dim3ignore_memetricafoo.bar(根级字面键)都会被自动检测为列。
  2. 扁平列表(单值列表)也会被自动发现hello是 Double 列表,会被自动发现;但示例中为了分别摄入各元素,仍显式定义了hello-0hello-4等字段。
  3. 值为 map 的字段不会被自动发现world必须显式定义(因为其值是 map 数组)。
  4. 类似但包含 map 的列表不会被自动发现mixarrayhello表面相似,但最后一个元素是 map,因此也必须显式定义。

这些规则在源码中有清晰的对应实现。JSONPathParser.java 的discoverFields方法:

if (val == null) continue; // null 值跳过 if (val instanceof Map) continue; // map 不自动发现 if (val instanceof List) { if (!isFlatList((List) val)) continue; // 非扁平列表不自动发现 } map.put(field, valueConversionFunction(val));

其中isFlatList递归检查列表中是否包含子对象或子列表,只有全部为单值的列表才算"扁平列表"。discoverFields还有一个保护逻辑:if (!map.containsKey(field))——已经显式定义的字段不会被重复加入(见 JSONPathParser.java)。

重复定义与输入约束

文档明确了两条硬性约束,它们同样可以在源码中找到依据:

  • 不允许重复字段定义,否则抛出异常generateFieldPaths使用LinkedHashMap维护字段映射,插入前检查:if (map.get(fieldName) != null) throw new IllegalArgumentException("Cannot have duplicate field definition: " + fieldName)(见 JSONPathParser.java)。注意这里对"重复"的判断基于name,因此示例中两个foo.bar会直接触发异常——上面的示例配置里两个foo.bar同名,在实际运行时二者只能保留其一,请根据业务二选一(原文档保留了两条定义用于说明 root 与 path 的并存场景,实际使用时需要避免同名冲突)。
  • JSON 输入根节点必须是对象,不能是数组{"valid": "true"}{"valid":[1,2,3]}支持,而[{"invalid": "true"}][1,2,3]不支持。源码中parse()使用mapper.readValue(input, new TypeReference<Map<String, Object>>(){})把输入反序列化为 Map,数组根节点会在此处失败(见 JSONPathParser.java),失败时抛出ParseException("Unable to parse row")。

此外,从源码看还有一个值得注意的细节:JsonPath 求值配置了Option.SUPPRESS_EXCEPTIONS(见 JSONPathParser.java),这意味着当某条 path 表达式在当前事件中取不到值时(例如$.thing.food[1]不存在),解析不会抛异常,而是返回 null 并被跳过,不会导致整行摄入失败。这保证了同一份 schema 可以兼容字段缺失的稀疏事件。

值类型转换:flat 之后的类型处理

JSONPathParser在把取值放入输出 Map 前会统一做valueConversionFunction转换(见 JSONPathParser.java),这对理解"为什么 JSON 数字能成为 Long/Double 指标"很关键:

  • IntegerLong:Jackson 默认把小整数反序列化为Integer,Druid 统一提升为Long,便于longSum等聚合。
  • BigIntegerDouble:大整数转为 Double,避免精度截断问题(同时说明超大整数不建议作为精确 Long 指标摄入)。
  • String→ 经过charsetFix处理,保证 UTF-8 可编码。
  • List/Map:递归应用同样的转换规则。

聚合器(metricsSpec)如何引用扁平化后的列

扁平化发生在摄入(parse)阶段,产出的是普通列名;后续dimensionsSpecmetricsSpectimestampSpec都以扁平化后的列名为准。文档强调:"聚合器应使用 flattenSpec 中定义的指标列名"。沿用上面的示例:

"metricsSpec" : [ { "type" : "longSum", "name" : "path-metric-sum", "fieldName" : "path-metric" }, { "type" : "doubleSum", "name" : "hello-0-sum", "fieldName" : "hello-0" }, { "type" : "longSum", "name" : "metrica-sum", "fieldName" : "metrica" } ]

这里fieldName引用的path-metrichello-0metrica正是 flattenSpec 中定义的 name 或自动发现的根级字段名。整个摄入任务的数据流为:原始 JSON 事件 → JSONPathParser 按 flattenSpec 拉平 → InputRow(含维度与指标) → 聚合/索引JSONParseSpec.makeParser()JSONPathSpec转换为JSONPathParser.FieldSpec列表并交给JSONPathParser(见 JSONParseSpec.java),与这一流程一一对应。

源码、测试与基准:验证与参考

仓库中与本主题相关的可继续深挖的代码与测试包括:

  • 配置模型:JSONPathSpec(JSONPathSpec.java)与JSONPathFieldSpec(JSONPathFieldSpec.java),分别对应 flattenSpec 顶层结构与 field spec 对象,其@JsonCreator反序列化逻辑保证了 JSON 配置到 Java 对象的映射。
  • 解析实现:JSONPathParser.java 是扁平化的真正执行者,涵盖 path 编译、字段发现、类型转换、重复检测等全部逻辑。
  • 序列化/反序列化测试:JSONPathSpecTest.java 验证了 flattenSpec 配置的 JSON 往返序列化,其中同时构造了嵌套 path 字段foobar1$.foo.bar1)与根字段foo.bar1,并断言二者的nameexpr正确存取;InputRowParserSerdeTest.java 则覆盖了含 flattenSpec 的 parseSpec 整体序列化链路。
  • 性能基准:FlattenJSONProfile.java 与 FlattenJSONBenchmarkUtil.java 提供了对嵌套 JSON 解析(含扁平化)的 JMH 基准,可用来评估不同字段数量、嵌套深度下的解析吞吐,适合在引入大量 path 字段前做性能验证。

使用建议与注意事项汇总

综合文档与源码,在真实摄入任务中使用 flattenSpec 时建议遵循以下要点:

  1. 能自动发现就不显式定义:保持useFieldDiscovery: true,只对需要重命名、深层访问、数组取元素的字段显式声明,可显著减少配置量并降低出错面。
  2. 同名列冲突要当心fields中每个name必须唯一(源码会抛IllegalArgumentException)。若事件中同时存在foo.bar字面键与嵌套foo.bar,需要规划好列名(例如分别命名为foo.barroot_foo.bar),不要照抄文档示例中的同名写法。
  3. map 与对象数组必须显式声明:自动发现不会覆盖它们,漏配会导致这些字段静默丢失。
  4. path 表达式要验证:建议先在独立工具中验证 JsonPath 表达式对典型样本事件的求值结果,再写入配置;同时注意SUPPRESS_EXCEPTIONS行为意味着取值失败会返回空而不报错,配置错误可能难以在日志中直接发现。
  5. 输入根节点必须是 JSON 对象:数组根节点(如 Kafka 中直接发送[...])无法直接摄入,需要在上游做包装或预处理。
  6. 指标列引用扁平化后的 namemetricsSpec.fieldNamedimensionsSpec都以 flattenSpec 产出的列名为准。

延伸阅读

  • 本文档原文:flatten-json.md
  • 摄入任务整体配置:tasks.md 与 index.md
  • 维度与指标定义:schema-design.md
  • 流式摄入中应用 flattenSpec 的示例配置:kafka-ingestion.md
  • 数据库
  • 数据分析
  • OLAP
  • 大数据
  • 实时分析
  • 数据仓库
  • 后端

【免费下载链接】druid

Apache Druid: a high performance real-time analytics database.

项目地址:https://gitcode.com/gh_mirrors/druid7/druid
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询