前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >ClickHouse查询优化

ClickHouse查询优化

原创
作者头像
charmer
发布2022-11-14 17:42:32
2K0
发布2022-11-14 17:42:32
举报

ClickHouse是OLAP(Online analytical processing)数据库,以速度见长^clickhouse_bench。ClickHouse为什么能这么快?有两点原因^why_clickhouse_is_so_fast:

  • 架构优越
    • 列式存储
    • 索引
    • 数据压缩
    • 向量化执行
    • 资源利用
  • 关注底层细节

但是,数据库设计再优越也拯救不了错误的使用方式,本文以MergeTree引擎家族为例讲解如何对查询优化。

ClickHouse查询执行过程

⚠️ 本节基于ClickHouse 22.3版本分析

clickhouser-server启动后会在while循环中等待请求,接收到查询后会调用executeQueryImpl()行数构建AST、优化并生成执行计划pipeline,最后在executeImpl()中多线程执行DAG获取结果,这篇文章只关心SQL执行,省略掉网络交互部分,查询执行流程如下图所示:

20221113144122.CfziGv.png
20221113144122.CfziGv.png

SQL的解析优化和编译原理息息相关,本节将包含大量编译原理和代码细节,属扩展知识。

词法解析和语法解析

ClickHouse拿到需要执行的SQL,首先需要将String格式的字符串解析为它能理解的数据结构,也就是AST和执行计划。构造AST部分代码如下所示:

// src/Interpreters/executeQuery.cpp

static std::tuple<ASTPtr, BlockIO> executeQueryImpl()
{
    // 构造Parser
    ParserQuery parser(end, settings.allow_settings_after_format_in_insert);

    // 将SQL转为抽象语法树
    ast = parseQuery(parser, begin, end, "", max_query_size, settings.max_parser_depth);

    // 设置query的上下文,比如SETTINGS
    ...

    if (async_insert)
    {
        ...
    } else {
        // 生成interpreter实例
        interpreter = InterpreterFactory::get(ast, context, SelectQueryOptions(stage).setInternal(internal));

        // interpreter优化AST并返回执行计划
        res = interpreter->execute();
    }

    // 返回抽象语法树和执行计划
    return std::make_tuple(ast, std::move(res));
}

值得一提的是,解析SQL生成语法树这是编译原理中词法分析和语法分析部分覆盖的事情。而词法分析分为自顶向下和自底向上两种方式,常见的词法分析方式也分为手写词法分析(往往是自顶向下的有限状态机,递归下降分析)和词法分析器(往往是自底向上,如Flex、Yacc/Bison等)。

  • 曾经GCC使用yacc/bison作为词法解析器,在3.x某个版本之后改为手写递归下降词法分析^gcc_new_c_parser
  • clang一直是手写递归下降词法分析^clang_features
20221112144923.zSq5fo.png
20221112144923.zSq5fo.png

手写词法分析比起词法分析器有几个优势(当然要写得好的情况):

  • 性能更好。可以优化热点路径等
  • 诊断和错误恢复更清晰明了。手写状态机可以完全掌控系统状态,错误处理更容易
  • 简单。不需要掌握新语法

ClickHouse解析SQL的函数如下所示:

// src/Parsers/parseQuery.cpp

ASTPtr tryParseQuery()
{
    // 将SQL拆分为token流
    Tokens tokens(query_begin, all_queries_end, max_query_size);
    IParser::Pos token_iterator(tokens, max_parser_depth);

    // 将token流解析为语法树
    ASTPtr res;
    const bool parse_res = parser.parse(token_iterator, res, expected);

    return res;
}

可以看到语法分析关键函数是perser.parse()函数,它的实现如下:

// src/Parsers/ParserQuery.cpp

