81.【数据库】ClickHouse从入门到放弃-SummingMergeTree

435 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第17天,点击查看活动详情 |

文档参考:《ClickHouse原理解析与应用实践(数据库技术丛书)(朱凯)》

70.【数据库】ClickHouse从入门到放弃-MergeTree的创建方式 - 掘金 (juejin.cn)

71.【数据库】ClickHouse从入门到放弃-MergeTree的存储结构 - 掘金 (juejin.cn)

72.【数据库】ClickHouse从入门到放弃-数据分区 - 掘金 (juejin.cn)

73.【数据库】ClickHouse从入门到放弃-一级索引 - 掘金 (juejin.cn)

74.【数据库】ClickHouse从入门到放弃-二级索引 - 掘金 (juejin.cn)

75.【数据库】ClickHouse从入门到放弃-数据存储 - 掘金 (juejin.cn)

76.【数据库】ClickHouse从入门到放弃-数据标记 - 掘金 (juejin.cn)

77.【数据库】ClickHouse从入门到放弃-对于分区、索引、标记和压缩数据的协同总结 - 掘金 (juejin.cn)

78.【数据库】ClickHouse从入门到放弃-MergeTree 数据TTL - 掘金 (juejin.cn)

79.【数据库】ClickHouse从入门到放弃-MergeTree 多路径存储策略 - 掘金 (juejin.cn)

80.【数据库】ClickHouse从入门到放弃-ReplacingMergeTree - 掘金 (juejin.cn)

1.SummingMergeTree

假设有这样一种查询需求:终端用户只需要查询数据的汇总结果,不关心明细数据,并且数据的汇总条件是预先明确的(GROUP BY条件明确,且不会随意改变)。

对于这样的查询场景,在ClickHouse中如何解决呢?最直接的方案就是使用MergeTree存储数据,然后通过GROUP BY聚合查询,并利用SUM聚合函数汇总结果。这种方案存在两个问题。

存在额外的存储开销:终端用户不会查询任何明细数据,只关心汇总结果,所以不应该一直保存所有的明细数据。

存在额外的查询开销:终端用户只关心汇总结果,虽然MergeTree性能强大,但是每次查询都进行实时聚合计算也是一种性能消耗。

SummingMergeTree就是为了应对这类查询场景而生的。顾名思义,它能够在合并分区的时候按照预先定义的条件聚合汇总数据,将同一分组下的多行数据汇总合并成一行,这样既减少了数据行,又降低了后续汇总查询的开销。

在先前介绍MergeTree原理时曾提及,在MergeTree的每个数据分区内,数据会按照ORDER BY表达式排序。主键索引也会按照PRIMARY KEY表达式取值并排序。而ORDER BY可以指代主键,所以在一般情形下,只单独声明ORDER BY即可。此时,ORDER BY与PRIMARY KEY定义相同,数据排序与主键索引相同。

如果需要同时定义ORDER BY与PRIMARY KEY,通常只有一种可能,那便是明确希望ORDER BY与PRIMARY KEY不同。这种情况通常只会在使用SummingMergeTree或AggregatingMergeTree时才会出现。这是为何呢?这是因为SummingMergeTree与AggregatingMergeTree的聚合都是根据ORDER BY进行的。由此可以引出两点原因:主键与聚合的条件定义分离,为修改聚合条件留下空间。

现在用一个示例说明。假设一张SummingMergeTree数据表有A、B、C、D、E、F六个字段,如果需要按照A、B、C、D汇总,则有:

ORDER BY (AB,C,D)

但是如此一来,此表的主键也被定义成了A、B、C、D。而在业务层面,其实只需要对字段A进行查询过滤,应该只使用A字段创建主键。所以,一种更加优雅的定义形式应该是:

ORDER BY (AB、C、D)
PRIMARY KEY A

如果同时声明了ORDER BY与PRIMARY KEY,MergeTree会强制要求PRIMARY KEY列字段必须是ORDER BY的前缀。例如下面的定义是错误的:

ORDER BY (B、C)
PRIMARY KEY A

PRIMARY KEY必须是ORDER BY的前缀:

ORDER BY (B、C)
PRIMARY KEY B

这种强制约束保障了即便在两者定义不同的情况下,主键仍然是排序键的前缀,不会出现索引与数据顺序混乱的问题。

假设现在业务发生了细微的变化,需要减少字段,将先前的A、B、C、D改为按照A、B聚合汇总,则可以按如下方式修改排序键:

ALTER TABLE table_name MODIFY ORDER BY (A,B)

在修改ORDER BY时会有一些限制,只能在现有的基础上减少字段。如果是新增排序字段,则只能添加通过ALTER ADD COLUMN新增的字段。但是ALTER是一种元数据的操作,修改成本很低,相比不能被修改的主键,这已经非常便利了。

现在开始正式介绍SummingMergeTree的使用方法。表引擎的声明方式如下所示:

ENGINE = SummingMergeTree((col1,col2,…))

其中,col1、col2为columns参数值,这是一个选填参数,用于设置除主键外的其他数值类型字段,以指定被SUM汇总的列字段。如若不填写此参数,则会将所有非主键的数值类型字段进行SUM汇总。接来下用一组示例说明它的使用方法:

CREATE TABLE summing_table(
    id String,
    city String,
    v1 UInt32,
    v2 Float64,
    create_time DateTime
)ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(create_time)
ORDER BY (id, city)
PRIMARY KEY id

注意,这里的ORDER BY是一项关键配置,SummingMergeTree在进行数据汇总时,会根据ORDER BY表达式的取值进行聚合操作。假设此时表内的数据如下所示:

┌─id──┬─city───┬─v1─┬─v2─┬────────create_time─┐
 A001   wuhan     10   20  2019-08-10 17:00:00      
 A001   wuhan     20   30  2019-08-20 17:00:00      
 A001   zhuhai    20   30  2019-08-10 17:00:00      
└─────┴───────┴───┴───┴────────────────┘
┌─id──┬─city───┬─v1─┬─v2─┬────────create_time─┐
 A001   wuhan     10  20   2019-02-10 09:00:00     
└─────┴───────┴───┴───┴───────────────┘
┌─id──┬─city───┬─v1─┬─v2─┬────────create_time─┐
 A002   wuhan     60   50   2019-10-10 17:00:00     
└────┴──────┴───┴───┴───────────────┘

执行optimize强制进行触发和合并操作:

optimize TABLE summing_table FINAL

再次查询,表内数据会变成下面的样子:

┌─id──┬─city───┬─v1─┬─v2─┬─────────create_time─┐
 A001   wuhan      30    50   2019-08-10 17:00:00       
 A001   zhuhai     20    30   2019-08-10 17:00:00       
└─────┴──────┴────┴────┴─────────────────┘
┌─id──┬─city───┬─v1─┬─v2─┬─────────create_time─┐
 A001   wuhan      10    20  2019-02-10 09:00:00       
└────┴──────┴────┴────┴─────────────────┘
┌─id──┬─city───┬─v1─┬─v2─┬─────────create_time─┐
 A002   wuhan      60    50   2019-10-10 17:00:00       
└────┴──────┴────┴────┴─────────────────┘

至此能够看到,在第一个分区内,同为A001:wuhan的两条数据汇总成了一行。其中,v1和v2被SUM汇总,不在汇总字段之列的create_time则选取了同组内第一行数据的取值。而不同分区之间,数据没有被汇总合并。

SummingMergeTree也支持嵌套类型的字段,在使用嵌套类型字段时,需要被SUM汇总的字段名称必须以Map后缀结尾,例如:

CREATE TABLE summing_table_nested(
    id String,
    nestMap Nested(
        id UInt32,
        key UInt32,
        val UInt64
    ),
    create_time DateTime
)ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(create_time)
ORDER BY id

在使用嵌套数据类型的时候,默认情况下,会以嵌套类型中第一个字段作为聚合条件Key。假设表内的数据如下所示:

┌─id──┬─nestMap.id─┬─nestMap.key─┬─nestMap.val─┬─────create_time─┐
│ A001  │ [1,1,2][10,20,30][40,50,60]2019-08-10 17:00:00 │
└─────┴────────┴─────────┴─────────┴─────────────┘

上述示例中数据会按照第一个字段id聚合,汇总后的数据会变成下面的样子:

┌─id──┬─nestMap.id─┬─nestMap.key─┬─nestMap.val─┬─────create_time─┐
│ A001  │ [1,2][30,30][90,60]2019-08-10 17:00:00 │
└────┴────────┴─────────┴─────────┴─────────────┘

数据汇总的逻辑示意如下所示:

[(1, 10, 40)] + [(1, 20, 50)] -> [(1, 30, 90)]
 
[(2, 30, 60)] -> [(2, 30, 60)]

在使用嵌套数据类型的时候,也支持使用复合Key作为数据聚合的条件。为了使用复合Key,在嵌套类型的字段中,除第一个字段以外,任何名称是以Key、Id或Type为后缀结尾的字段,都将和第一个字段一起组成复合Key。例如将上面的例子中小写key改为Key:

nestMap Nested(
        id UInt32,
        Key UInt32,
        val UInt64
    ),

上述例子中数据会以id和Key作为聚合条件。

在知道了SummingMergeTree的使用方法后,现在简单梳理一下它的处理逻辑。

(1)用ORBER BY排序键作为聚合数据的条件Key。

(2)只有在合并分区的时候才会触发汇总的逻辑。

(3)以数据分区为单位来聚合数据。当分区合并时,同一数据分区内聚合Key相同的数据会被合并汇总,而不同分区之间的数据则不会被汇总。

(4)如果在定义引擎时指定了columns汇总列(非主键的数值类型字段),则SUM汇总这些列字段;如果未指定,则聚合所有非主键的数值类型字段。

(5)在进行数据汇总时,因为分区内的数据已经基于ORBER BY排序,所以能够找到相邻且拥有相同聚合Key的数据。

(6)在汇总数据时,同一分区内,相同聚合Key的多行数据会合并成一行。其中,汇总字段会进行SUM计算;对于那些非汇总字段,则会使用第一行数据的取值。

(7)支持嵌套结构,但列字段名称必须以Map后缀结尾。嵌套类型中,默认以第一个字段作为聚合Key。除第一个字段以外,任何名称以Key、Id或Type为后缀结尾的字段,都将和第一个字段一起组成复合Key。