ClickHouse学习笔记(二):执行计划、建表优化、语法优化规则、查询优化、数据一致性

一、Explain查看执行计划

在ClickHouse 20.6版本之前要查看SQL语句的执行计划需要设置日志级别为trace才能可以看到,并且只能真正执行sql,在执行日志里面查看。在20.6版本引入了原生的执行计划的语法。在20.6.3版本成为正式版本的功能

EXPLAIN [AST | SYNTAX | PLAN | PIPELINE] [setting = value, ...] SELECT ... [FORMAT ...]

1)PLAN:用于查看执行计划,默认值

  • header打印计划中各个步骤的head说明,默认关闭,默认值0
  • description打印计划中各个步骤的描述,默认开启,默认值1
  • actions打印计划中各个步骤的详细信息,默认关闭,默认值0

2)AST:用于查看语法树

3)SYNTAX:用于优化语法

4)PIPELINE:用于查看PIPELINE计划

  • header打印计划中各个步骤的head说明,默认关闭
  • graph用DOT图形语言描述管道图,默认关闭,需要查看相关的图形需要配合graphviz查看
  • actions如果开启了graph,紧凑打印打,默认开启

2、案例
1)查看PLAN

  1. 简单查询

explain plan select arrayJoin([1,2,3,null,null]);

复杂SQL的执行计划 

explain select database,table,count(1) cnt from system.parts where database in ('datasets','system') group by database,table order by database,cnt desc limit 2 by database; 

打开全部的参数的执行计划

explain header=1, actions=1,description=1 SELECT number from system.numbers limit 10; 

2)AST语法树

explain ast select number from system.numbers limit 10; 

 3)SYNTAX语法优化

// 先做一次查询
select number = 1 ? 'hello' : (number = 2 ? 'world' : 'atguigu') from numbers(10);
// 查看语法优化
explain syntax select number = 1 ? 'hello' : (number = 2 ? 'world' : 'atguigu') from numbers(10);
// 开启三元运算符优化
set optimize_if_chain_to_multiif = 1;
// 再次查看语法优化
explain syntax select number = 1 ? 'hello' : (number = 2 ? 'world' : 'atguigu') from numbers(10);
// 返回优化后的语句
select multiIf(number = 1, 'hello', number = 2, 'world', 'atguigu') from numbers(10);

4)查看PIPELINE 

explain pipeline select sum(number) from numbers_mt(100000) group by number % 20; 
// 打开其他参数
explain pipeline header=1,graph=1 select sum(number) from numbers_mt(10000) group by number%20;

二、建表优化

1、数据类型

1)、时间字段的类型

建表时能用数值型或日期时间型表示的字段就不要用字符串

虽然ClickHouse底层将DateTime存储为时间戳Long类型,但不建议存储Long类型,因为DateTime不需要经过函数转换处理,执行效率高、可读性好

create table t_type2(
id UInt32,
sku_id String,
total_amount Decimal(16,2) ,
create_time Int32 
) engine =ReplacingMergeTree(create_time)
partition by toYYYYMMDD(toDate(create_time)) –-需要转换一次,否则报错
primary key (id)
order by (id, sku_id);

 2)、空值存储类型
官方已经指出Nullable类型几乎总是会拖累性能,因为存储Nullable列时需要创建一个额外的文件来存储NULL的标记,并且Nullable列无法被索引。因此除非极特殊情况,应直接使用字段默认值表示空,或者自行指定一个在业务中无意义的值(例如用-1表示没有商品ID)
 

CREATE TABLE t_null(x Int8, y Nullable(Int8)) ENGINE TinyLog;
INSERT INTO t_null VALUES (1, NULL), (2, 3);
SELECT x + y FROM t_null;

1 + NULL = NULL:

ClickHouse学习笔记(二):执行计划、建表优化、语法优化规则、查询优化、数据一致性_第1张图片

 单独的文件来存储NULL值:

[root@aliyun ~]# cd /var/lib/clickhouse/data/default/t_null/
[root@aliyun t_null]# ll
total 16
-rw-r----- 1 clickhouse clickhouse 91 Sep 21 08:28 sizes.json
-rw-r----- 1 clickhouse clickhouse 28 Sep 21 08:28 x.bin
-rw-r----- 1 clickhouse clickhouse 28 Sep 21 08:28 y.bin
-rw-r----- 1 clickhouse clickhouse 28 Sep 21 08:28 y.null.bin

2、分区和索引
分区粒度根据业务特点决定,不宜过粗或过细。一般选择按天分区,也可以指定为Tuple(),以单表一亿数据为例,分区大小控制在10-30个为最佳

必须指定索引列,ClickHouse中的索引列即排序列,通过order by指定,一般在查询条件中经常被用来充当筛选条件的属性被纳入进来;可以是单一维度,也可以是组合维度的索引;通常需要满足高级列在前、查询频率大的在前原则;还有基数特别大的不适合做索引列,如用户表的userid字段;通常筛选后的数据满足在百万以内为最佳

3、表参数
Index_granularity是用来控制索引粒度的,默认是8192,如非必须不建议调整

如果表中不是必须保留全量历史数据,建议指定TTL(生存时间值),可以免去手动过期历史数据的麻烦,TTL也可以通过alter table语句随时修改

4、写入和删除优化
1)尽量不要执行单条或小批量删除和插入操作,这样会产生小分区文件,给后台Merge任务带来巨大压力

2)不要一次写入太多分区,或数据写入太快,数据写入太快会导致Merge速度跟不上而报错,一般建议每秒钟发起2-3次写入操作,每次操作写入2w~5w条数据(依服务器性能而定)

写入过快报错,报错信息:
 

1. Code: 252, e.displayText() = DB::Exception: Too many parts(304). 
Merges are processing significantly slower than inserts
2. Code: 241, e.displayText() = DB::Exception: Memory limit (for query) 
exceeded:would use 9.37 GiB (attempt to allocate chunk of 301989888 
bytes), maximum: 9.31 GiB

处理方式:

Too many parts处理:使用WAL预写日志,提高写入性能

in_memory_parts_enable_wal默认为true

在服务器内存充裕的情况下增加内存配额,一般通过max_memory_usage来实现在服务器内存不充裕的情况下,建议将超出部分内容分配到系统硬盘上,但会降低执行速度,一般通过max_bytes_before_external_group_by、max_bytes_before_external_sort参数来实现

5、常见配置
配置项主要在config.xml或users.xml中,基本上都在users.xml里

config.xml的配置项:

https://clickhouse.tech/docs/en/operations/server-configuration-parameters/settings/

users.xml的配置项:

https://clickhouse.tech/docs/en/operations/settings/settings/


1)、CPU资源

配置    描述
background_pool_size  后台线程池的大小,merge线程就是在该线程池中执行,该线程池不仅仅是给merge线程用的,默认值16,允许的前提下建议改成cpu个数的2倍(线程数)
background_schedule_pool_size  执行后台任务(复制表、Kafka流、DNS缓存更新)的线程数。默认128,建议改成cpu个数的2倍(线程数)
background_distributed_schedule_pool_size  设置为分布式发送执行后台任务的线程数,默认16,建议改成cpu个数的2倍(线程数)
max_concurrent_queries 最大并发处理的请求数(包含select、insert等),默认值100,推荐150(不够再加)~300
max_threads 设置单个查询所能使用的最大cpu个数,默认是cpu核数

2)、内存资源

配置 描述
max_memory_usage  单次Query占用内存最大值,该值可以设置的比较大,这样可以提升集群查询的上限。保留一点给OS,比如128G内存的机器,设置为100GB
max_bytes_before_external_group_by 一般按照max_memory_usage的一半设置内存,当group使用内存超过阈值后会刷新到磁盘进行。因为ClickHouse聚合分两个阶段:查询并及建立中间数据、合并中间数据,结合上一项,建议50GB
max_bytes_before_external_sort 当order by已使用max_bytes_before_external_sort内存就进行溢写磁盘(基于磁盘排序),如果不设置该值,那么当内存不够时直接抛错,设置了该值order by可以正常完成,但是速度相对存内存来说肯定要慢点(实测慢的非常多,无法接受)
max_table_size_to_drop  应用于需要删除表或分区的情况,默认是50GB,意思是如果删除50GB以上的分区表会失败。建议修改为0,这样不管多大的分区表都可以删除