bool ParserQuery::parseImpl(Pos & pos, ASTPtr & node, Expected & expected)
{
    ParserQueryWithOutput query_with_output_p(end, allow_settings_after_format_in_insert);
    ParserInsertQuery insert_p(end, allow_settings_after_format_in_insert);
    ParserUseQuery use_p;
    ParserSetQuery set_p;
    ParserSystemQuery system_p;
    ParserCreateUserQuery create_user_p;
    ParserCreateRoleQuery create_role_p;
    ParserCreateQuotaQuery create_quota_p;
    ParserCreateRowPolicyQuery create_row_policy_p;
    ParserCreateSettingsProfileQuery create_settings_profile_p;
    ParserCreateFunctionQuery create_function_p;
    ParserDropFunctionQuery drop_function_p;
    ParserDropAccessEntityQuery drop_access_entity_p;
    ParserGrantQuery grant_p;
    ParserSetRoleQuery set_role_p;
    ParserExternalDDLQuery external_ddl_p;
    ParserTransactionControl transaction_control_p;
    ParserBackupQuery backup_p;

    bool res = query_with_output_p.parse(pos, node, expected)
        || insert_p.parse(pos, node, expected)
        || use_p.parse(pos, node, expected)
        || set_role_p.parse(pos, node, expected)
        || set_p.parse(pos, node, expected)
        || system_p.parse(pos, node, expected)
        || create_user_p.parse(pos, node, expected)
        || create_role_p.parse(pos, node, expected)
        || create_quota_p.parse(pos, node, expected)
        || create_row_policy_p.parse(pos, node, expected)
        || create_settings_profile_p.parse(pos, node, expected)
        || create_function_p.parse(pos, node, expected)
        || drop_function_p.parse(pos, node, expected)
        || drop_access_entity_p.parse(pos, node, expected)
        || grant_p.parse(pos, node, expected)
        || external_ddl_p.parse(pos, node, expected)
        || transaction_control_p.parse(pos, node, expected)
        || backup_p.parse(pos, node, expected);

    return res;
}

可以发现ClickHouse将Query分为了18种类型(截止2022-11-12日),每种Query都有自己的Parser,通过关键词匹配构造AST上的节点,最终生成语法树。递归下降部分超纲了,这里就不铺开讲。

优化器

经过语法分析后生成的AST并不是执行最优解,ClickHouse包含大量基于规则的优化(rule based optimization),每个Query会遍历一遍优化规则,将满足的情况进行不改变查询语义地重写

每一种Query类型都有对应的Interpreter,后文都以Select查询举例,代码如下:

// src/Interpreters/InterpreterFactory.cpp

std::unique_ptr<IInterpreter> InterpreterFactory::get()
{
    ...
    if (query->as<ASTSelectQuery>())
    {
        return std::make_unique<InterpreterSelectQuery>(query, context, options);
    }
    ...
}

InterpreterSelectQuery类的构造函数中将AST优化、重写,代码详见src/Interpreters/InterpreterSelectQuery.cpp,这里只画流程图:

graph TD
    A[初始化settings] --> B[优化with]
    B --> C[优化joins]
    C --> D[谓词下推将where下推到prewhere]
    D --> E{是否要再次优化}
    E --> |是| A
    E --> |否| F[检查storage权限]
    F --> G[生成analysis_result和result_header]

构造执行计划

src/Interpreters/InterpreterSelectQuery.cpp文件InterpreterSelectQuery::executeImpl()方法将优化分析得到的中间数据辅助生成最终的执行计划,代码如下:

// src/Interpreters/InterpreterSelectQuery.cpp

