Clickhouse优化详解

文章目录

  • 一、 Explain查看执行计划
    • 1.1 基本语法
    • 1.2 案例实操
  • 二、建表优化
    • 2.1 数据类型
      • 2.1.1 时间字段的类型
      • 2.1.2 空值存储类型
    • 2.2 分区和索引
    • 2.3 表参数
    • 2.4 写入和删除优化
    • 2.5 常见配置
      • 2.5.1 CPU资源
      • 2.5.2 内存资源
      • 2.5.3 存储
  • 三、ClickHouse 语法优化规则
    • 3.1 准备测试用表
    • 3.2 COUNT 优化
    • 3.3 消除子查询重复字段
    • 3.4 谓词下推
    • 3.5 聚合计算外推
    • 3.6 聚合函数消除
    • 3.7 删除重复的 order by key
    • 3.8 删除重复的 limit by key
    • 3.9 删除重复的 USING Key
    • 3.10 标量替换
    • 3.11 三元运算优化
  • 四、查询优化
    • 4.1 单表查询
      • 4.1.1 Prewhere替代where
      • 4.1.2 数据采样
      • 4.1.3 列裁剪与分区裁剪
      • 4.1.4 orderby 结合 where、limit
      • 4.1.5 避免构建虚拟列
      • 4.1.6 uniqCombined替代distinct
      • 4.1.7 使用物化视图
      • 4.1.8 其他注意事项
        • 查询熔断
        • 关闭虚拟内存
        • 配置join_use_nulls
        • 批量写入时先排序
        • 关注CPU
    • 4.2 多表关联
      • 4.2.1 准备表和数据
      • 4.2.2 用 IN 代替 JOIN
      • 4.2.3 大小表JOIN
      • 4.2.4 注意谓词下推(版本差异)
      • 4.2.5 分布式表使用GLOBAL
      • 4.2.6 使用字典表
      • 4.2.7 提前过滤

一、 Explain查看执行计划

1.1 基本语法

1.2 案例实操

二、建表优化

2.1 数据类型

2.1.1 时间字段的类型

建表时能用时间日期类型就不要使用字符串类型,在Hive中可以使用全字符串,但是在Clickhouse中不应使用。
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.1.2 空值存储类型

在Clickhouse中空值是会影响性能的,因为要存储一个nullable列时需要创建一个额外的文件来存储null的标记,并且null列无法被索引,因此,应直接使用字段默认值表示为空,或者自定义一个在业务中午意义的值表示。

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;

2.2 分区和索引

分区应按照实际业务进行分区,一般选择按天进行分区。
索引列必须执行,Clickhouse中的索引列即排序列,通过order by执行,一般在查询条件中经常被用来充当筛选条件的属性被纳入进来,可以是单一的维度,也可以是组合维度的索引,通常是高级列在前、查询频率较大的列在前的原则,筛选后的数据满足在百万以内最佳。

2.3 表参数

index_granularity 是用来控制索引粒度的,默认是8192,不建议调整。
如果表中不是必须保留全量历史数据,建议指定TTL,避免手动删除过期数据,TTL也可以通过alter table进行修改。

2.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

解决办法:

  1. Too many parts:使用WAL与写入日志,提高写入性能。
  2. Memory limit (for query) :in_memory_parts_enable_wal 默认为 true,如果服务器内存够用,则通过max_memory_usage 来实现,如果服务器内存不够用,可以将超出的部分写入到磁盘,通过max_bytes_before_external_group_by、max_bytes_before_external_sort 参数实现。

2.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/

2.5.1 CPU资源

Clickhouse优化详解_第1张图片

2.5.2 内存资源

Clickhouse优化详解_第2张图片

2.5.3 存储

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

三、ClickHouse 语法优化规则

Clickhouse的SQL优化是基于RBO(Rule Based Optimization)

3.1 准备测试用表

将 visits_v1.tar 和 hits_v1.tar 上传到虚拟机,解压到 clickhouse 数据路径下
Clickhouse优化详解_第3张图片

# 解压到 clickhouse 数据路径
sudo tar -xvf hits_v1.tar -C /var/lib/clickhouse
sudo tar -xvf visits_v1.tar -C /var/lib/clickhouse
# 修改所属用户
sudo chown -R clickhouse:clickhouse /var/lib/clickhouse/data/datasets
sudo chown -R clickhouse:clickhouse /var/lib/clickhouse/metadata/datasets

# 重启Clickhouse
sudo clickhouse restart

3.2 COUNT 优化

如果直接使用count()或者count(*),且没有where条件时,则直接会使用system.tables的total_rows,如:

explain select count(*) from hits_v1 hv ;

Optimized trivial count :这是对 count 的优化。
如果使用count具体字段,则不会有此优化:

EXPLAIN SELECT count(CounterID) FROM datasets.hits_v1;