3)、存储

ClickHouse不支持设置多数据目录,为了提升数据IO性能,可以挂载虚拟券组,一个券组绑定多块物理磁盘提升读写性能,多数据查询场景SSD会比普通机械硬盘快2-3倍

三、ClickHouse语法优化规则

ClickHouse的SQL优化规则是基于RBO(Rule Based Optimization),下面是一些优化规则

1、准备测试用表

下载hits_v1.tar和visits_v1.tar

    [root@aliyun /]# curl -O https://datasets.clickhouse.tech/hits/partitions/hits_v1.tar
[root@aliyun /]# curl -O https://datasets.clickhouse.tech/visits/partitions/visits_v1.tar

解压到ClickHouse数据路径 

[root@aliyun /]# sudo tar -xvf hits_v1.tar -C /var/lib/clickhouse

[root@aliyun /]# sudo tar -xvf visits_v1.tar -C /var/lib/clickhouse 

授权 

[root@aliyun /]# sudo chown -R 777 /var/lib/clickhouse/data/datasets
[root@aliyun /]# sudo chown -R 777 /var/lib/clickhouse/metadata/datasets 

重启clickhouse-server

[root@aliyun /]# sudo clickhouse restart

hits_v1表有130多个字段,880多万条数据

visits_v1表有180多个字段,160多万条数据

2、COUNT优化
在调用count函数时,如果使用的是count()或者count(*),且没有where条件,则会直接使用system.tables的total_rows

explain select count() from datasets.hits_v1;

ClickHouse学习笔记(二):执行计划、建表优化、语法优化规则、查询优化、数据一致性_第2张图片

 explain select count(CounterID) from datasets.hits_v1;

 如果count具体的列字段,则不会使用此项优化

ClickHouse学习笔记(二):执行计划、建表优化、语法优化规则、查询优化、数据一致性_第3张图片

 3、消除子查询重复字段

下面语句子查询中有两个重复的id字段,会被去重:

explain syntax select 
a.UserID,
b.VisitID,
a.URL,
b.UserID
from
datasets.hits_v1 as a 
left join ( 
select 
UserID, 
UserID as HaHa, 
VisitID 
from datasets.visits_v1) as b 
using (UserID)
limit 3;

优化后的语句:

SELECT 
UserID,
VisitID,
URL,
b.UserID
FROM datasets.hits_v1 AS a
ALL LEFT JOIN 
(
SELECT 
UserID,
VisitID
FROM datasets.visits_v1
) AS b USING (UserID)
LIMIT 3;

4、谓词下推

1)当group by有having子句,但是没有with cube、with rollup或者with totals修饰的时候,having过滤会下推到where提前过滤。例如下面的查询,having name变成了where name,在group by之前过滤:

explain syntax select UserID from datasets.hits_v1 group by UserID having UserID = '8585742290196126178';

优化后的语句:

SELECT UserID
FROM datasets.hits_v1
WHERE UserID = '8585742290196126178'
GROUP BY UserID;

2)子查询也支持谓词下推:

explain syntax
select *
from 
(
select UserID
from datasets.visits_v1
)
where UserID = '8585742290196126178';

3)再来一个复杂的例子:

explain syntax
select * from (
select * from (
select UserID from datasets.visits_v1) 
union all 
select * from (
select UserID from datasets.visits_v1)
)
where UserID = '8585742290196126178';

优化后的语句:

SELECT UserID
FROM 
(
SELECT UserID
FROM 
(
SELECT UserID
FROM datasets.visits_v1
WHERE UserID = '8585742290196126178' )
WHERE UserID = '8585742290196126178'
UNION ALL
SELECT UserID
FROM 
(
SELECT UserID
FROM datasets.visits_v1
WHERE UserID = '8585742290196126178' )
WHERE UserID = '8585742290196126178' )
WHERE UserID = '8585742290196126178';

5、聚合计算外推

聚合函数内的计算会外推

explain syntax
select sum(UserID * 2)
from datasets.visits_v1;

优化后的语句:

SELECT sum(UserID) * 2
FROM datasets.visits_v1;

6、聚合函数消除

如果对聚合键,也就是group by key使用min、max、any聚合函数,则将函数消除

explain syntax
select
sum(UserID * 2),
max(VisitID),
max(UserID)
from datasets.visits_v1
group by UserID;

优化后的语句:

SELECT 
sum(UserID) * 2,
max(VisitID),
UserID
FROM datasets.visits_v1
GROUP BY UserID;

7、删除重复的order by key

重复的聚合键id字段会被去重

explain syntax
select *
from datasets.visits_v1
order by
UserID asc,
UserID asc,
VisitID asc,
VisitID asc;

优化后的语句:

SELECT
...
FROM datasets.visits_v1
ORDER BY 
UserID ASC,
VisitID ASC;

8、删除重复的limit by key

在ClickHouse里,增加了一个limit by部分,区别于MySQL的limit在最终结果集的行数限制,这个limit by是对by字段,每个值保留对应的行数

explain syntax
select *
from datasets.visits_v1
limit 3 by
VisitID,
VisitID
limit 10;

 优化后的语句:

SELECT
...
FROM datasets.visits_v1
LIMIT 3 BY VisitID
LIMIT 10;

9、删除重复的USING Key

重复的关联键id字段会被去重

explain syntax
select
a.UserID,
a.UserID,
b.VisitID,
a.URL,
b.UserID
from datasets.hits_v1 as a
left join datasets.visits_v1 as b using (UserID, UserID);

优化后的语句:

SELECT 
UserID,
UserID,
VisitID,
URL,
b.UserID
FROM datasets.hits_v1 AS a
ALL LEFT JOIN datasets.visits_v1 AS b USING (UserID);

10、标量替换

explain syntax
with
(
select sum(bytes)
from system.parts
where active
) as total_disk_usage
select
(sum(bytes) / total_disk_usage) * 100 AS table_disk_usage,
table
from system.parts
group by table
order by table_disk_usage desc
limit 10;

优化后的语句:

