JSON 优化
- RFC PR: datafuselabs/databend#6995
- Tracking Issue: datafuselabs/databend#6994
概要
本 RFC 描述了 JSON 性能优化的设计。
动机
目前,Databend 半结构化数据以原始文本 JSON 格式存储,并使用 serde_json
进行序列化和反序列化。
它存在以下性能问题:
- 每次使用 JSON 都必须进行解析,而文本解析非常慢,特别是对于大型复杂对象数据。
- 查询单个键路径需要读取和解析整个 JSON 数据,这会花费更多的解析时间并占用更多的内存。
为了使半结构化数据的查询性能与其他数据类型一样快。本 RFC 引入了以下两个提案:
- 自动检测 JSON 列中经常查询的键路径,并将它们提取为虚拟列,以实现与其他列类似的查询性能。
- 使用 JSONB 而不是 JSON 作为半结构化数据类型的底层二进制编码格式,这将优化解析速度并有利于键路径访问。
指导性解释
无。
参考性解释
虚拟列
JSON 的 schema 可以任意更改。但是,在实际使用中,JSON 数据通常由机器生成,并且具有相当严格和可预测的结构。 利用此功能,我们可以检测和提取 JSON 中经常查询的键路径作为虚拟列,以加速查询处理。
收集经常访问的 JSON 键路径
提取和存储虚拟列需要额外的解析过程和存储资源,因此为所有键路径生成虚拟列是不合适的。 我们应该只为经常查询的 JSON 键路径生成虚拟列。
为了知道哪些键路径经常被查询, 我们使用 FPGrowth 算法 来统计 JSON 数据键路径的访问频率。 "FP" 代表频繁模式,通常用于计算项目频率并识别频繁项目。 每次用户查询 JSON 数据的某个键路径时,FPGrowth 算法都会记录它,最终生成一个树状的统计信息。 我们可以使用此统计信息来确定哪些键路径需要生成虚拟列。
提取虚拟列
提取虚拟列的操作是异步执行的,因此不会影响数据插入的性能。 由于 Databend 会定期压缩表块,我们可以将提取虚拟列作为一项附加操作,如果列数据类型为 JSON。 这样,可以重用为压缩而读取的数据,这将减少不必要的读取放大。
提取虚拟列的过程如下:
- 收集访问计数超过 FPGrowth 算法生成的阈值的键路径。
- 解析 JSON 数据,推断所有键路径的 JSON schema,包括值的类型和行号。
- 检查所有键路径,如果值的类型相同,并且每行都有数据,则从此键路径提取数据以生成虚拟列并将其存储在单独的 parquet 文件中。
以下面的 JSON 为例。
{"id":1,"title":"a","user":{"id":1,"name":"a"},"tags":["t1","t2"]}
{"id":2,"title":"b","user":{"id":2,"name":"b"},"tags":["t3","t4"]}
{"id":3,"title":"c","user":{"id":3,"name":3}}
{"id":4,"title":"d","user":{"id":4,"name":4}}
键路径 id
、title
和 user:id
具有相同的数据类型,我们可以将它们提取为虚拟列。
对于 user.name
,值的类型不同。
对于 tags
,它不在每一行上。我们不为它们生成虚拟列。
将键路径访问下推到存储
有两种访问 json 键路径的方式:点表示法,例如 col:level1:level2
和括号表示法,例如 col['level1'][0]
。
例如:
create table test (id int8, v json);
insert into test values(1, parse_json('{"k1":{"k2":"v"},"a":[0,1,2]}'));
select v:k1:k2, v['a'][0] from test;
+---------+-----------+
| v:k1:k2 | v['a'][0] |
+---------+-----------+
| "v" | 0 |
+---------+-----------+
目前,JSON 键路径的访问将被转换为一个 get
标量函数,并通过键遍历 JSON 以获取值。
为了使用虚拟列来提高性能,我们需要修改查询计划并将键路径的访问下推到存储层。
由于我们使用 JSONB 而不是 JSON,即使对于没有生成虚拟列的键路径,下推到存储层也会带来好处。