void InterpreterSelectQuery::executeImpl()
{
    ...
    // 个人理解针对EXPLAIN PLAN,只构建执行计划不执行
    if (options.only_analyze)
    {
        ...
    }
    else
    {
        // 从磁盘读取所需列,注意这一行,后文跳转进去分析
        executeFetchColumns(from_stage, query_plan);
    }
    if (options.to_stage > QueryProcessingStage::FetchColumns)
    {
        // 在分布式执行Query时只在远程节点执行
        if (expressions.first_stage)
        {
            // 当storage不支持prewhere时添加FilterStep
            if (!query_info.projection && expressions.filter_info)
            {
                ...
            }
            if (expressions.before_array_join)
            {
                ...
            }
            if (expressions.array_join)
            {
                ...
            }
            if (expressions.before_join)
            {
                ...
            }
            // 可选步骤:将join key转为一致的supertype
            if (expressions.converting_join_columns)
            {
                ...
            }
            // 添加Join
            if (expressions.hasJoin())
            {
                ...
            }
            // 添加where
            if (!query_info.projection && expressions.hasWhere())
                executeWhere(query_plan, expressions.before_where, expressions.remove_where_filter);
            // 添加aggregation
            if (expressions.need_aggregate)
            {
                executeAggregation(
                    query_plan, expressions.before_aggregation, aggregate_overflow_row, aggregate_final, query_info.input_order_info);
                /// We need to reset input order info, so that executeOrder can't use it
                query_info.input_order_info.reset();
                if (query_info.projection)
                    query_info.projection->input_order_info.reset();
            }
            // 准备执行:
            // 1. before windows函数
            // 2. windows函数
            // 3. after windows函数
            // 4. 准备DISTINCT
            if (expressions.need_aggregate)
            {
                // 存在聚合函数,在windows函数/ORDER BY之前不执行
            }
            else
            {
                // 不存在聚合函数
                // 存在windows函数,应该在初始节点运行
                // 并且,ORDER BY和DISTINCT依赖于windows函数,这里也不能运行
                if (query_analyzer->hasWindow())
                {
                    executeExpression(query_plan, expressions.before_window, "Before window functions");
                }
                else
                {
                    // 没有windows函数,执行before ORDER BY、准备DISTINCT
                    assert(!expressions.before_window);
                    executeExpression(query_plan, expressions.before_order_by, "Before ORDER BY");
                    executeDistinct(query_plan, true, expressions.selected_columns, true);
                }
            }
            // 如果查询没有GROUP、HAVING,有ORDER或LIMIT,会在远程排序、LIMIT
            preliminary_sort();
        }
        // 在分布式执行Query时只在初始节点执行或optimize_distributed_group_by_sharding_key开启时
        if (expressions.second_stage || from_aggregation_stage)
        {
             if (from_aggregation_stage)
            {
                // 远程节点聚合过,这里啥也不干
            }
            else if (expressions.need_aggregate)
            {
                // 从不同节点拉取数据合并
                if (!expressions.first_stage)
                    executeMergeAggregated(query_plan, aggregate_overflow_row, aggregate_final);
                
                if (!aggregate_final)
                {
                    // 执行group by with totals/rollup/cube
                    ...
                }
                // 添加Having
                else if (expressions.hasHaving())
                    executeHaving(query_plan, expressions.before_having, expressions.remove_having_filter);
            }
            // 报个错
            else if (query.group_by_with_totals || query.group_by_with_rollup || query.group_by_with_cube)
                throw Exception("WITH TOTALS, ROLLUP or CUBE are not supported without aggregation", ErrorCodes::NOT_IMPLEMENTED);
            // 准备执行:
            // 1. before windows函数
            // 2. windows函数
            // 3. after windows函数
            // 4. 准备DISTINCT
            if (from_aggregation_stage)
            {
                if (query_analyzer->hasWindow())
                    throw Exception(
                        "Window functions does not support processing from WithMergeableStateAfterAggregation",
                        ErrorCodes::NOT_IMPLEMENTED);
            }
            else if (expressions.need_aggregate)
            {
                executeExpression(query_plan, expressions.before_window,
                    "Before window functions");
                executeWindow(query_plan);
                executeExpression(query_plan, expressions.before_order_by, "Before ORDER BY");
                executeDistinct(query_plan, true, expressions.selected_columns, true);
            }
            else
            {
                if (query_analyzer->hasWindow())
                {
                    executeWindow(query_plan);
                    executeExpression(query_plan, expressions.before_order_by, "Before ORDER BY");
                    executeDistinct(query_plan, true, expressions.selected_columns, true);
                }
                else
                {
                    // Neither aggregation nor windows, all expressions before
                    // ORDER BY executed on shards.
                }
            }
            // 添加order by
            if (expressions.has_order_by)
            {
                // 在分布式查询中,没有聚合函数却有order by,将会在远端节点order by
                ...
            }
            // 多source order by优化
            ...
            
            // 多条流时再次执行distinct
            if (!from_aggregation_stage && query.distinct)
                executeDistinct(query_plan, false, expressions.selected_columns, false);

            // 处理limit
            ...
            // 处理projection
            ...
            // 处理offset
            ...
        }
        
        // 需要子查询结果构建set
        if (!subqueries_for_sets.empty())
            executeSubqueriesInSetsAndJoins(query_plan, subqueries_for_sets);
    }
}

