ClickHouse介绍(四)ClickHouse使用操作
ClickHouse使用操作
这章主要介绍在ClickHouse使用的各个操作的注意点。常规的统一语法不做详细介绍。
1. Join操作
在ClickHouse中,对连接操作定义了不同的精度,包含ALL、ANY和ASOF三种类型,默认为ALL。可以通过join_default_strictness配置修改默认精度(位于system.setting表中)。下面分别说明这3种精度。
首先建表并插入测试数据:
--表join_tb1 CREATE TABLE join_tb1 ( `id` String, `name` String, `time` DateTime ) ENGINE = MergeTree PARTITION BY toYYYYMM(time) ORDER BY id --表 join_tb2 CREATE TABLE join_tb2 ( `id` String, `rate` UInt8, `time` DateTime ) ENGINE = MergeTree PARTITION BY toYYYYMM(time) ORDER BY id --表 join_tb3 CREATE TABLE join_tb3 ( `id` String, `star` UInt8 ) ENGINE = MergeTree ORDER BY id --插入数据 INSERT INTO join_tb1 VALUES ('1', 'ClickHouse', '2019-05-01 12:00:00') ('2', 'Spark', '2019-05-01 12:30:00') ('3', 'ElasticSearch', '2019-05-01 13:00:00') ('4', 'HBase', '2019-05-01 13:30:00') (NULL, 'ClickHouse', '2019-05-01 14:00:00') (NULL, 'Spark', '2019-05-01 14:30:00') INSERT INTO join_tb2 VALUES ('1', 100, '2019-05-01 11:55:00') ('1', 105, '2019-05-01 11:50:00') ('2', 90, '2019-05-01 12:01:00') ('3', 80, '2019-05-01 13:10:00') ('5', 70, '2019-05-01 14:00:00') ('6', 60, '2019-05-01 13:55:00') INSERT INTO join_tb3 VALUES ('1', 1000) ('2', 900)
1.1. ALL
如果左表内的一行数据,在右表中有多行数据与之连接匹配,则返回右表种全部连接的数据。连接依据为:left.key=right.key。
SELECT a.id, a.name, b.rate FROM join_tb1 AS a ALL INNER JOIN join_tb2 AS b ON a.id=b.id SELECT a.id, a.name, b.rate FROM join_tb1 AS a ALL INNER JOIN join_tb2 AS b ON a.id = b.id ┌─id─┬─name──────────┬─rate─┐ │ 1 │ ClickHouse │ 100 │ │ 1 │ ClickHouse │ 105 │ │ 2 │ Spark │ 90 │ │ 3 │ ElasticSearch │ 80 │ └────┴───────────────┴──────┘
1.2. ANY
如果左表内的一行数据,在右表中有多行数据与之连接匹配,则仅返回右表中第一行连接的数据。连接依据同样为:left.key=right.key
SELECT a.id, a.name, b.rate FROM join_tb1 AS a ANY INNER JOIN join_tb2 AS b ON a.id = b.id ┌─id─┬─name──────────┬─rate─┐ │ 1 │ ClickHouse │ 100 │ │ 2 │ Spark │ 90 │ │ 3 │ ElasticSearch │ 80 │ └────┴───────────────┴──────┘
1.3. ASOF
ASOF 是一种模糊连接,允许在连接键之后追加定义一个模糊连接的匹配条件asof_column,例如:
SELECT a.id, a.name, b.rate, a.time, b.time FROM join_tb1 AS a ASOF INNER JOIN join_tb2 AS b ON (a.id = b.id) AND (a.time >= b.time) ┌─id─┬─name───────┬─rate─┬────────────────time─┬──────────────b.time─┐ │ 1 │ ClickHouse │ 100 │ 2019-05-01 12:00:00 │ 2019-05-01 11:55:00 │ │ 2 │ Spark │ 90 │ 2019-05-01 12:30:00 │ 2019-05-01 12:01:00 │ └────┴────────────┴──────┴─────────────────────┴─────────────────────┘
根据官网介绍的语法:
SELECT expressions_list FROM table_1 ASOF LEFT JOIN table_2 ON equi_cond AND closest_match_cond
https://clickhouse.tech/docs/en/sql-reference/statements/select/join/
ASOF会先以 left.key = right.key 进行连接匹配,然后根据AND 后面的 closest_match_cond(也就是这里的a.time >= b.time)过滤出最符合此条件的第一行连接匹配的数据。
另一种写法是使用USING,语法为:
SELECT expressions_list FROM table_1 ASOF JOIN table_2 USING (equi_column1, ... equi_columnN, asof_column)
举例:
SELECT a.id, a.name, b.rate, a.time, b.time FROM join_tb1 AS a ASOF INNER JOIN join_tb2 AS b USING (id, time) Query id: 075f7e4a-7355-4e11-ae3b-0e3275912a3e ┌─id─┬─name───────┬─rate─┬────────────────time─┬──────────────b.time─┐ │ 1 │ ClickHouse │ 100 │ 2019-05-01 12:00:00 │ 2019-05-01 11:55:00 │ │ 2 │ Spark │ 90 │ 2019-05-01 12:30:00 │ 2019-05-01 12:01:00 │ └────┴────────────┴──────┴─────────────────────┴─────────────────────┘
对 asof_colum 字段的使用有2点需要注意:
- asof_column 必须是整型、浮点型和日期型这类有序序列的数据类型
- asof_column不能是数据表内的唯一字段,也就是说连接键(JOIN KEY)和asof_column不能是同一字段
1.4. Join性能
在执行JOIN时,ClickHouse对执行的顺序没有特别优化,JOIN操作会在WHERE以及聚合查询前运行。
JOIN操作结果不会缓存,所以每次JOIN操作都会生成一个全新的执行计划。如果应用程序会大量使用JOIN,则需进一步考虑借助上层应用侧的缓存服务或使用JOIN表引擎来改善性能(JOIN表引擎不支持ASOF精度)。JOIN表引擎会在内存中保存JOIN结果。
在某些情况下,IN的效率比JOIN要高。
在使用JOIN连接维度表时,JOIN操作可能并不会特别高效,因为右则表对每个query来说,都需要加载一次。在这种情况下,外部字典(external dictionaries)的功能会比JOIN性能更好。
1.5. JOIN的内存限制
默认情况下,ClickHouse使用Hash Join 算法。它会将右侧表(right_table)加载到内存,并为它创建一个hash table。在达到了内存使用的一个阈值后,ClickHouse会转而使用Merge Join 算法。
可以通过以下参数限制JOIN操作消耗的内存:
- max_rows_in_join:限制hash table中的行数
- max_bytes_in_join:限制hash table的大小
在达到任何上述limit后,ClickHouse会以join_overflow_mode 的参数进行动作。此参数包含2个可选值:
- THROW:抛出异常并终止操作
- BREAK:终止操作但并不抛出异常
2. WHERE与PREWHERE子句
WHERE可以通过表达式来过滤数据,如果过滤条件恰好为主键字段,则可以进一步借助索引加速查询,所以WHERE子句是决定查询语句是否能使用索引的判断依据(前提是表引擎支持索引)。
除此之外,ClickHouse还提供了PREWHERE子句用于条件过滤,它可以更有效地进行过滤优化,仅用于MergeTree表系列引擎。
PREWHERE与WHERE不同之处在于:使用PREWHERE时,首先只会去PREWHERE指定的列字段数据,用于数据过滤的条件判断。在数据过滤之后再读取SELECT声明的列字段以补全其余属性。所以在一些场合下,PREWHERE相比WHERE而言,处理的数据更少,性能更高。
默认情况下,即使在PREWHERE子句没有显示指定的情况下,它也会自动移动到WHERE条件到PREWHERE阶段。
下面做个对比:
# 默认自动开启了PREWHERE,查询速度为: select WatchID, Title, GoodEvent from hits_v1 where JavaEnable=1; … 6535088 rows in set. Elapsed: 1.428 sec. Processed 8.87 million rows, 863.90 MB (6.21 million rows/s., 604.82 MB/s.) # 关闭PREWHERE set optimize_move_to_prewhere=0 # 关闭自动PREWHERE,查询速度为 6535088 rows in set. Elapsed: 1.742 sec. Processed 8.87 million rows, 864.55 MB (5.09 million rows/s., 496.20 MB/s.)
可以看到2条语句处理的数据总量没有变化,但是其数据处理量稍有降低(PREWHERE为863.90MB),且每秒吞吐量上升(PREWHER为604.82MB/s,WHERE为496.20MB/s)。
对比2条语句的执行计划:
# PREWHERE explain select WatchID, Title, GoodEvent from hits_v1 prewhere JavaEnable=1; EXPLAIN SELECT WatchID, Title, GoodEvent FROM hits_v1 PREWHERE JavaEnable = 1 Query id: 103fd24a-e718-4304-9f75-4900528c1d1a ┌─explain───────────────────────────────────────────────────────────────────┐ │ Expression ((Projection + Before ORDER BY)) │ │ SettingQuotaAndLimits (Set limits and quota after reading from storage) │ │ ReadFromStorage (MergeTree) │ └───────────────────────────────────────────────────────────────────────────┘ # WHERE explain select WatchID, Title, GoodEvent from hits_v1 where JavaEnable=1; EXPLAIN SELECT WatchID, Title, GoodEvent FROM hits_v1 WHERE JavaEnable = 1 Query id: 9b470524-1320-4e9f-bade-cf8c2c9944c8 ┌─explain─────────────────────────────────────────────────────────────────────┐ │ Expression ((Projection + Before ORDER BY)) │ │ Filter (WHERE) │ │ SettingQuotaAndLimits (Set limits and quota after reading from storage) │ │ ReadFromStorage (MergeTree) │ └─────────────────────────────────────────────────────────────────────────────┘
可以看到相比WHERE语句,PREWHERE语句的执行计划省去了一次Filter操作。
3. Group By
Group By的用法非常常见,ClickHouse中执行聚合查询时,若是SELECT后面只声明了聚合函数,则GROUP BY 关键字可以省略:
SELECT SUM(data_compressed_bytes) AS compressed, SUM(data_uncompressed_bytes) AS uncompressed FROM system.parts Query id: e38e3ec1-968d-4442-ba7d-b8555f27e0d0 ┌─compressed─┬─uncompressed─┐ │ 1851073942 │ 9445387666 │ └────────────┴──────────────┘
聚合查询还能配合WITH ROLLUP、WITH CUBE和WITH TOTALS三种修饰符获取额外的汇总信息。
3.1. WITH ROLLUP
ROLLUP便是上卷数据,按聚合键从右到左,基于聚合函数依次生成分组小计和总计。如果设聚合键的个数为n,则最终会生成小计的个数为n+1。例如:
SELECT table, name, SUM(bytes_on_disk) FROM system.parts GROUP BY table, name WITH ROLLUP ORDER BY table ASC ┌─table──────────────────────────────────────────┬─name───────────────────────────────────┬─SUM(bytes_on_disk)─┐ │ │ │ 1857739143 │ │ .inner_id.604be4d8-bb5c-437b-ada9-3d3d5a91fc24 │ │ 638 │ │ .inner_id.604be4d8-bb5c-437b-ada9-3d3d5a91fc24 │ 953e60a1e8747360786c2b70a223788d_2_4_1 │ 318 │ │ .inner_id.604be4d8-bb5c-437b-ada9-3d3d5a91fc24 │ acb795a12c7ba41b0ed4c3d94a008ecd_1_3_1 │ 320 │ │ agg_table │ │ 358 │ │ agg_table │ 201909_2_2_0 │ 358 │
可以看到第1行是一个汇总,统计的SUM(bytes_on_disk)的总行数。而每个table字段都有一个汇总(例如.inner_id.604be4d8-bb5c-437b-ada9-3d3d5a91fc24 表第一行以及agg_table 第一行)。
3.2. WITH CUBE
CUBE也是数仓里重要的概念,基于聚合键之间所有的组合生成统计信息。如果聚合键的个数为n,则最终聚合数据的个数为2的n次方。例如:
--建表 CREATE TABLE person ( `id` int, `name` String, `course` String, `year` DateTime, `points` int ) ENGINE = MergeTree ORDER BY id --插入数据 INSERT INTO person VALUES (1, 'jane', 'CS', '2021-01-02 11:00:00', 50), (2, 'tom', 'CS', '2021-01-03 11:00:00', 60), (3, 'bob', 'BS', '2021-01-03 11:00:00', 50), (4, 'alice', 'BS', '2021-01-01 11:00:00', 40), (5, 'jane', 'ACC', '2021-01-02 11:00:00', 70), (6, 'bob', 'ACC', '2021-01-03 11:00:00', 90), (7, 'jane', 'MATH', '2021-01-04 11:00:00', 100) --Cube计算 SELECT name, course, year, AVG(points) FROM person GROUP BY name, course, year WITH CUBE ┌─name──┬─course─┬────────────────year─┬─AVG(points)─┐ │ jane │ ACC │ 2021-01-02 11:00:00 │ 70 │ │ bob │ ACC │ 2021-01-03 11:00:00 │ 90 │ │ alice │ BS │ 2021-01-01 11:00:00 │ 40 │ … ┌─name─┬─course─┬────────────────year─┬───────AVG(points)─┐ │ │ │ 2021-01-01 11:00:00 │ 40 │ │ │ │ 2021-01-03 11:00:00 │ 66.66666666666667 │ │ │ │ 2021-01-02 11:00:00 │ 60 │ │ │ │ 2021-01-04 11:00:00 │ 100 │ └──────┴────────┴─────────────────────┴───────────────────┘ ┌─name─┬─course─┬────────────────year─┬───────AVG(points)─┐ │ │ │ 1970-01-01 00:00:00 │ 65.71428571428571 │ └──────┴────────┴─────────────────────┴───────────────────┘
可以看到结果中会生成 8 个统计结果(部分结果已省略)。
3.3. WITH TOTALS
WITH TOTALS会基于聚合函数对所有数据进行统计(比原结果多一行总的统计结果),例如:
SELECT name, course, year, AVG(points) FROM person GROUP BY name, course, year WITH TOTALS ┌─name──┬─course─┬────────────────year─┬─AVG(points)─┐ │ jane │ ACC │ 2021-01-02 11:00:00 │ 70 │ │ bob │ ACC │ 2021-01-03 11:00:00 │ 90 │ │ alice │ BS │ 2021-01-01 11:00:00 │ 40 │ │ jane │ CS │ 2021-01-02 11:00:00 │ 50 │ │ jane │ MATH │ 2021-01-04 11:00:00 │ 100 │ │ tom │ CS │ 2021-01-03 11:00:00 │ 60 │ │ bob │ BS │ 2021-01-03 11:00:00 │ 50 │ └───────┴────────┴─────────────────────┴─────────────┘ Totals: ┌─name─┬─course─┬────────────────year─┬───────AVG(points)─┐ │ │ │ 1970-01-01 00:00:00 │ 65.71428571428571 │ └──────┴────────┴─────────────────────┴───────────────────┘
4. 查看SQL执行计划
ClickHouse目前并没有直接提供EXPLAIN的详细查询计划,当前EXPLAIN仅是输出一个简单的计划。不过我们仍可以借助后台服务日志来实现此功能,例如执行以下语句即可看到详细的执行计划:
clickhouse-client --password xxx --send_logs_level=trace <<<'select * from tutorial.hits_v1' > /dev/null
打印信息如下(仅截取关键信息):
tutorial.hits_v1 (SelectExecutor): Key condition: unknown => 查询未使用主键索引 tutorial.hits_v1 (SelectExecutor): MinMax index condition: unknown => 未使用分区索引 tutorial.hits_v1 (SelectExecutor): Not using primary index on part 201403_1_29_2 => 未在分区 201403_1_29_2 下使用primary index tutorial.hits_v1 (SelectExecutor): Selected 1 parts by partition key, 1 parts by primary key, 1094 marks by primary key, 1094 marks to read from 1 ranges => 选择了1个分区,共计1094个marks executeQuery: Read 8873898 rows, 7.88 GiB in 21.9554721 sec., 404177 rows/sec., 367.50 MiB/sec. => 读取 8873898条数据,7.88G 数据,耗时21.955秒… MemoryTracker: Peak memory usage (for query): 361.67 MiB. => 消耗内存量
下面优化一下查询:
clickhouse-client --password xxx --send_logs_level=trace <<<"select WatchID from tutorial.hits_v1 where EventDate='2014-03-17'" > /dev/null
打印结果为:
InterpreterSelectQuery: MergeTreeWhereOptimizer: condition "EventDate = '2014-03-17'" moved to PREWHERE => 自动调用了PREWHERE tutorial.hits_v1 (SelectExecutor): Key condition: (column 1 in [16146, 16146]) => 使用了主键索引 tutorial.hits_v1 (SelectExecutor): MinMax index condition: (column 0 in [16146, 16146]) => 使用了分区索引 tutorial.hits_v1 (SelectExecutor): Selected 1 parts by partition key, 1 parts by primary key, 755 marks by primary key, 755 marks to read from 64 ranges => 根据分区键选择了一个分区 executeQuery: Read 6102294 rows, 58.19 MiB in 0.032661599 sec., 186833902 rows/sec., 1.74 GiB/sec. => 读到的数据,以及速度 MemoryTracker: Peak memory usage (for query): 11.94 MiB. => 消耗内存量
总的来说,ClickHouse未直接通过EXPLAIN语句提供查看语句执行的详细过程,但是可以变相的将日志设置到DEBUG或是TRACE级别,实现此功能,并分析SQL的执行日志。