Clickhouse Projection 特性探索

769 阅读8分钟

年初的clickhouse meetup上快手团队分享了clickhouse projection在其公司内部的实践。分享包括了projection原理、使用、性能测试等内容。从性能测试的数据上看,projeciton对查询性能有着百倍级别的提升,意味着之前分钟级的查询响应延迟,将会提升到秒级响应。秒级的查询响应延迟,将会提升到毫秒级的响应,对于使用者将会有更加完美的体验。

看完了快手同学的clickhouse projeciton的分享,在我脑中也产生了几个问题?

  1. 没有projection功能之前,clickhouse还存在什么问题?

  2. clickhouse projection如何解决的问题?

  3. clickhouse projection适用于哪些场景?

  4. clickhouse proejction有什么要注意的吗?

没有projection功能之前,clickhouse还存在什么问题?

clickhosue作为一款olap引擎,处于数据平台中的最顶层,直接对接平台用户。查询性能的好坏,直接决定着用户的使用体验。

  1. clickhouse的查询性能虽然已经非常完美,但是面对超大数据量的场景还是会存在一定的问题,原因是clickhouse是基于内存计算的MPP架构分析型数据库,与Spark, Hive, MR等计算框架不同,计算 过程中的临时数据没有磁盘选项。查询过程中,数据会加载到内存中。如果内存配置不够,将会导致查询失败,对clickhouse集群的稳定也会有一定的影响。

  2. 用户在数据查询的场景中,会有着一定的使用习惯。比如,每天定时都会查看一些特定的图表。这些图表中包含全量的数据统计,复杂的数据查询逻辑等。这些查询相较于其他查询,可以归属于异常查询。这些查询可能因为内存问题导致查询失败,也可能因为复杂的计算逻辑导致查询时间过长,影响平台上其他用户的查询。

clickhouse projection如何解决的问题?

在OLAP领域中,根据数据模型主要分为ROLAP(Relational OLAP) 关系OLAP,MOLAP(Multidimension OLAP) 多维OLAP 两种。ROLAP将数据表达为二维关系模型,类似关系型数据库模型,数据表达能力较好,对外提供SQL接口。MOLAP将OLAP分析所用到的多维数据物理上存储为多维数组的形式,形成“立方体”的结构。维的属性值被映射成多维数组的下标值或下标的范围,而汇总数据作为多维数组的值存储在数组的单元中,采用预聚合的思想,加速数据查询,但是数据模型不够灵活。

clickhouse作为ROLAP典型代表之一,纯列式存储单表查询性能几乎没有对手。projection 名字起源于vertica,相当于传统意义上的物化视图。它借鉴 MOLAP 预聚合的思想,在数据写入的时候,根据 projection 定义的表达式,计算写入数据的聚合数据同原始数据一并写入。数据查询的过程中,如果查询SQL通过分析可以通过聚合数据得出,直接查询聚合数据减少计算的开销,解决了由于数据量导致的内存问题。

projeciton 底层存储上属于part目录下数据的扩充,可以理解为查询索引的一种形式。

从数据写入逻辑的核心代码上看(clickhouse version 21.7),多个projection在part目录下以多个子目录存储,projection目录下存储基于原始数据聚合的数据。所以,projection写入与原始数据写入同步,只有创建projection之后写入的数据才会被物化,保证数据的一致性。

MergeTreeDataWrite.cpp.390

如果存在projection配置,将projection part添加new_data_part中。
if (metadata_snapshot->hasProjections())
{
    for (const auto & projection : metadata_snapshot->getProjections())
    {
        /// 1. 获取projection query的执行计划。
        /// 2. 当前Block作为输入,计算聚合结果
        /// 3. 获取数据流
        auto in = InterpreterSelectQuery(
                      projection.query_ast,
                      context,
                      Pipe(std::make_shared<SourceFromSingleChunk>(block, Chunk(block.getColumns(), block.rows()))),
                      SelectQueryOptions{
                          projection.type == ProjectionDescription::Type::Normal ? QueryProcessingStage::FetchColumns : QueryProcessingStage::WithMergeableState})
                      .execute()
                      .getInputStream();
        in = std::make_shared<SquashingBlockInputStream>(in, block.rows(), std::numeric_limits<UInt64>::max());
        in->readPrefix();
        // 4. 读取prjeciton计算的数据块
        auto projection_block = in->read();
        if (in->read())
            throw Exception("Projection cannot grow block rows", ErrorCodes::LOGICAL_ERROR);
        in->readSuffix();
        if (projection_block.rows())
        {
            // 5. 将聚合的数据(.proj)添加到new_data_part中
            new_data_part->addProjectionPart(projection.name, writeProjectionPart(projection_block, projection, new_data_part.get()));
        }
    }
}

从文件系统目录上看,p2.proj 为data part下p2 projection的数据目录,目录下聚合列,聚合函数作为单独的列存文件存储。

├── dim1.bin 
├── dim1.mrk2 
├── dim2.bin 
├── dim2.mrk2 
├── dim3.bin 
├── dim3.mrk2 
├── event_key.bin 
├── event_key.mrk2 
├── event_time.bin 
├── event_time.mrk2 
├── p2.proj 
│   ├── checksums.txt 
│   ├── columns.txt 
│   ├── count%28%29.bin 
│   ├── count%28%29.mrk2 
│   ├── count.txt 
│   ├── default_compression_codec.txt 
│   ├── dim3.bin 
│   ├── dim3.mrk2 
│   ├── groupBitmap%28user%29.bin 
│   ├── groupBitmap%28user%29.mrk2 
│   └── primary.idx 