其中InterpreterSelectQuery::executeFetchColumns()函数是读取所需列的阶段。从代码中可以看到它也做了很多的优化:

  • count()优化
  • 只有LIMIT情况的优化
  • quota限制

可以看到:

  1. limit大部分情况下是计算完成后再执行,而quota是在读取数据时执行的
  2. 加速的关键是减少读入的数据量,也就是说善用索引
  3. count()count(1)count(*),ClickHouse都有优化,但不要count(any_field)

索引设计

索引是ClickHouse快速查询最重要的一环,分为主键索引(sparse indexes)和跳表索引(data skipping indexes)。在执行查询时,索引命中顺序如下图所示:

graph TD
    A[Partition Key MinMax Index] --> B[Partition]
    B --> C[Primary Key Sparse Index]
    C --> D[Data Skipping Indexes]

详见代码:

// src/Processors/QueryPlan/ReadFromMergeTree.cpp

MergeTreeDataSelectAnalysisResultPtr ReadFromMergeTree::selectRangesToRead()
{
    ...
    try
    {
        // 使用partition by选取需要parts
        MergeTreeDataSelectExecutor::filterPartsByPartition(...);
        // 处理抽样
        ...
        // 使用主键索引和跳表索引
        result.parts_with_ranges = MergeTreeDataSelectExecutor::filterPartsByPrimaryKeyAndSkipIndexes(...);
    }
    catch(...)
    {
        ...
    }
    ...
}

值得注意的是,主键的sparse index使用二分查找直接缩小范围到所需要的parts,而跳表索引就需要在选出来的parts里,每n个(用户自定义)granules就需要比较n次。

最佳实践

partition by需要一个可以转为时间的列,比如Datatime、Date或者时间戳,而如果primary key中也有时间字段,可以使用同一个字段避免查询时需要同时指定两个时间字段。比如:指定为数据处理时间。

Partition

首先要辨析part和partition的区别,ClickHouse应用层面定义了partition,用户指定partition by关键词设置不同的partition,但是partition只是逻辑分区。真正存储到磁盘时按part来存储,每一个part一个文件夹,里面存储不同字段的.mrk.bin文件,以及一个minmax_{PARTITION_KEY_COLUMN}.idx文件,不同part的minmax作为一个索引存储于内存。

当查询的WHERE带有partition key时,首先会比较每一个part的minmax索引过滤不相关parts。之后再根据PARTITION BY定义的规则过滤不相关partition。

可是partition不是越小越好

partitioning并不会加速查询(有主键存在),过小的partition反而会导致大量的parts无法合并(MergeTree引擎家族会在后台不断合并parts),因为属于不同partition的parts无法合并。^mergetree_data_storage

最佳实践^partition_by_best_practice:

  • 一个(Replicated)MergeTree的partition大概1~300GB
    • Summing/ReplacingMergeTree的partition大概400MB~40GB
  • 查询时涉及尽量少partition
  • 插入时最好只有1~2个分区
  • 一张表维持100个分区以内

Primary key index

主键是ClickHouse最重要的索引,没有之一。好的主键应该能有效排除大量无关的数据granules,减少磁盘读取的字节数。

先讲几个主键的背景知识:

  • 主键用于数据排序
    • ClickHouse讲数据按主键排序,再按index_granularity设置的大小(默认8192)将数据分为一个个granules^data_into_granules
    • 每个granules的第一行作为主键索引中的一个元素^first_element_of_granules_in_primary_index
  • 查询时在主键上使用二分查找跳过无关granules^skip_unrelated_data_with_primary_key
  • 主键只能通过前缀命中索引^prefix_primary_key
  • 每一个part内的.bin文件存储了n个granules,用.mrk文件记录每一个granules在.bin文件的地址偏移^mrk_file
  • ClickHouse会在后台不断合并同一个partition的不同parts,直到大小/分布达到“预期”

主键的选择应该尽可能考虑周全,因为主键是无法修改的,只能建新表后数据迁移。