3.3 消除子查询重复字段

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

EXPLAIN SYNTAX SELECT 
 a.UserID,
 b.VisitID,
 a.URL,
 b.UserID
 FROM
 hits_v1 AS a 
 LEFT JOIN ( 
 SELECT 
 UserID, 
 UserID as HaHa, 
 VisitID 
 FROM visits_v1) AS b 
 USING (UserID)
 limit 3;

-- 返回优化语句:
SELECT 
 UserID,
 VisitID,
 URL,
 b.UserID
FROM hits_v1 AS a
ALL LEFT JOIN 
(
 SELECT 
 UserID,
 VisitID
 FROM visits_v1
) AS b USING (UserID)
LIMIT 3

3.4 谓词下推

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

EXPLAIN SYNTAX SELECT UserID FROM hits_v1 GROUP BY UserID HAVING UserID = 
'8585742290196126178';

-- 返回优化语句
SELECT UserID
FROM hits_v1
WHERE UserID = \'8585742290196126178\'
GROUP BY UserID

子查询也支持谓词下推:

EXPLAIN SYNTAX
SELECT *
FROM 
(
 SELECT UserID
 FROM visits_v1
)
WHERE UserID = '8585742290196126178'

-- 返回优化后的语句
SELECT UserID
FROM 
(
 SELECT UserID
 FROM visits_v1
 WHERE UserID = \'8585742290196126178\'
)
WHERE UserID = \'8585742290196126178\'

复杂场景:

EXPLAIN SYNTAX 
SELECT * FROM (
 SELECT 
 * 
 FROM 
 (
 SELECT 
 UserID 
 FROM visits_v1) 
 UNION ALL 
 SELECT 
 *
   FROM 
 (
 SELECT 
 UserID 
 FROM visits_v1)
)
WHERE UserID = '8585742290196126178'

-- 返回优化后的语句
SELECT UserID
FROM 
(
 SELECT UserID
 FROM 
 (
 SELECT UserID
 FROM visits_v1
 WHERE UserID = \'8585742290196126178\'
 )
 WHERE UserID = \'8585742290196126178\'
 UNION ALL
 SELECT UserID
 FROM 
 (
 SELECT UserID
 FROM visits_v1
 WHERE UserID = \'8585742290196126178\'
 )
 WHERE UserID = \'8585742290196126178\'
)
WHERE UserID = \'8585742290196126178\'

3.5 聚合计算外推

聚合函数内的计算,会进行外推:

EXPLAIN SYNTAX
SELECT sum(UserID * 2)
FROM visits_v1

-- 优化后的sql
SELECT sum(UserID) * 2
FROM visits_v1

3.6 聚合函数消除

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

EXPLAIN SYNTAX
SELECT
 sum(UserID * 2),
 max(VisitID),
 max(UserID)
FROM visits_v1
GROUP BY UserID

-- 优化后的语句
SELECT 
 sum(UserID) * 2,
 max(VisitID),
 UserID
FROM visits_v1
GROUP BY UserID

3.7 删除重复的 order by key

重复的order by key会被删除:

EXPLAIN SYNTAX
SELECT *
FROM visits_v1
ORDER BY
 UserID ASC,
 UserID ASC,
 VisitID ASC,
VisitID ASC
//返回优化后的语句:
select
……
FROM visits_v1
ORDER BY 
 UserID ASC,
VisitID ASC

3.8 删除重复的 limit by key

重复的limit by key会被删除:

EXPLAIN SYNTAX
SELECT *
FROM visits_v1
LIMIT 3 BY
 VisitID,
 VisitID
LIMIT 10

--返回优化后的语句:
select
……
FROM visits_v1
LIMIT 3 BY VisitID
LIMIT 10

3.9 删除重复的 USING Key

重复的using key会被删除:

EXPLAIN SYNTAX
SELECT
 a.UserID,
 a.UserID,
 b.VisitID,
 a.URL,
 b.UserID
FROM hits_v1 AS a
LEFT JOIN visits_v1 AS b USING (UserID, UserID)

-- 返回优化后的语句:
SELECT 
 UserID,
 UserID,
 VisitID,
 URL,
 b.UserID
FROM hits_v1 AS a
ALL LEFT JOIN visits_v1 AS b USING (UserID)

3.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

3.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

四、查询优化

4.1 单表查询

4.1.1 Prewhere替代where

prewhere和where作用相同,用来过滤数据,不同之处在于prewhere只支持MergeTree系列的表,首先会读取指定的列的数据,来判断数据过滤,等待数据过滤之后再读取select字段来补全其余属性。
当查询列多余筛选列时使用prewhere可提升十倍查询性能,prewhere会自动优化执行过滤阶段的数据读取方式,降低IO。
在某些情况下,prewhere语句比where语句处理的数据量更少性能更高。

