建表优化
1.数据类型
时间字段的类型
建表时能用数值型或日期时间型表示的字段就不要用字符串。全String类型在以Hive为钟显的数仓建设中常见,但Clickhouse中不受此影响。
虽然ClickHouse底层将DateTime存储为时间戳Long类型(相当于Int32),但不建议存储Long类型,因为DateTime不需要经过函数转换处理,执行效率高、可读性好。
以Int32为例:
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);
所以最好还是使用Datetime。
空值存储类型
官方已经指出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
单独的文件来存储NULL值,如下面的y.null.bin:
cd /var/lib/clickhouse/data/default/t_null/
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.分区和索引
https://blog.csdn.net/qq_44769557/article/details/124909764#21CKsql_47
分区粒度根据业务特点决定,不宜过粗或过细。
一般选择按天分区,也可以指定为Tuple(),以单表一亿数据为例,分区大小控制在10-30个为最佳。
分区,原则是尽量把经常一起用到的数据放到相同区(也可以根据where条件来分区),如果一个区太大再放到多个区。
大量分区是一种常见的误解。这将导致严重的负面性能影响,包括服务器启动速度慢、插入查询速度慢和选择查询速度慢。表的建议分区总数在1000以下。请注意,分区并不是为了加速SELECT查询(按键排序足以使范围查询变得快速)。分区用于数据操作(删除分区等)
必须指定索引列,ClickHouse中的索引列即排序列,通过order by指定,一般在查询条件中经常被用来充当筛选条件的属性被纳入进来;可以是单一维度,也可以是组合维度的索引;通常需要满足高级列在前、查询频率大的在前原则;还有基数特别大的不适合做索引列,如用户表的userid字段;通常筛选后的数据满足在百万以内为最佳。
主键(索引,即排序)order by字段选择: 就是把where 里面肯定有的字段加到里面,where 中一定有的字段放到第一位,注意字段的区分度适中即可 区分度太大太小都不好,因为ck的索引时稀疏索引,采用的是按照固定的粒度抽样作为实际的索引值,不是mysql的二叉树,所以不建议使用区分度特别高的字段。
值得一提的是,通常只有在使用SummingMergeTree或AggregatingMergeTree 的时候,才需要同时设置ORDER BY与PRIMARY KEY。显式的设置PRIMARY KEY,是为了将主键和排序键设置成不同的值,是进一步优化的体现。(PS:因为我们使用的引擎并不是上面几种,省略PRIMARY KEY。默认情况下,主键与排序键(ORDER BY)相同,所以通常直接使用ORDER BY代为指定主键。)
创建索引尽量选择基数大的,也就是重复相对较多的(因为是稀疏索引) 在mysql中正好是相反的。
总结一下可以优化的地方:
可以根据现有字段直接分区 PARTITION BY 字段
可以根据函数分区 PARTITION BY toYYYYMM(EventDate) toYYYYMM是函数,(eventDate)属性需要是Data类型
目前业务中可优化的选项:
日期尽量都使用date类型
按月分区的键仅允许读取那些包含适当范围的日期的数据块。在这种情况下,数据块可能包含许多日期(最多一整个月)的数据。在块中,数据按主键排序,主键可能不包含日期作为第一列。因此,使用仅具有未指定主键前缀的日期条件的查询将导致读取的数据多于单个日期的数据。
针对常查询的字段,使用ORDER BY(c1, c2, xxx) 创建索引,括号中从左到右的优先级会导致查询效率有别;
注意:有Nullable的字段,不能创建索引,一些查询字段不要设置Nullable()
举个例子:统计了一下业务中的风险实际表的查询sql语句,按照优先级高到低整理出来的字段
create_time , score , vehicle_id,id
也就是说需要这么写:
order by(create_time , score , vehicle_id,id).
官网原话:长主键会对插入性能和内存消耗产生负面影响 , 但主键中的额外列不会影响查询期间的ClickHouse 性能。
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.com/docs/zh/operations/server-configuration-parameters/settings
users.xml的配置项:
https://clickhouse.com/docs/zh/operations/settings/settings
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核数
一般建议使用连接池,防止连接不关闭。
max_memory_usage
(在users.xml中)单次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,这样不管多大的分区表都可以删除
ClickHouse不支持设置多数据目录,为了提升数据IO性能,可以挂载虚拟券组(对外是一块),一个券组绑定多块物理磁盘提升读写性能,多数据查询场景SSD会比普通机械硬盘快2-3倍
ClickHouse语法优化规则
ClickHouse的SQL优化规则是基于RBO(Rule Based Optimization,基于规则的优化,区别于CBO),下面是一些优化规则:
1.测试用表
下载hits_v1.tar和visits_v1.tar
https://datasets.ClickHouse.com/hits/partitions/hits_v1.tar
https://datasets.ClickHouse.com/visits/partitions/visits_v1.tar
一个是元数据,实际上就是sql文件;一个是数据文件,可以看到是按列存储的。
上传至服务器,解压到ClickHouse数据路径
sudo tar -xvf hits_v1.tar -C /var/lib/clickhouse
sudo tar -xvf visits_v1.tar -C /var/lib/clickhouse
然后进入目录后发现多了datasets这个库,然后授权
sudo chown -R clickhouse:clickhouse /var/lib/clickhouse/data/datasets
sudo chown -R clickhouse:clickhouse /var/lib/clickhouse/metadata/datasets
重启clickhouse-server
clickhouse restart
执行查询:
clickhouse-client --query "SELECT COUNT(*) FROM datasets.hits_v1"
#8873898
clickhouse-client --query "SELECT COUNT(*) FROM datasets.visits_v1"
#1676861
#可进入交互界面
SELECT COUNT() FROM datasets.hits_v1;
官方的tar包,包含了建库、建表语句、数据内容,这种方式不需要手动建库、建表,最方便。
hits_v1表有130多个字段,880多万条数据
visits_v1表有180多个字段,160多万条数据
https://www.cnblogs.com/wdh01/p/16877602.html
2.count优化
在调用 count 函数时,如果使用的是 count() 或者 count(*),且没有 where 条件,则会直接使用 system.tables 的 total_rows,例如:
EXPLAIN SELECT count()FROM datasets.hits_v1;
Union
Expression (Projection)
Expression (Before ORDER BY and SELECT)
MergingAggregated
ReadNothing (Optimized trivial count)
注意 Optimized trivial count ,这是对 count 的优化,直接查找count文件内容。如果 count 具体的列字段,则不会使用此项优化:
EXPLAIN SELECT count(CounterID) FROM datasets.hits_v1;
Union
Expression (Projection)
Expression (Before ORDER BY and SELECT)
Aggregating
Expression (Before GROUP BY)
ReadFromStorage (Read from MergeTree)
说明:由于使用 count() 和 count(1) 底层会自动优化为 count() ,所有 count() 和 count(1) 也可以进行自动优化;
EXPLAIN syntax SELECT count(*) FROM datasets.hits_v1;
SELECT count()
FROM datasets.hits_v1;
EXPLAIN syntax SELECT count(1) FROM datasets.hits_v1;
SELECT count()
FROM datasets.hits_v1;
3.消除子查询重复字段
下面语句子查询中有两个重复的 id 字段,会被去重(与hive全都展示不同),即使用AS起别名也不行:
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,
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.谓词下推
当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
子查询也支持谓词下推:
EXPLAIN SYNTAX
SELECT *
FROM (SELECT UserID
FROM datasets.visits_v1)
WHERE UserID = '8585742290196126178';
//优化后效果
SELECT UserID
FROM (SELECT UserID
FROM datasets.visits_v1
WHERE UserID = '8585742290196126178')
WHERE UserID = '8585742290196126178'
ck会把能加过滤的地方都加上。
再看另外一个例子:
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
SELECT UserID
SELECT UserID
FROM datasets.visits_v1
WHERE UserID = '8585742290196126178'
WHERE UserID = '8585742290196126178'
UNION ALL
SELECT UserID
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
EXPLAIN SYNTAX
SELECT *
FROM datasets.visits_v1
ORDER BY UserID ASC,
UserID ASC,
VisitID ASC,
VisitID ASC;
//返回优化后的语句:
select
FROM visits_v1
ORDER BY
UserID ASC,
VisitID ASC
8.删除重复的limit by key
例如下面的语句,重复声明的 name 字段会被去重
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,
b.UserID
FROM datasets.hits_v1 AS a
ALL LEFT JOIN datasets.visits_v1 AS b USING (UserID)
10.标量替换
如果子查询只返回一行数据,在被引用的时候用标量替换,例如下面语句中的 total_disk_usage 字段
EXPLAIN SYNTAX
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.1 Prewhere替代where
Prewhere和where语句的作用相同,用来过滤数据。不同之处在于prewhere只支持 *MergeTree 族系列引擎的表,首先会读取指定的列数据,来判断数据过滤,等待数据过滤之后再读取select 声明的列字段来补全其余属性。当查询列明显多于筛选列时使用Prewhere可十倍提升查询性能,Prewhere会自动优化执行过滤阶段的数据读取方式,降低io操作。在某些场合下,prewhere语句比where语句处理的数据量更少性能更高。
EXPLAIN SYNTAX
select WatchID,
JavaEnable,
Title,
GoodEvent,
EventTime,
EventDate,
CounterID,
ClientIP,
ClientIP6,
RegionID,
UserID,
CounterClass,
UserAgent,
Referer,
URLDomain,
RefererDomain,
Refresh,
IsRobot,
RefererCategories,
URLCategories,
URLRegions,
RefererRegions,
ResolutionWidth,
ResolutionHeight,
ResolutionDepth,
FlashMajor,
FlashMinor,
FlashMinor2
from datasets.hits_v1 where UserID='3198390223272470366';
#自动优化 from datasets.hits_v1 PREWHERE UserID = '3198390223272470366';
大部分情况都会自动优化成prewhere。
关闭where自动转prewhere:
set optimize_move_to_prewhere=0;
关闭自动优化后不会转变 PREWHERE 默认情况,我们肯定不会关闭where自动优化成prewhere,但是某些场景即使开启优化,也不会自动转换成prewhere,需要手动指定prewhere:
使用常量表达式
使用默认值为alias类型的字段
包含了arrayJOIN,globalIn,globalNotIn或者indexHint的查询
select查询的列字段和where的谓词相同
使用了主键字段
1.2 数据采样
有时候我们不需要获取全量数据,采样部分数据分析即可,通过采样运算可极大提升数据分析的性能
SELECT Title,count(*) AS PageViews
FROM hits_v1
SAMPLE 0.1 #代表采样10%的数据,也可以是具体的条数
WHERE CounterID =57
GROUP BY Title
ORDER BY PageViews DESC LIMIT 1000
采样修饰符只有在MergeTree engine表中才有效,且在创建表时需要指定采样策略。
1.3 列裁剪与分区裁剪
数据量太大时应避免使用select * 操作,查询的性能会与查询的字段大小和数量成线性表换,字段越少,消耗的io资源越少,性能就会越高。
select * from datasets.hits_v1;
select WatchID,
JavaEnable,
Title,
GoodEvent,
EventTime,
EventDate,
CounterID,
ClientIP,
ClientIP6,
RegionID,
UserID
from datasets.hits_v1;
分区裁剪就是只读取需要的分区,在过滤条件中指定。
select WatchID,
JavaEnable,
Title,
GoodEvent,
EventTime,
EventDate,
CounterID,
ClientIP,
ClientIP6,
RegionID,
UserID
from datasets.hits_v1
where EventDate='2022-08-23';
其中EventDate是分区键。
1.4 orderby 结合 where、limit
千万以上数据集进行order by查询时需要搭配where条件和limit语句一起使用
SELECT UserID,Age
FROM hits_v1
WHERE CounterID=57
ORDER BY Age DESC LIMIT 1000
SELECT UserID,Age
FROM hits_v1
ORDER BY Age DESC
1.5 避免构建虚拟列
如非必须,不要在结果集上构建虚拟列,虚拟列非常消耗资源浪费性能,可以考虑在前端进行处理,或者在表中构造实际字段进行额外存储。
SELECT Income,Age,Income/Age as IncRate FROM datasets.hits_v1;
正例:拿到Income和Age后,考虑在前端进行处理,或者在表中构造实际字段进行额外存储
SELECT Income,Age FROM datasets.hits_v1;
1.6 uniqCombined替代distinct
性能可提升10倍以上,uniqCombined底层采用类似HyperLogLog算法实现,能接收2%左右的数据误差,可直接使用这种去重方式提升查询性能。Count(distinct )会使用uniqExact精确去重。不建议在千万级不同数据上执行distinct去重查询,改为近似去重uniqCombined
select count(distinct rand()) from hits_v1;
SELECT uniqCombined(rand()) from datasets.hits_v1
1.7 其他注意事项
(1)查询熔断
为了避免因个别慢查询引起的服务雪崩的问题,除了可以为单个查询设置超时以外,还可以配置周期熔断,在一个查询周期内,如果用户频繁进行慢查询操作超出规定阈值后将无法继续进行查询操作。
(2)关闭虚拟内存
物理内存和虚拟内存的数据交换,会导致查询变慢,资源允许的情况下关闭虚拟内存。
(3)配置join_use_nulls
为每一个账户添加 join_use_nulls 配置,左表中的一条记录在右表中不存在,右表的相应字段会返回该字段相应数据类型的默认值,而不是标准SQL中的Null值。
(4)批量写入时先排序
批量写入数据时,必须控制每个批次的数据中涉及到的分区的数量,在写入之前最好对需要导入的数据进行排序。无序的数据或者涉及的分区太多,会导致ClickHouse无法及时对新导入的数据进行合并,从而影响查询性能。
(5)关注CPU
cpu一般在50%左右会出现查询波动,达到70%会出现大范围的查询超时,cpu是最关键的指标,要非常关注。
(6)物化视图,生产上大概率要用,参考后面单独介绍物化视图的文章。
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
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
select *
from datasets.hits_v1
where 1 = 0;
其中1=0是只要表结构不要数据。
2.用 IN 代替 JOIN
当多表联查时,查询的数据仅从其中一张表出时,可考虑用 IN 操作而不是JOIN
#正例:使用in
insert into hits_v2
select a.*
from datasets.hits_v1 a
where a. CounterID in (select CounterID from datasets.visits_v1);
#反例:使用join
insert into table hits_v2
select a.*
from datasets.hits_v1 a
inner join datasets.visits_v1 b on a. CounterID = b. CounterID;
强烈推荐。ck进行join时,将右表全部加载到内存,然后左表每一条数据进行匹配。
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;
大表在右直接抛错内存不够(用溢写磁盘会特别慢)
*4.注意谓词下推(版本差异)
需要子查询提前完成过滤(新版本已不存在此问题)
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';
Explain syntax
select a.* from datasets.hits_v1 a left join datasets.visits_v2 b on a. CounterID=b. CounterID
having b.StartDate = '2014-03-17';
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';
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;
在ck中,关联条件写在on中,过滤条件不能写在on里,只能写在where。
5.分布式表使用GLOBAL