最佳实践^order_by_best_practice(针对(Replicated)MergeTree引擎):

  1. 选择永远会用于过滤条件的列
  2. 越重要的、基数越低的放左边
  3. 主键中不要出现两个高基数字段,一般最后一列可以为总体增长的时间字段
  4. 将行的特征字段加入,将相似的行放一起,提高压缩率
  5. 若主键包含主从关系,主放左边,从放右边

Data skipping indexes

最后一步是跳表索引,这个没有太多可以讲的地方,和其他数据库相同,跳表索引用于尽量减少读取的行数。具体参看官方文档

配置优化

配置优化分为两部分,全局配置优化和MergeTree表配置优化。

全局配置优化

参看Altinity选择性配置优化项。

这里写三个推荐的配置:

  1. 添加force_index_by_dateforce_primary_key避免全盘读取
  2. 调整内存配置,参考Altinity
  3. 系统表添加TTL和ttl_only_drop_parts表配置

表配置优化

除了全局配置,MergeTree引擎家族每张表也有自己的配置项。^settings_for_merge_tree

推荐设置如下配置:

  1. ttl_only_drop_parts=1。只有parts中所有数据都过期了才会DROP,可以有效减少TTL_MERGE发生的频率,降低磁盘负载。
  2. merge_with_ttl_timeout=86400。配合上一项配置,将TTL检查调整为1天一次(默认4小时一次)。
  3. use_minimalistic_part_header_in_zookeeper=1。可以有效降低Zookeeper负载,避免Zookeeeper成为性能瓶颈(插入)。

字段优化

除了索引、分区和配置外,还有表字段可以优化。接下来将讲述Schema类型、CODEC和缓存三个方面。

注意,尽量避免使用Null,在ClickHouse中Null会用一个单独Null masks文件存储哪些行为Null^nullable_storage_in_clickhouse,因此读取某个普通字段只需要.bin.mrk两个文件,而读取Nullable字段时需要.bin.mrk和masks文件。社区查询验证,最高会有2倍性能损失。^nullable_slow_test

Schema类型

使用ClickHouse存储时,一般用户都会创建大宽表,包含大量数值、字符串类型的字段。这里提及两种Schema类型^best_schema_for_metrics,没有哪个更优越,由读者执行评估业务适合哪一种。

平铺字段

这是我们主表正在使用的类型,将可能用到的字段预留平铺,除了一系列基础字段外,增加大量metric1, metric2...metricNtag1, tag2...tagN等等字段。

优点:

  • 简单
  • 只读取所需要的列,非常高效
  • 每个指标、标记都可以有特殊类型
  • 适合密集记录(所有预留字段几乎全用上)

缺点:

  • 添加字段需要改变schema
  • 预留字段不能过多,最多100~200个
  • 如果使用很稀疏,会创建大量sparse file字段
  • 需要标识“数据缺失”的情况(Null或者默认值)
  • 读取的列越多,需要读取文件越多,IO次数越多
arrays/nested/map字段

这是我们ctree功能正在使用的类型。将业务字段塞入嵌套数据类型中,比如array、nested struct和map。后文以array举例:metric_arraytag_array

优点:

  • 动态扩展
  • ClickHouse有大量高效的相关处理函数,甚至可以针对Array、Map设置索引
  • 适合稀疏记录(每行存储少量值,尽管总基数很高)

缺点:

  • 只需要其中一个metric/tag时,需要将整个array全部读入内存
  • 不通用,与其他系统交互时比较麻烦。比如spark使用jdbc时,嵌套类型无法支持比如array(array(string))
  • 不通意义的值存储在相同字段,压缩率变低
  • 需要不同类型的预留字段时需要创建不同类型
总结

关于Schema设计这里,读者可以考虑28原则,理论上80%查询只会用到20%的业务字段,因此可以将使用频率高的业务字段平铺,将使用频率低的字段放入嵌套结构中。

CODEC

CODEC分为压缩算法CODEC、存储格式CODEC和加密CODEC,一般可以组合一起使用。在ClickHouse中,未显示指定CODEC的字段都会被分配一个DEFAULT默认CODEC LZ4(除非用户修改clickhouse配置compression部分^clickhouse_server_compression)。