#关闭 where 自动转 prewhere(默认情况下, where 条件会自动优化成 prewhere)
set optimize_move_to_prewhere=0;

# 使用 where
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';

# 使用 prewhere 关键字
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:

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

4.1.2 数据采样

通过数据采样运算可极大提高数据分析的性能,数据采样只针对MergeTree表才有用,且在建表时需要指定采样策略:

SELECT Title,count(*) AS PageViews 
FROM hits_v1
SAMPLE 0.1 -- 代表采样 10%的数据,也可以是具体的条数
WHERE CounterID =57
GROUP BY Title
ORDER BY PageViews DESC LIMIT 1000

4.1.3 列裁剪与分区裁剪

数据量太大时避免使用select * 这种写法,字段越少,IO越低,性能也就越高。
数据量较大的表一定要建成分区表,减少数据的扫描量。

4.1.4 orderby 结合 where、limit

千万级以上数据量在使用order by查询时需要搭配where和limit语句一起使用。

4.1.5 避免构建虚拟列

虚拟列非常耗资源,不是必须不要在结果集上构建虚拟列。可以考虑在前端进行处理,或者在表中构造实际字段进行额外存储。

4.1.6 uniqCombined替代distinct

性能可提升十倍以上,uniqCombined底层采用类似HyperLogLog算法实现,能接受2%左右的数据误差,可直接使用这种去重方式提升查询性能,count(distinct) 会使用uniqExact精确去重。
千万级以上数据不建议使用distinct ,改为近似去重uniqCombined。

4.1.7 使用物化视图

ClickHouse 的物化视图是一种查询结果的持久化,它确实是给我们带来了查询效率的提升。用户查起来跟表没有区别,它就是一张表,它也像是一张时刻在预计算的表,创建的过程它是用了一个特殊引擎,加上后来 as select,就是 create 一个 table as select 的写法。“查询结果集”的范围很宽泛,可以是基础表中部分数据的一份简单拷贝,也可以是多表 join 之后产生的结果或其子集,或者原始数据的聚合指标等等。所以,物化视图不会随着基础表的变化而变化,所以它也称为快照(snapshot)。
物化视图是把查询的结果根据相应的引擎存入到了磁盘或内存中。
优点:查询速度,要是把物化视图这些规则全部写好,它比原数据查询快了很多,总
的行数少了,因为都预计算好了。
缺点:它的本质是一个流式数据的使用场景,是累加式的技术,所以要用历史数据做去
重、去核这样的分析,在物化视图里面是不太好用的。在某些场景的使用也是有限的。而且
如果一张表加了好多物化视图,在写这张表的时候,就会消耗很多机器的资源,比如数据带
宽占满、存储一下子增加了很多。

4.1.8 其他注意事项

查询熔断

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

关闭虚拟内存

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

配置join_use_nulls

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

批量写入时先排序

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

关注CPU

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

4.2 多表关联

4.2.1 准备表和数据

-- 创建小表
CREATE TABLE 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 visits_v1 limit 10000;

-- 创建 join 结果表:避免控制台疯狂打印数据
CREATE TABLE 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 hits_v1 where 1=0;

4.2.2 用 IN 代替 JOIN

当进行多表联查时,如果查询的数据仅从其中一张表出时,可考虑用IN而不是Join

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

-- 反例:使用 join
insert into table hits_v2
select a.* from hits_v1 a left join visits_v1 b on a. CounterID=b. 
CounterID;

4.2.3 大小表JOIN

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

-- 小表在右
insert into table hits_v2
select a.* from hits_v1 a left join visits_v2 b on a. CounterID=b. 
CounterID;

-- 大表在右
insert into table hits_v2
select a.* from visits_v2 b left join hits_v1 a on a. CounterID=b. 
CounterID;

4.2.4 注意谓词下推(版本差异)

Clickhouse在Join查询时不会主动发起谓词下推的操作,需要每个子查询提前完成过滤操作,需要注意的是,是否执行谓词下推,对性能影响很大。

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

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

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

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

4.2.5 分布式表使用GLOBAL

两张分布式表上的IN和Join之前必须加上GLOBAL关键字,右表只会在接收查询请求的那个节点查询一次,并将其分发到其他节点上,如果不加GLOBAL关键字的话,每个节点都会单独发起一次对右表的查询,而右表又是分布式表,就导致右表一共会被查询N²次(N是该分布式表的分片数量),这就是查询放大,会带来很大开销。

4.2.6 使用字典表

将一些需要关联分析的业务创建成字典表进行join操作,前提是字典表不宜太大,因为字典表会常驻内存。

4.2.7 提前过滤

通过增加逻辑过滤可以减少数据扫描,达到提高执行速度及降低内存消耗的目的

你可能感兴趣的:(Clickhouse,clickhouse,大数据,hive)