简介
JSON 已成为现代数据系统中处理半结构化和非结构化数据的通用语言。无论是在日志记录和 可观测性 场景、实时数据流、移动应用存储还是机器学习管道中,JSON 的灵活结构使其成为跨分布式系统捕获和传输数据的首选格式。
在 ClickHouse,我们长期以来 认识到 无缝 JSON 支持的重要性。但正如 JSON 看起来很简单一样,大规模有效地利用它也带来了独特的挑战,我们将在下面简要介绍。
挑战 1:真正的列式存储
ClickHouse 是 市场上 最快 的分析型数据库之一。如此高的性能水平只有通过正确的数据“方向”才能实现。ClickHouse 是一个 真正的 列式数据库,它将表存储为磁盘上列数据文件的集合。这实现了最佳的 压缩 和硬件高效、极速的 向量化 列操作,例如过滤器或 聚合。
为了使 JSON 数据也能达到相同的性能水平,我们需要为 JSON 实现 真正的 列式存储,以便 JSON 路径可以像所有其他列类型(如数值类型)一样高效地进行压缩和处理(例如,以向量化的方式进行过滤和聚合)。
因此,我们不希望像下图所示那样,盲目地将 JSON 文档转储(并在以后 解析)到字符串列中
我们希望以真正的列式方式存储每个唯一 JSON 路径的值
挑战 2:动态变化的数据,无需类型统一
如果我们能以真正的列式方式存储 JSON 路径,那么下一个挑战是 JSON 允许同一 JSON 路径的值具有不同的数据类型。对于 ClickHouse 来说,这些不同的数据类型可能是互不兼容的,并且事先未知。此外,我们需要找到一种方法来保留所有数据类型,而不是将它们统一为最常见的类型。例如,如果对于同一个 JSON 路径 a
,我们有两个整数和一个浮点数作为值,我们不希望将所有三个都作为浮点数值存储在磁盘上,如下图所示
因为这种方法不会保留混合类型数据的完整性,也不支持更复杂的场景,例如,如果同一路径 a
下一个存储的值是一个数组
挑战 3:防止磁盘上列数据文件雪崩
以真正的列式方式存储 JSON 路径对于数据压缩和向量化数据处理具有优势。然而,在唯一 JSON 键数量较多的场景中,盲目地为每个新的唯一 JSON 路径创建一个新的列文件,最终可能会导致磁盘上列文件雪崩
这可能会造成性能问题,因为它需要大量的 文件描述符(每个文件描述符都需要占用内存空间),并由于需要处理大量文件而影响合并的性能。因此,我们需要对列创建引入限制。这使得 JSON 存储能够有效地扩展,从而确保对 PB 级数据集进行高性能分析。
挑战 4:密集存储
在唯一但稀疏的 JSON 键数量较多的场景中,我们希望避免为没有特定 JSON 路径的实际值的行冗余地存储(和处理)NULL 或默认值,如下图所示
相反,我们希望以密集、非冗余的方式存储每个唯一 JSON 路径的值。同样,这使得 JSON 存储能够扩展,从而对 PB 级数据集进行高性能分析。
我们新的、显著增强的 JSON 数据类型
我们很高兴推出我们新的、显著增强的 JSON 数据类型,它专门用于实现 JSON 数据的高性能处理,而不会遇到传统实现中常见的瓶颈。
在第一篇文章中,我们将深入探讨我们如何构建此功能,解决上述所有挑战(以及 过去的限制),同时向您展示为什么我们的实现是列式存储之上 JSON 的最佳实现,具有以下支持:
-
动态变化的数据:允许同一 JSON 路径的值具有不同的数据类型(可能是互不兼容的,并且事先未知),而无需统一为最常见的类型,从而保留混合类型数据的完整性。
-
高性能和密集的真正列式存储:将任何插入的 JSON 键路径存储和读取为原生的、密集的子列,从而实现高数据压缩,并保持在经典类型上看到的查询性能。
-
可扩展性:允许限制单独存储的子列数量,以扩展 JSON 存储,从而对 PB 级数据集进行高性能分析。
-
调优:允许 JSON 解析提示(JSON 路径的显式类型、应在解析期间跳过的路径等)。
本文的其余部分将解释我们如何通过首先构建超出 JSON 更广泛应用的基石组件来开发新的 JSON 类型。
构建模块 1 - Variant 类型
Variant 数据类型 是实现我们新的 JSON 数据类型的第一个构建模块。它被设计为一个完全独立的功能,可以在 JSON 之外使用,并且允许在同一表列中高效地存储(和读取)具有不同数据类型的值。无需统一为最常见的类型。这解决了我们的 第一个 和 第二个 挑战。
ClickHouse 中的传统数据存储
在没有新的 Variant 数据类型的情况下,ClickHouse 表的列都具有固定的类型,并且所有插入的值都必须是目标列的正确数据类型,或者隐式地强制转换为所需的类型。
为了更好地理解 Variant 类型的工作原理,下图显示了 ClickHouse 如何传统地将具有固定数据类型列的 MergeTree 系列表的数据存储在磁盘上(每个 数据部分)
重现上图中示例表的 SQL 代码 在此。请注意,我们用其数据类型注释了每一列,例如列 C1
的类型为 Int64
。由于 ClickHouse 是一个 列式数据库,因此每个表列的值都存储在磁盘上的单独(高度 压缩)列文件中。由于列 C2
是 Nullable,ClickHouse 使用 一个单独的文件,其中包含 NULL 掩码,以及包含值的普通列文件,以区分 NULL 值和空(默认)值。对于表列 C3
,上图显示了 ClickHouse 如何通过使用磁盘上的单独文件存储每个表行中每个数组的大小来原生支持存储 数组。这些大小值用于计算访问数据文件中数组元素的相应偏移量。
动态变化数据的存储扩展
借助新的 Variant 数据类型,我们可以将上表所有列中的所有值存储在单个列中。下图(您可以单击它放大)草绘了此类列的工作原理以及如何在 ClickHouse 的磁盘列式存储之上实现它(每个数据部分)
此处 是重新创建上图中所示示例表的 SQL 代码。我们使用其 Variant 类型注释了 ClickHouse 表列 C
,指定 我们要存储整数、字符串和整数数组的混合作为 C
的值。对于这样的列,ClickHouse 将所有具有相同具体数据类型的值存储在单独的子列中(类型变体列数据文件,它们本身看起来几乎与 上一个 示例中的列数据文件相同)。例如,所有整数值都存储在 C.Int64
.bin
文件中,所有 String 值都存储在 C.String
.bin
中,依此类推。
用于在子类型之间切换的鉴别器列
为了知道 ClickHouse 表的每一行使用哪种类型,ClickHouse 为每种数据类型分配一个鉴别器值,并存储一个相应的附加 (UInt8
) 列数据文件,其中包含这些鉴别器(上图中的 C
.variant
_discr
.bin
)。每个鉴别器值表示已排序的使用类型名称列表的索引。鉴别器 255 保留用于 NULL
值,这意味着根据设计,Variant 最多可以有 255 种不同的具体类型。
请注意,我们不需要单独的 NULL 掩码文件 来区分 NULL 值和默认值。
此外,请注意,有一种 特殊的紧凑型 鉴别器序列化形式(用于优化典型的 JSON 场景)。
密集数据存储
单独的类型变体列数据文件是密集的。我们不在这些文件中存储 NULL
值。在具有许多唯一但稀疏的 JSON 键的场景中,我们不会为没有特定 JSON 路径的实际值的行存储默认值,如 此 上图(作为反例)所示。这解决了我们的 第四个 挑战。
由于类型变体的这种密集存储,我们还需要从鉴别器列中的行到相应类型变体列数据文件中的行的映射。为此,我们使用一个附加的 UInt64
偏移量列(参见上图中的 offsets
),该列仅存在于内存中,但不存储在磁盘上(内存中的表示可以从鉴别器列文件动态创建)。
例如,为了获得上图中 ClickHouse 表第 6 行中的值,ClickHouse 检查鉴别器列中的第 6 行,以识别包含请求值的类型变体列数据文件:C.Int64
.bin
。此外,通过检查 offsets
文件的第 6 行:偏移量 2,ClickHouse 知道 C.Int64
.bin
文件中请求值的具体偏移量。因此,ClickHouse 表第 6 行的请求值为 44。
Variant 类型的任意嵌套
Variant 列中嵌套的类型顺序无关紧要:Variant(T1, T2)
= Variant(T2, T1)
。此外,Variant 类型允许任意嵌套,例如,您可以将 Variant 类型用作 Variant 类型内类型变体之一。我们用另一张图演示这一点(您可以单击它放大)
可以在 此处 找到复制上图中示例表的 SQL 代码。这一次,我们指定我们想要使用 Variant 列 C
来存储整数、字符串和包含 Variant 值的数组 - 整数和字符串的混合。上图草绘了 ClickHouse 如何使用我们在上面解释的 Variant 存储方法,嵌套在数组列数据文件中,以实现嵌套的 Variant 类型。
将 Variant 嵌套类型作为子列读取
Variant 类型 支持 使用类型名称作为子列从 Variant 列读取单个嵌套类型的值。例如,您可以使用语法 C.Int64
从上表读取 Int64
C
子列的所有整数值
SELECT C.Int64
FROM test;
┌─C.Int64─┐
1. │ 42 │
2. │ ᴺᵁᴸᴸ │
3. │ ᴺᵁᴸᴸ │
4. │ 43 │
5. │ ᴺᵁᴸᴸ │
6. │ ᴺᵁᴸᴸ │
7. │ 44 │
8. │ ᴺᵁᴸᴸ │
9. │ ᴺᵁᴸᴸ │
└─────────┘
构建模块 2 - Dynamic 类型
Variant 类型之后的下一步是在其之上实现 Dynamic 类型。与 Variant 类型一样,Dynamic 类型被实现为一个独立的功能,可以独立使用,而无需 JSON 上下文。
Dynamic 类型可以被视为 Variant 类型的增强,引入了两个关键的新功能
我们将在下面简要介绍这两个新功能。
无需指定子类型
下图(您可以单击它放大)显示了一个包含单个 Dynamic 列的 ClickHouse 表及其在磁盘上的存储(每个数据部分)
您可以使用 此 SQL 代码重新创建上图中描绘的表。我们可以将任何类型的值插入到 Dynamic 列 C
中,而无需像在 Variant 类型中那样预先指定类型。
在内部,Dynamic 列以与 Variant 列 相同 的方式在磁盘上存储数据,外加一些关于存储在特定列中的类型的附加信息。上图显示,存储与 Variant 列的不同之处仅在于它有一个附加文件,C.dynamic_structure.bin
,其中包含关于存储为子列的类型列表的信息,以及类型变体列数据文件大小的统计信息。此元数据用于子列读取和数据部分合并。
防止列文件雪崩
Dynamic 类型还支持通过在类型声明中指定 max_types
参数来限制存储为单独列数据文件的类型数量:Dynamic(max_types=N)
,其中 0 <= N < 255。max_types
的默认值为 32。当达到此限制时,所有剩余类型都将存储在具有特殊结构的单个列数据文件中。下图显示了一个示例(您可以单击它放大)
此处 是生成上图所示示例表的 SQL 脚本。这一次,我们使用了一个 Dynamic 列 C
,并将 max_types
参数设置为 3。
因此,只有前三种使用的类型存储在单独的列数据文件中(这对于压缩和分析查询是高效的)。来自其他使用类型(在上例表中用绿色突出显示)的所有值都存储在具有 String
类型的单个列数据文件 (C.SharedVariant.bin
) 中。SharedVariant 中的每一行都包含一个字符串值,该值包含以下数据:<binary_encoded_data_type><binary_value>。使用这种结构,我们可以在单个列中存储(和检索)不同类型的值。
将动态嵌套类型作为子列读取
与 Variant 类型一样,Dynamic 类型 支持 使用类型名称作为子列从 Dynamic 列读取单个嵌套类型的值
SELECT C.Int64
FROM test;
┌─C.Int64─┐
1. │ 42 │
2. │ ᴺᵁᴸᴸ │
3. │ ᴺᵁᴸᴸ │
4. │ 43 │
5. │ ᴺᵁᴸᴸ │
6. │ ᴺᵁᴸᴸ │
7. │ 44 │
8. │ ᴺᵁᴸᴸ │
9. │ ᴺᵁᴸᴸ │
└─────────┘
ClickHouse JSON 类型:整合所有内容
在实现了 Variant 和 Dynamic 类型之后,我们拥有了所有必需的构建模块,可以在 ClickHouse 的列式存储之上实现新的强大 JSON 类型,克服了我们在 简介 中提出的所有挑战,并支持:
-
动态变化的数据:允许同一 JSON 路径的值具有不同的数据类型(可能是互不兼容的,并且事先未知),而无需统一为最常见的类型,从而保留混合类型数据的完整性。
-
高性能和密集的真正列式存储:将任何插入的 JSON 键路径存储和读取为原生的、密集的子列,从而实现高数据压缩,并保持在经典类型上看到的查询性能。
-
可扩展性:允许限制单独存储的子列数量,以扩展 JSON 存储,从而对 PB 级数据集进行高性能分析。
-
调优:允许 JSON 解析提示(JSON 路径的显式类型、应在解析期间跳过的路径等)。
我们的新 JSON 类型 允许存储任何结构的 JSON 对象,并允许使用 JSON 路径作为子列从中读取每个 JSON 值。
JSON 类型声明
新类型在其声明中具有多个可选参数和提示
<column_name> JSON(
max_dynamic_paths=N,
max_dynamic_types=M,
some.path TypeName,
SKIP path.to.skip,
SKIP REGEXP 'paths_regexp')
其中
-
max_dynamic_paths
(默认值1024
)指定有多少 JSON 键路径作为子列单独存储。如果超出此限制,所有其他路径将与特殊结构的单个子列一起存储。 -
max_dynamic_types
(默认值32
)介于0
和254
之间,并指定对于类型为Dynamic
的单个 JSON 键路径列,有多少不同的数据类型存储为单独的列数据文件。如果超出此限制,所有新类型将与特殊结构的单个列数据文件一起存储。 -
some.path TypeName
是特定 JSON 路径的类型提示。此类路径始终作为具有指定类型的子列存储,从而提供性能保证。 -
SKIP path.to.skip
是特定 JSON 路径的提示,应在 JSON 解析期间跳过这些路径。此类路径永远不会存储在 JSON 列中。如果指定的路径是嵌套的 JSON 对象,则将跳过整个嵌套对象。 -
SKIP REGEXP 'path_regexp'
是一个带有正则表达式的提示,用于在 JSON 解析期间跳过路径。与此正则表达式匹配的所有路径永远不会存储在 JSON 列中。
真正的列式 JSON 存储
下图(您可以单击它放大)显示了一个包含单个 JSON 列的 ClickHouse 表,以及该列的 JSON 数据如何在 ClickHouse 的磁盘列式存储之上高效实现(每个数据部分)
使用下面的 此 SQL 代码重新创建上图所示的表。我们的示例表中的列 C
的类型为 JSON
,我们提供了两个类型提示,指定 JSON 路径 a.b
和 a.c
的类型。
我们的表列包含 6 个 JSON 文档,每个唯一 JSON 键路径的叶值都存储在磁盘上,要么作为常规列数据文件(对于类型化的 JSON 路径 - 带有类型提示的路径,请参见上图中的 C.a.b
和 C.a.c
),要么作为动态子列(对于动态 JSON 路径 - 具有潜在动态变化数据的路径,请参见上图中的 C.a.d
、C.a.d.e
和 C.a.e
)。对于后者,ClickHouse 使用 Dynamic 数据类型。
此外,JSON 类型使用一个特殊文件 (object_structure
),其中包含关于动态路径的元数据信息以及每个动态路径的非空值统计信息(在列序列化期间计算)。此元数据用于读取子列和合并数据部分。
防止列文件雪崩
为了防止在以下场景中磁盘上列文件数量激增:(1) 单个 JSON 键路径中存在大量动态类型,以及 (2) 存在大量唯一动态 JSON 键路径,JSON 类型允许:
(1) 使用 max_dynamic_types
(默认值 32
)参数限制对于单个 JSON 键路径,有多少不同的数据类型存储为单独的列数据文件。
(2) 使用 max_dynamic_paths
(默认值 1024
)参数限制有多少 JSON 键路径作为子列单独存储。
这解决了我们的 第三个 挑战。
我们在 上面 进一步给出了 (1) 的示例。我们用另一张图演示 (2)(您可以单击它放大)
此 代码是用于重现上图表的 SQL 代码。与上一个示例一样,我们 ClickHouse 表的列 C
的类型为 JSON,
,我们提供了相同的两个类型提示,指定 JSON 路径 a.b
和 a.c
的类型。
此外,我们将 max_dynamic_paths
参数设置为 3。这导致 ClickHouse 仅将前三个动态 JSON 路径的叶值存储为动态子列(使用 Dynamic 类型)。
所有其他动态 JSON 路径及其类型信息和值(在上例表中用绿色突出显示)都作为共享数据存储 - 请参见上图中的文件 C
.object_
shared_
data
.size0.bin
、C
.object_shared_data.paths.bin
和 C.object_shared_data.values.bin
。请注意,共享数据文件 (object_shared_data.values
) 的类型为 String
。每个条目都是一个字符串值,该值包含以下数据:<binary_encoded_data_type><binary_value>。
对于共享数据,我们还在 object_structure.bin
文件中存储附加统计信息(用于读取子列和合并数据部分)。我们存储共享数据列中存储的(当前为前 10000 个)路径的非空值统计信息。
读取 JSON 路径
JSON 类型 支持 使用路径名作为子列读取每个路径的叶值。例如,可以使用语法 C.a.b
读取上例表中 JSON 路径 a.b
的所有值
SELECT C.a.b
FROM test;
┌─C.a.b─┐
1. │ 10 │
2. │ 20 │
3. │ 30 │
4. │ 40 │
5. │ 50 │
6. │ 60 │
└───────┘
如果请求路径的类型未在 JSON 类型声明中通过类型提示指定,则路径值将始终具有 Dynamic 类型
SELECT
C.a.d,
toTypeName(C.a.d)
FROM test;
┌─C.a.d───┬─toTypeName(C.a.d)─┐
1. │ 42 │ Dynamic │
2. │ 43 │ Dynamic │
3. │ ᴺᵁᴸᴸ │ Dynamic │
4. │ foo │ Dynamic │
5. │ [23,24] │ Dynamic │
6. │ ᴺᵁᴸᴸ │ Dynamic │
└─────────┴───────────────────┘
也可以使用特殊的 JSON 语法 JSON_column.some.path.:TypeName
读取 Dynamic 类型的子列。
SELECT C.a.d.:Int64
FROM test;
┌─C.a.d.:`Int64`─┐
1. │ 42 │
2. │ 43 │
3. │ ᴺᵁᴸᴸ │
4. │ ᴺᵁᴸᴸ │
5. │ ᴺᵁᴸᴸ │
6. │ ᴺᵁᴸᴸ │
└────────────────┘
此外,JSON 类型 支持 使用特殊的语法 JSON_column.^some.path
将嵌套的 JSON 对象作为类型为 JSON 的子列读取。
SELECT C.^a
FROM test;
┌─C.^`a`───────────────────────────────────────┐
1. │ {"b":10,"c":"str1","d":"42"} │
2. │ {"b":20,"c":"str2","d":"43"} │
3. │ {"b":30,"c":"str3","e":"44"} │
4. │ {"b":40,"c":"str4","d":"foo","e":"baz"} │
5. │ {"b":50,"c":"str5","d":["23","24"]} │
6. │ {"b":60,"c":"str6","d":{"e":"bar"},"e":"45"} │
└──────────────────────────────────────────────┘
SELECT toTypeName(C.^a)
FROM test
LIMIT 1;
┌─toTypeName(C.^`a`)───────┐
1. │ JSON(b UInt32, c String) │
└──────────────────────────┘
目前,出于性能原因,点语法(
.
)不读取嵌套对象。数据的存储方式使得按路径读取字面值非常高效,但是按路径读取所有子对象需要读取更多数据,有时可能会更慢。因此,当我们想要返回一个对象时,我们需要使用.^
代替。我们计划统一这两种不同的.
语法。
还有一个细节 - 紧凑的鉴别器序列化
在许多场景中,动态 JSON 路径的值大多具有相同的类型。在这种情况下,Dynamic 类型的鉴别器文件将主要包含相同的数字(类型鉴别器)。
类似地,当存储大量唯一但稀疏的 JSON 路径时,每个路径的鉴别器文件将主要包含值 255(表示 NULL 值)。
在这两种情况下,鉴别器文件都会被很好地压缩,但是当所有行都具有相同的值时,仍然可能非常冗余。
为了优化这一点,我们实现了一种特殊的紧凑格式的鉴别器序列化。如果目标粒度中的所有鉴别器都相同,我们只序列化 3 个值(而不是 8192 个值),而不是像通常那样将鉴别器写为 UInt8
值。
- 紧凑粒度格式的指示器
- 此粒度中值的数量的指示器
- 鉴别器值
此优化可以通过 MergeTree 设置 use_compact_variant_discriminators_serialization
控制(默认启用)。
我们才刚刚开始
在这篇文章中,我们概述了我们如何从头开始开发新的 JSON 类型,首先创建了基础构建块,这些构建块在 JSON 之外也有更广泛的应用。
这种新的 JSON 类型旨在取代现在已弃用的 Object('json') 数据类型,解决其局限性并提高整体功能。
新的实现目前以实验性版本发布,用于测试目的,我们的功能集尚未完成。我们的 JSON 路线图 包括一些强大的增强功能,例如在表的主键或数据跳过索引中使用 JSON 键路径。
最后但并非最不重要的一点,我们创建的最终实现新 JSON 类型的构建块为扩展 ClickHouse 以支持其他半结构化类型(如 XML、YAML 等)铺平了道路。
请继续关注即将发布的文章,我们将在其中使用真实世界的数据展示新 JSON 类型的主要查询功能,以及数据压缩和查询性能的基准测试。我们还将深入研究 JSON 实现的内部工作原理,以揭示数据如何在内存中高效地合并和处理。
如果您正在使用 ClickHouse Cloud 并想测试我们的新 JSON 数据类型,请联系我们的支持以获得私有预览访问权限。