压缩算法CODEC的选择是一个平衡板问题,更高的压缩度可以有更少的IO但是更高的CPU,更低的压缩度有更多的IO但是更少的CPU。这需要读者根据部署机器配置自行选择合适的压缩算法和压缩等级。

这里提供两个判断策略:

  • 存在索引的字段可以设置更高的压缩等级
  • 用于where条件的字段应该设置更低压缩等级

存储格式CODEC主要是DeltaDoubleDeltaGorillaFPCT64几种。

  • Delta存储行之间的变化值,适合变化较小且比较固定的列,比如时间戳。需要配合ZSTD使用
  • DoubleDelta存储DeltaDelta。适合变化很慢的序列
  • Gorilla适合不怎么变动的integer、float类型^gorilla
  • FPC适合于float类型,由于我们未使用float字段这里略过
  • T64存储编码范围内最大、最小值,以转为64bit存储,适合较小的integer类型

扩展阅读:

缓存

mark_cache_size可以调整.mrk文件的缓存大小,默认为5GB。适当调大可以减少查询时IO次数,有效降低磁盘压力。^mrk_cache_size

  • 字段越多,.mrk文件越大
  • index_granularity.mrk文件大小成负相关

可以通过如下SQL查询当前所有表的parts信息:

SELECT
    database,
    table,
    count() AS parts,
    uniqExact(partition_id) AS partition_cnt,
    sum(rows),
    formatReadableSize(sum(data_compressed_bytes) AS comp_bytes) AS comp,
    formatReadableSize(sum(data_uncompressed_bytes) AS uncomp_bytes) AS uncomp,
    uncomp_bytes / comp_bytes AS ratio,
    formatReadableSize(sum(marks_bytes) AS mark_sum) AS marks,
    mark_sum / uncomp_bytes AS mark_ratio
FROM clusterAllReplicas(default_cluster, system.parts)
WHERE active
GROUP BY
    database,
    table
ORDER BY comp_bytes DESC

除此之外,ClickHouse还可以调整uncompressed_cache缓存一定量原始数据于内存中。^cache_in_clickhouse读者可以自行根据ClickHouse内存情况调整。

业务优化

到了最难的部分,由于接下来的部分和不同业务息息相关,为了讲解我们业务上的优化,我先介绍下我们业务情况:

QAPM主打应用性能监控,主要分为指标、个例两张表。个例表包含更多基础字段,一般用户展示;指标表主要用于聚合计算。

首先确定主键,毋庸置疑的前两个一定是

  • app_id。放首位,因为可能存在同一个产品不同功能联动的情况,比如会话分析
  • category。放第二位,因为功能之间独立,大量查询只涉及单功能

指标没有特征键值,因此只添加处理时间作为第三个主键。

对于指标表,设置的主键为:app_id, category, entrance_time

个例存在特征feature,由于:

  1. 大量查询都包含feature_md5
  2. feature是行的特征,相同的特征表明两行相似,

将特征的md5增加到主键中,用于加速查询、提高压缩率。但是这里有两个方向:

  • 若feature_md5是高基数、大量长尾的字段
    • 设置的主键为:app_id, category, intDiv(entrance_time, 3600000), feature_md5
  • 若feature_md5基数可以降低到千、万量级
    • 设置的主键为:app_id, category, feature_md5, entrance_time