clickhouse projection适用于哪些场景?

为了探索projection适用于哪些场景,准备了典型的用户行为数据集,数据量为1亿条, 数据模型选择事件模型,模型中包含了用户做过什么事件,以及事件对应的维度。

维度选择上,dim1,dim2为普通维度值,维度值种类有10种。dim3为高基维维度,维度值种类有100000种。

user

唯一用户标识

event_key

事件时间

event_time

事件时间

dim1

普通维度

dim2

普通维度

dim3

高基维度

如何为数据表构建projection?

1. 建表的时候指定多个projection 定义,projection中为基本的select语句,可以省略from table子句,默认与源表保持一致。

CREATE TABLE event_projection1 
( 
    `event_key` String, 
    `user` UInt32, 
    `event_time` DateTime64(3, 'Asia/Shanghai'), 
    `dim1` String, 
    `dim2` String, 
    `dim3` String, 
    PROJECTION p1 
    ( 
        SELECT 
            groupBitmap(user), 
            count(1) 
        GROUP BY dim1 
    ) 
) 
ENGINE = MergeTree() 
ORDER BY (event_key, user, event_time) 

2. alter table 语句补充projection定义

ALTER TABLE event_projection1 
    ADD PROJECTION p2 
    ( 
        SELECT 
            count(1), 
            groupBitmap(user) 
        GROUP BY dim1, dim3 
    ) 

怎么查询才能命中projection?

  1. select表达式必须为projection定义中select 表达式的子集。

  2. group by clause必须为projection定义中group by clause的子集。

  3. where clause key必须为projeciton定义中的group by column的子集。

如何知道是否命中了projection?

  1. explain查看执行计划,ReadFromStorage (MergeTree(with projection)) 表示命中projection

    EXPLAIN SQL expain actions=1 select dim, count(1) from event_projection group by dim1

    执行计划: Expression ((Projection + Before ORDER BY))
    Actions: INPUT :: 0 -> dim1 String : 0
    INPUT :: 1 -> count() UInt64 : 1
    Positions: 0 1
    SettingQuotaAndLimits (Set limits and quota after reading from storage)
    ReadFromStorage (MergeTree(with projection))

2. clickouse 查询关键日志

查询命中了projection p 
(SelectExecutor): Choose aggregate projection p 
(SelectExecutor): projection required columns: dim1, count() 
(SelectExecutor): Reading approx. 63 rows with 4 streams 

查询效果如何?

场景: select dim1, count(1) from event_projection group by dim1

projection定义

查询耗时

存储

插入时间

无projection

5.347s

650M

7min

dim1聚合

0.018s

654M

12min

(dim1 + dim3) 聚合

0.319s

923M

20min

  1. 命中projection相比没有命中projection对于查询性能的提升非常明显。

  2. 构建projection对于存储,数据插入有一定的额外开销。

  3. 如果构建projection的时候混入了高基维度,查询耗时相比没有混入高基维度,查询性能同比降低了近200倍,存储与插入时间也付出了更多的额外开销。

场景: 不同聚合函数性能提升效果对比

聚合函数

没有projection

普通维度聚合

高基维度聚合

count(1)

5.347s

0.018s

0.319s

groupBitmap(user)

7.936s

0.040s

5.840s

  1. 相同的条件下groupBitmap没有count聚合函数的性能提升效果好,

  2. 高基维的场景下,即使命中了projection与没有命中projection,查询效果几乎相同, 而且付出了额外的存储计算开销。

综上以上测试可以得出,高基维度对于projection特性并不友好,查询性能提升有限,并且还有付出不小的额外开销,不建议projection构建的时候应用高基维度。

clickhouse projection有什么要注意的吗?

1. 额外的存储开销

上面有提到,每个projection在part目录下存到单独的目录独立存储,projection目录下存储基于原始数据计算的聚合数据。projection数据可以抽象理解为一张聚合表,按照不同的维度聚合,聚合度不同,projection的存储开销也会同。

2. 影响数据写入速度

通过源代码分析可以发现, projection写入与原始数据写入过程保持一致。每一份数据part写入都会基于原始数据Block结合projection定义计算聚合数据,增加了数据写入的额外开销,也增加了数据写入的时间,降低了数据的时效性。

3. 历史数据不会自动物化

projection基于part粒度存储,并且与数据写入保证一致,创建了projection之后插入的数据才会被物化。同时,part之间的merge包含projection之间的merge,如果part之间的projeciton定义不一致,将会导致part merge失败,可以通过projection materialization操作将part中projection数据拉齐。

projection materialization: projection计算基于原始数据block,对于比较大part计算的过程中很容易出现内存问题。可以构建insert select pipeline模拟新数据产生的过程,中间会生成多个临时小part,小part中的proejction进行多段merge。

4. part过多导致projection不能命中

数据查询命中projeciton其中的一个条件为50%以上的part 覆盖projection。存在部分场景,由于数据频繁写入,导致生成很多小part,part数量增加增大了计算覆盖率的分母,导致没有达成命中projection的条件。但是,伴随着part的合并part数量的减少,之后的查询有可能命中projection。

本篇文章只是针对clickhouse proejction特性进行了简单的介绍,并进行了基础的性能测试。

在性能测试中,也发现了高基维度对于clickhouse projection的影响。后续将会其他文章对 clickhouse 的查询流程,底层存储进行细致的详解,分析其影响的内部原因。