WITH CAST(0, \'UInt64\') AS total_disk_usage
SELECT 
(sum(bytes) / total_disk_usage) * 100 AS table_disk_usage,
table
FROM system.parts
GROUP BY table
ORDER BY table_disk_usage DESC
LIMIT 10

11、三元运算优化

如果开启了optimize_if_chain_to_multiif参数,三元运算符会被替换成multiIf函数

explain syntax
select number = 1 ? 'hello' : (number = 2 ? 'world' : 'atguigu') 
from numbers(10) 
settings optimize_if_chain_to_multiif = 1;

优化后的语句: 

SELECT multiIf(number = 1, \'hello\', number = 2, \'world\', \'atguigu\')
FROM numbers(10)
SETTINGS optimize_if_chain_to_multiif = 1;

四、查询优化


1、单表查询
1)、prewhere替代where
prewhere和where语句的作用相同,都是用来过滤数据。prewhere只支持MergeTree族系列引擎的表,首先会读取指定的列数据,来判断数据过滤,等待数据过滤之后再读取select声明的列字段来补全其余属性

当查询列明显多于筛选列时使用prewhere可十倍提升查询性能,prewhere会自动优化执行过滤阶段的数据读取方式,降低IO操作

在某些场合下,prewhere语句比where语句处理的数据量更少性能更高

#关闭where自动转prewhere(默认情况下,where条件会自动优化成prewhere)
set optimize_move_to_prewhere=0;
explain syntax
select WatchID, 
JavaEnable, 
Title, 
GoodEvent, 
EventTime, 
EventDate, 
CounterID, 
ClientIP, 
ClientIP6, 
RegionID, 
UserID, 
CounterClass, 
OS, 
UserAgent, 
URL, 
Referer, 
URLDomain, 
RefererDomain, 
Refresh, 
IsRobot, 
RefererCategories, 
URLCategories, 
URLRegions, 
RefererRegions, 
ResolutionWidth, 
ResolutionHeight, 
ResolutionDepth, 
FlashMajor, 
FlashMinor, 
FlashMinor2
from datasets.hits_v1 where UserID='3198390223272470366';

 优化后的语句:

SELECT WatchID, 
JavaEnable, 
Title, 
GoodEvent, 
EventTime, 
EventDate, 
CounterID, 
ClientIP, 
ClientIP6, 
RegionID, 
UserID, 
CounterClass, 
OS, 
UserAgent, 
URL, 
Referer, 
URLDomain, 
RefererDomain, 
Refresh, 
IsRobot, 
RefererCategories, 
URLCategories, 
URLRegions, 
RefererRegions, 
ResolutionWidth, 
ResolutionHeight, 
ResolutionDepth, 
FlashMajor, 
FlashMinor, 
FlashMinor2
FROM datasets.hits_v1 PREWHERE UserID='3198390223272470366';

某些场景即使开启优化,也不会自动转换成prewhere,需要手动指定prewhere:

  1. 使用常量表达式
  2. 使用默认值为alias类型的字段
  3. 包含了arrayJOIN、globalIn、globalNotIn或者indexHint的查询
  4. select查询的列字段和where的谓词相同
  5. 使用了主键字段

2)数据采样

select Title,count(*) as PageViews 
from datasets.hits_v1
sample 0.1
where CounterID =57
group by Title
order by PageViews desc limit 1000;

sample 0.1:代表采样10%的数据,也可以是具体的条数

3)列裁剪与分区裁剪
数据量太大时应避免使用select *操作,查询的性能会与查询的字段大小和数量成线性表换,字段越少,消耗的IO资源越少,性能就会越高

4)order by结合where、limit
千万以上数据集进行order by查询时需要搭配where条件和limit语句一起使用
 

select UserID,Age
from datasets.hits_v1 
where CounterID=57
order by Age desc limit 1000;

5)避免构建虚拟列

如非必须,不要在结果集上构建虚拟列,虚拟列非常消耗资源浪费性能,可以考虑在程序中进行处理,或者在表中构造实际字段进行额外存储

反例:

select Income,Age,Income/Age as IncRate from datasets.hits_v1;

正例:

select Income,Age from datasets.hits_v1;

拿到Income和Age后,考虑在程序中进行处理,或者在表中构造实际字段进行额外存储

6)uniqCombined替代distinct
性能可提升10倍以上,uniqCombined底层采用类似HyperLogLog算法实现,能接受2%左右的数据误差,可直接使用这种去重方式提升查询性能。count(distinct)会使用uniqExact精确去重

不建议在千万级不同数据上执行distinct去重查询,改为近似去重uniqCombined

反例:
 

select count(distinct UserID) from datasets.hits_v1;

拿到Income和Age后,考虑在程序中进行处理,或者在表中构造实际字段进行额外存储

6)、uniqCombined替代distinct
性能可提升10倍以上,uniqCombined底层采用类似HyperLogLog算法实现,能接受2%左右的数据误差,可直接使用这种去重方式提升查询性能。count(distinct)会使用uniqExact精确去重

不建议在千万级不同数据上执行distinct去重查询,改为近似去重uniqCombined

反例:
 

select count(distinct UserID) from datasets.hits_v1;

正例:

select uniqCombined(UserID) from datasets.hits_v1;

7)、其他注意事项
1)查询熔断

为了避免因个别慢查询引起的服务雪崩的问题,除了可以为单个查询设置超时以外,还可以配置周期熔断,在一个查询周期内,如果用户频繁进行慢查询操作超出规定阈值后将无法继续进行查询操作