分区键设置为PARTITION BY intDiv(entrance_time, 2592000000)`

鉴于SAMPLE BY需要将xxHash字段放在主键中,主键都包含高基数字段,就不设置抽样键,而是在需要的时候软抽样^soft_sample:

SELECT count() FROM table WHERE ... AND cityHash64(some_high_card_key) % 10 = 0; -- Deterministic
SELECT count() FROM table WHERE ... AND rand() % 10 = 0; -- Non-deterministic

插入优化

数据插入看起来和查询性能没什么联系,但是有间接影响。不合理的插入会导致更多的写盘、更多的数据merge甚至有可能插入失败,影响读盘性能。

聚合写入

ClickHouse作为OLAP并不适合小批量、大并发写入,相反而适合大批量、小并发写入,官方建议插入数据每批次至少1000行,或者每秒钟最多1次插入。^clickhouse_insert

这一小节我想强调原子(Atomic Insert)写入的概念:一次插入创建一个数据part。

前文提及,ClickHouse一个part是一个文件夹,后台有个merge线程池不断merge不同的part。原子插入可以减少merge次数,让ClickHouse负载更低,性能更好。

原子写入的充分条件^clickhouse_atomic_insert:

  • 数据直接插入MergeTree表(不能有Buffer表)
  • 数据只插入一个partition(注意前文提到的partition和part的区别)
  • 对于INSERT FORMAT
    • 插入行数少于max_insert_block_size(默认1048545)
    • 关闭并行格式化input_format_parallel_parsing=0
  • 对于INSERT SELECT
    • 插入行数少于max_block_size
  • 小block被合并到合适的block大小min_insert_block_size_rows and min_insert_block_size_bytes
  • MergeTree表不包含物化视图

这里贴一下我们生产的配置(users.xml)。

经过统计,个例表每行大约2KB,指标表每行大约100B(未压缩)。

设置min_insert_block_size_rows为10000000,指标会先满足这个条件,大概一个block原始大小1GB。设置min_insert_block_size_bytes为4096000000,个例会先满足这个条件,大概一个block原始大小1G,约1024000行。

<yandex>
	<profile>
  	<root>
      <max_insert_block_size>16777216</max_insert_block_size>
      <input_format_parallel_parsing>0</input_format_parallel_parsing>
      <min_insert_block_size_rows>10000000</min_insert_block_size_rows>
      <min_insert_block_size_bytes>1024000000</min_insert_block_size_bytes>
    </root>
  </profile>
</yandex>

注意,min_insert_block_size_rowsmin_insert_block_size_bytes是“或”的关系:

// src/Interpreters/SquashingTransform.cpp

bool SquashingTransform::isEnoughSize(size_t rows, size_t bytes) const
{
    return (!min_block_size_rows && !min_block_size_bytes)
        || (min_block_size_rows && rows >= min_block_size_rows)
        || (min_block_size_bytes && bytes >= min_block_size_bytes);
}
读写分离

⚠️:本方案并没有经过生产验证,酌情考虑

ClickHouse有Shard和Replica可以配置,作用如下图所示:

20221113151840.girMJY.png
20221113151840.girMJY.png

所谓读写分离也就是将Shard分为两半,一半只用于查询,只要让分布式表查询都导入到Shard1即可(在users.xml中配置load_balancingfirst_or_random);一半用于写入,插入的程序手动控制插入Shard2的节点,由ClickHouse的ReplicatedMergeTree不同Shard数据依靠zookeeper自动同步的策略将数据同步到Shard1。^clickhouse_separate_read_and_write

这种策略有天然的缺陷:

  • 写的那半Shard持续有一定量(不会很高)的资源消耗用于写入
  • 读的那半Shard会有资源消耗用于同步写入(由于不用处理,会比直接写入的情况资源消耗更低),但是读请求会导致资源消耗突增
  • 并发增加时性能不如混合情况,因为读写分离相当于将读资源砍半

🤔:或许可以配置两边Shard资源不一致来解决问题,比如写入的Shard资源拉低,专用于处理数据插入;读的Shard资源更高,专门用于处理突增并发流量。

BufferEngine

Buffer并不推荐常规业务使用,只有在迫切需要查询实时性+插入无法大批量预聚合时使用:

  • 无法atomic insert
  • 即使使用BufferEngine,数据插入也至少1000行每次,或者每秒钟最多1次插入^buffer_insert
KafkaEngine+MV

该部分待补充,想看的同学可以在评论区踢踢😄

预聚合

预聚合有三种方法,ETL、物化视图和投影,他们的区别如下^clickhouse_etl_mv_pro:

ETL

MV

Projections

Realtime

no

yes

yes

How complex queries can be used to build the preaggregaton

any

complex

very simple

Impacts the insert speed

no

yes

yes

Are inconsistancies possible

Depends on ETL. If it process the errors properly - no.

yes (no transactions / atomicity)

no

Lifetime of aggregation

any

any

Same as the raw data

Requirements

need external tools/scripting

is a part of database schema

is a part of table schema

How complex to use in queries

Depends on aggregation, usually simple, quering a separate table

Depends on aggregation, sometimes quite complex, quering a separate table

Very simple, quering the main table

Can work correctly with ReplacingMergeTree as a source

Yes

No

No

Can work correctly with CollapsingMergeTree as a source

Yes

For simple aggregations

For simple aggregations

Can be chained

Yes (Usually with DAGs / special scripts)

Yes (but may be not straightforward, and often is a bad idea)

No

Resources needed to calculate the increment

May be signigicant

Usually tiny

Usually tiny

在我们业务中,个例是不应该预聚合的,因为数据需要被拉取展示而不用计算。指标需要聚合,数据量较大,每次实时计算对ClickHouse负载太大。

其实还有一种聚合方式,过期数据聚合。可以参考,同样限制要求group by的键值为主键前缀。

在我们业务使用时,什么时候用哪一个呢?

  1. 需要针对某个功能加速时,可以考虑物化视图/投影
  2. 全表预聚合加速查询,需要使用ETL

资源控制

最后,为了避免集群被某个查询、插入弄垮,需要合理安排内存使用,需要给访问账户分权限,在我们业务分为:

  • default:最高级账号,不使用
  • root:数据插入,配置聚合写入部分的几个配置项
  • monitor:内部开发使用,权限较高
  • viewer:web使用,添加大量限制

viewer账户配置如下所示:

<yandex>
    <profiles>
        <query>
            <max_memory_usage>10000000000</max_memory_usage>
            <max_memory_usage_for_all_queries>100000000000</max_memory_usage_for_all_queries>
            <max_rows_to_read>1000000000</max_rows_to_read>
            <max_bytes_to_read>100000000000</max_bytes_to_read>
            <max_rows_to_group_by>1000000</max_rows_to_group_by>
            <group_by_overflow_mode>any</group_by_overflow_mode>
            <max_rows_to_sort>1000000</max_rows_to_sort>
            <max_bytes_to_sort>1000000000</max_bytes_to_sort>
            <max_result_rows>100000</max_result_rows>
            <max_result_bytes>100000000</max_result_bytes>
            <result_overflow_mode>break</result_overflow_mode>
            <max_execution_time>60</max_execution_time>
            <min_execution_speed>1000000</min_execution_speed>
            <timeout_before_checking_execution_speed>15</timeout_before_checking_execution_speed>
            <max_columns_to_read>25</max_columns_to_read>
            <max_temporary_columns>100</max_temporary_columns>
            <max_temporary_non_const_columns>50</max_temporary_non_const_columns>
            <max_subquery_depth>2</max_subquery_depth>
            <max_pipeline_depth>25</max_pipeline_depth>
            <max_ast_depth>50</max_ast_depth>
            <max_ast_elements>100</max_ast_elements>
            <readonly>1</readonly>
        </query>
    </profiles>
</yandex>

同时建议设置quota,减少大量读盘计算、LIMIT少量数据返回的情况发生。


我们是CSIG性能工程二组QAPM团队,QAPM时一款应用性能监控工具,覆盖android、ios、小程序、mac和win多端,已有腾讯会议、优衣库等大用户接入,值得信赖,欢迎同事试用我们QAPM产品~跳转链接

在ClickHouse优化过程遇到无数的问题,卡在ClickHouse自身监控无法覆盖的角落时,全靠性能工程三组员工的Drop(雨滴)工具的鼎力相助,高效直观监控CVM各项指标,降低优化门槛,助力业务增效~跳转链接


参考

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

undefined

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • ClickHouse查询执行过程
    • 词法解析和语法解析
      • 优化器
        • 构造执行计划
        • 索引设计
          • Partition
            • Primary key index
              • Data skipping indexes
              • 配置优化
                • 全局配置优化
                  • 表配置优化
                  • 字段优化
                    • Schema类型
                      • 平铺字段
                      • arrays/nested/map字段
                      • 总结
                    • CODEC
                      • 缓存
                      • 业务优化
                        • 插入优化
                          • 聚合写入
                          • 读写分离
                          • BufferEngine
                          • KafkaEngine+MV
                        • 预聚合
                          • 资源控制
                          • 参考
                          相关产品与服务
                          文件存储
                          文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
                          领券
                          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档