2)关闭虚拟内存

物理内存和虚拟内存的数据交换,会导致查询变慢,资源允许的情况下关闭虚拟内存

3)配置join_use_nulls

为每一个账户添加join_use_nulls配置,左表中的一条记录在右表中不存在,右表的相应字段会返回该字段相应数据类型的默认值,而不是标准SQL中的Null值

4)批量写入时先排序

批量写入数据时,必须控制每个批次的数据中涉及到的分区的数量,在写入之前最好对需要导入的数据进行排序。无序的数据或者涉及的分区太多,会导致ClickHouse无法及时对新导入的数据进行合并,从而影响查询性能。

5)关注CPU

CPU一般在50%左右会出现查询波动,达到70%会出现大范围的查询超时,CPU是最关键的指标,要非常关注

2、多表关联

1)、准备表和数据

创建小表:

CREATE TABLE datasets.visits_v2 
ENGINE = CollapsingMergeTree(Sign)
PARTITION BY toYYYYMM(StartDate)
ORDER BY (CounterID, StartDate, intHash32(UserID), VisitID)
SAMPLE BY intHash32(UserID)
SETTINGS index_granularity = 8192
as select * from datasets.visits_v1 limit 10000;

创建join结果表,避免控制台疯狂打印数据:

CREATE TABLE datasets.hits_v2 
ENGINE = MergeTree()
PARTITION BY toYYYYMM(EventDate)
ORDER BY (CounterID, EventDate, intHash32(UserID))
SAMPLE BY intHash32(UserID)
SETTINGS index_granularity = 8192
as select * from datasets.hits_v1 where 1=0;

2)、用in代替jion

当多表联查时,查询的数据仅从其中一张表出时,可考虑用IN操作而不是JOIN

insert into datasets.hits_v2
select a.* from datasets.hits_v1 a where a. CounterID in 
(select CounterID from datasets.visits_v1);

反例:使用join

insert into table datasets.hits_v2
select a.* from datasets.hits_v1 a left join datasets.visits_v1 b 
on a. CounterID=b. CounterID;

3)、大小表join

多表join时要满足小表在右的原则,右表关联时被加载到内存中与左表进行比较,ClickHouse中无论是Left join、Right join还是Inner join永远都是拿着右表中的每一条记录到左表中查找该记录是否存在,所以右表必须是小表

小表在右

insert into table datasets.hits_v2
select a.* from datasets.hits_v1 a left join datasets.visits_v2 b on a. CounterID=b. 
CounterID;

反例:大表在右

insert into table datasets.hits_v2
select a.* from datasets.visits_v2 b left join datasets.hits_v1 a on a. CounterID=b. 
CounterID;

4)、注意谓词下推
ClickHouse在join查询时不会主动发起谓词下推的操作,需要每个子查询提前完成过滤操作,需要注意的是,是否执行谓词下推,对性能影响差别很大(新版本中已经不存在此问题,但是需要注意谓词的位置的不同依然有性能的差异)

1)having会自动优化为prewhere
 

Explain syntax
select a.* from datasets.hits_v1 a left join datasets.visits_v2 b on a. CounterID=b. CounterID
having a.EventDate = '2014-03-17';

优化后的语句:

SELECT a.* 
FROM datasets.hits_v1 AS a 
ALL LEFT JOIN datasets.visits_v2 AS b ON a.CounterID = b.CounterID 
PREWHERE a.EventDate = '2014-03-17';

2)尽量在join之前进行过滤

子查询里where:

insert into datasets.hits_v2
select a.* from (
select * from 
datasets.hits_v1 
where EventDate = '2014-03-17'
) a left join datasets.visits_v2 b on a. CounterID=b. CounterID;

join完再where:

insert into datasets.hits_v2
select a.* from datasets.hits_v1 a left join datasets.visits_v2 b on a. CounterID=b. 
CounterID
where a.EventDate = '2014-03-17';

你可能感兴趣的:(clickhouse,学习)