ClickHouse 查询优化实用指南:第一部分

图片

本文字数:18076;估计阅读时间:46分钟

作者: Lionel Palacin

本文在公众号【ClickHouseInc】首发

图片

几个月前,我加入了 ClickHouse 的产品营销工程团队。在此之前,我在 Elastic 专注于搜索解决方案。转岗后,我需要快速熟悉 ClickHouse 和 OLAP 数据库的核心知识。

我的第一个项目是参与推出全新的 ClickHouse Playground。这是一个包含丰富数据集的平台,非常适合我学习和实践 ClickHouse 的强大功能。

在 ClickHouse Playground 发布后,我对用户体验产生了浓厚的兴趣,特别是想知道我们提供的一些示例查询是否还可以进一步优化。通过按需培训、视频、文档和博客等学习资源,我积累了许多优化 ClickHouse 查询的技巧。本文是一个两部分系列博客的第一篇,旨在分享一些实用的优化方法。

在本篇中,我们将介绍 ClickHouse 提供的工具,帮助您分析和排查慢查询。接着,我们会讨论基础优化和主键设计的重要性。在下一篇中,我们将探讨更高级的优化技术,例如投影 (projections)、物化视图 (materialized views) 和跳过索引 (data-skipping indexes)。

如何分析查询性能

性能优化的最佳时机是在您首次向 ClickHouse 导入数据之前,设计数据模式时。

然而,现实中很难准确预测数据的增长规模或未来会执行的查询类型。

如果您刚刚开始使用 ClickHouse,可以直接跳过这一部分,前往下一节,学习基础优化和主键设计的关键点。

但如果您已经有了一个运行中的部署,并希望改进某些查询的性能,那么第一步是了解这些查询的执行情况。具体来说,分析为什么某些查询只需几毫秒,而另一些却耗时更久。

ClickHouse 提供了一系列强大的工具,帮助您深入了解查询的执行方式以及资源消耗。

接下来的内容中,我们将详细介绍这些工具及其使用方法。

理解查询性能的核心要点

要理解查询性能,首先让我们简单了解一下 ClickHouse 在执行查询时的主要流程。

以下内容经过简化,目的是帮助您快速掌握基本概念,而不是陷入繁琐的细节之中。如果您希望深入了解,可以参考查询分析器的文档。

从一个高层次的角度来看,当 ClickHouse 执行查询时,主要经历以下几个阶段:

1. 查询解析与分析 

首先,查询会被解析和分析,生成一个初步的查询执行计划。

2. 查询优化 

接下来,查询执行计划会经过优化,包括剪枝不必要的数据,并根据查询计划构建查询管道。

3. 查询管道的执行

在这个阶段,ClickHouse 会以并行方式读取和处理数据。这是实际执行查询操作的过程,包括过滤、聚合和排序等。

4. 最终处理

最后,查询结果会被合并、排序,并格式化为最终的输出结果,然后发送给客户端。

实际上,查询执行过程中包含了许多额外的优化步骤,我们将在本指南后续部分深入讨论。不过,目前这些核心概念足以帮助您理解 ClickHouse 查询执行背后的基本原理。

现在,基于这个高层次的理解,我们来看一看 ClickHouse 提供的工具,以及如何利用它们追踪影响查询性能的关键指标。

探索查询优化的演示环境

正如我们在介绍中提到的,我们最近上线了 ClickHouse Playground 演示环境,用户可以在这里用 ClickHouse 测试不同的数据集。这个环境运行在 ClickHouse Cloud 上,已经吸引了数百名用户的参与。

图片

在本文中,我将利用这个环境展示如何优化查询性能。

数据集介绍

ClickHouse Playground 中的一个数据集是 NYC Taxi 数据集,其中包含纽约市出租车行程的详细数据。在没有进行任何优化的情况下,我们已经将这个数据集导入。

以下是创建表并从 S3 存储桶导入数据的命令。为了方便操作,我们直接从数据中推断表结构。

-- Create table with inferred schema
CREATE TABLE trips_small_inferred
ORDER BY () EMPTY
AS SELECT * FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/nyc-taxi/clickhouse-academy/nyc_taxi_2009-2010.parquet');

-- Insert data into table with inferred schema
INSERT INTO trips_small_inferred
SELECT * 
FROM s3Cluster
('default','https://datasets-documentation.s3.eu-west-3.amazonaws.com/nyc-taxi/clickhouse-academy/nyc_taxi_2009-2010.parquet');

接下来,我们先看一下 ClickHouse 自动生成的表结构。

--- Display inferred table schema
SHOW CREATE TABLE trips_small_inferred

Query id: d97361fd-c050-478e-b831-369469f0784d

CREATE TABLE nyc_taxi.trips_small_inferred
(
    `vendor_id` Nullable(String),
    `pickup_datetime` Nullable(DateTime64(6, 'UTC')),
    `dropoff_datetime` Nullable(DateTime64(6, 'UTC')),
    `passenger_count` Nullable(Int64),
    `trip_distance` Nullable(Float64),
    `ratecode_id` Nullable(String),
    `pickup_location_id` Nullable(String),
    `dropoff_location_id` Nullable(String),
    `payment_type` Nullable(Int64),
    `fare_amount` Nullable(Float64),
    `extra` Nullable(Float64),
    `mta_tax` Nullable(Float64),
    `tip_amount` Nullable(Float64),
    `tolls_amount` Nullable(Float64),
    `total_amount` Nullable(Float64)
)
ORDER BY tuple()

识别慢查询

查询日志的使用

ClickHouse 会默认记录所有查询的执行信息,并存储在 system.query_log 表中。这些日志包含查询执行时间、读取行数以及资源使用情况(如 CPU 和内存消耗、文件系统缓存命中次数)。

如果您使用的是 ClickHouse Cloud,由于 query_log 表分布在多个节点上,需要使用 clusterAllReplicas(default, system.query_log)。而在本地运行时,只需替换为 FROM system.query_log。

ClickHouse 会为每个执行的查询记录统计数据,例如查询执行时间、读取的行数以及资源使用情况,包括 CPU、内存使用量和文件系统缓存命中率。

查询日志是定位慢查询的理想起点。通过这些日志,您可以快速找到运行时间较长的查询,并查看它们的资源消耗情况。

长时间运行查询分析

让我们筛选 NYC Taxi 数据集中运行时间最长的前五个查询。  

-- Find top 5 long running queries from nyc_taxi database in the last 1 hour
SELECT
    type,
    event_time,
    query_duration_ms,
    query,
    read_rows,
    tables
FROM clusterAllReplicas(default, system.query_log)
WHERE has(databases, 'nyc_taxi') AND (event_time >= (now() - toIntervalMinute(60))) AND type='QueryFinish'
ORDER BY query_duration_ms DESC
LIMIT 5
FORMAT VERTICAL

Query id: e3d48c9f-32bb-49a4-8303-080f59ed1835

Row 1:
──────
type:              QueryFinish
event_time:        2024-11-27 11:12:36
query_duration_ms: 2967
query:             WITH
  dateDiff('s', pickup_datetime, dropoff_datetime) as trip_time,
  trip_distance / trip_time * 3600 AS speed_mph
SELECT
  quantiles(0.5, 0.75, 0.9, 0.99)(trip_distance)
FROM
  nyc_taxi.trips_small_inferred
WHERE
  speed_mph > 30
FORMAT JSON
read_rows:         329044175
tables:            ['nyc_taxi.trips_small_inferred']

Row 2:
──────
type:              QueryFinish
event_time:        2024-11-27 11:11:33
query_duration_ms: 2026
query:             SELECT 
    payment_type,
    COUNT() AS trip_count,
    formatReadableQuantity(SUM(trip_distance)) AS total_distance,
    AVG(total_amount) AS total_amount_avg,
    AVG(tip_amount) AS tip_amount_avg
FROM 
    nyc_taxi.trips_small_inferred
WHERE 
    pickup_datetime >= '2009-01-01' AND pickup_datetime < '2009-04-01'
GROUP BY 
    payment_type
ORDER BY 
    trip_count DESC;

read_rows:         329044175
tables:            ['nyc_taxi.trips_small_inferred']

Row 3:
──────
type:              QueryFinish
event_time:        2024-11-27 11:12:17
query_duration_ms: 1860
query:             SELECT
  avg(dateDiff('s', pickup_datetime, dropoff_datetime))
FROM nyc_taxi.trips_small_inferred
WHERE passenger_count = 1 or passenger_count = 2
FORMAT JSON
read_rows:         329044175
tables:            ['nyc_taxi.trips_small_inferred']

Row 4:
──────
type:              QueryFinish
event_time:        2024-11-27 11:12:31
query_duration_ms: 690
query:             SELECT avg(total_amount) FROM nyc_taxi.trips_small_inferred WHERE trip_distance > 5
FORMAT JSON
read_rows:         329044175
tables:            ['nyc_taxi.trips_small_inferred']

Row 5:
──────
type:              QueryFinish
event_time:        2024-11-27 11:12:44
query_duration_ms: 634
query:             SELECT
vendor_id,
avg(total_amount),
avg(trip_distance),
FROM
nyc_taxi.trips_small_inferred
GROUP BY vendor_id
ORDER BY 1 DESC
FORMAT JSON
read_rows:         329044175
tables:            ['nyc_taxi.trips_small_inferred']

通过字段 query_duration_ms,我们可以看到第一个查询的执行时间为 2967 毫秒,显然有优化的空间。  

除此之外,您还可以检查内存和 CPU 消耗最多的查询,以找到对系统负载最大的操作。

-- Top queries by memory usage 
SELECT
    type,
    event_time,
    query_id,
    formatReadableSize(memory_usage) AS memory,
    ProfileEvents.Values[indexOf(ProfileEvents.Names, 'UserTimeMicroseconds')] AS userCPU,
    ProfileEvents.Values[indexOf(ProfileEvents.Names, 'SystemTimeMicroseconds')] AS systemCPU,
    (ProfileEvents['CachedReadBufferReadFromCacheMicroseconds']) / 1000000 AS FromCacheSeconds,
    (ProfileEvents['CachedReadBufferReadFromSourceMicroseconds']) / 1000000 AS FromSourceSeconds,
    normalized_query_hash
FROM clusterAllReplicas(default, system.query_log)
WHERE has(databases, 'nyc_taxi') AND (type='QueryFinish') AND ((event_time >= (now() - toIntervalDay(2))) AND (event_time <= now())) AND (user NOT ILIKE '%internal%')
ORDER BY memory_usage DESC
LIMIT 30

接下来,我们将定位这些慢查询并多次重新运行,以便更好地了解它们的响应时间。

同时,为了确保结果的可重复性,我们需要将 enable_filesystem_cache 设置为 0,关闭文件系统缓存。

-- Disable filesystem cache
set enable_filesystem_cache = 0;

-- Run query 1
WITH
  dateDiff('s', pickup_datetime, dropoff_datetime) as trip_time,
  trip_distance / trip_time * 3600 AS speed_mph
SELECT
  quantiles(0.5, 0.75, 0.9, 0.99)(trip_distance)
FROM
  nyc_taxi.trips_small_inferred
WHERE
  speed_mph > 30
FORMAT JSON

----
1 row in set. Elapsed: 1.699 sec. Processed 329.04 million rows, 8.88 GB (193.72 million rows/s., 5.23 GB/s.)
Peak memory usage: 440.24 MiB.

-- Run query 2
SELECT 
    payment_type,
    COUNT() AS trip_count,
    formatReadableQuantity(SUM(trip_distance)) AS total_distance,
    AVG(total_amount) AS total_amount_avg,
    AVG(tip_amount) AS tip_amount_avg
FROM 
    nyc_taxi.trips_small_inferred
WHERE 
    pickup_datetime >= '2009-01-01' AND pickup_datetime < '2009-04-01'
GROUP BY 
    payment_type
ORDER BY 
    trip_count DESC;

--- 
4 rows in set. Elapsed: 1.419 sec. Processed 329.04 million rows, 5.72 GB (231.86 million rows/s., 4.03 GB/s.)
Peak memory usage: 546.75 MiB.

-- Run query 3
SELECT
  avg(dateDiff('s', pickup_datetime, dropoff_datetime))
FROM nyc_taxi.trips_small_inferred
WHERE passenger_count = 1 or passenger_count = 2
FORMAT JSON

---
1 row in set. Elapsed: 1.414 sec. Processed 329.04 million rows, 8.88 GB (232.63 million rows/s., 6.28 GB/s.)
Peak memory usage: 451.53 MiB.

查询性能与资源分析 

Name

ElapsedRows processedPeak memory
Query 11.699 sec329.04 million440.24 MiB
Query 21.419 sec329.04 million546.75 MiB
Query 31.414 sec329.04 million451.53 MiB

通过日志分析,我们发现:

1. 数据表包含 3.2904 亿行记录,每个查询都对表进行了全表扫描。

2. 查询 1:计算平均速度超过 30 英里/小时行程的距离分布。

3. 查询 2:统计每周行程数量和平均费用。

4. 查询 3:计算每次行程的平均时间。

尽管这些查询本身并不复杂,但第一个查询需要动态计算行程时间,这使其负载较高。每个查询的执行时间都超过 1 秒,而在 ClickHouse 环境中,这已经属于较长时间。同时,每个查询大约消耗 400 MB 内存,并且读取了相同数量的行数(3.2904 亿行)。接下来,我们将快速确认表的实际行数。

-- Count number of rows in table 
SELECT count()
FROM nyc_taxi.trips_small_inferred

Query id: 733372c5-deaf-4719-94e3-261540933b23

   ┌───count()─┐
1. │ 329044175 │ -- 329.04 million
   └───────────┘

该表共有 3.29 亿行,因此每次查询都会对表进行全表扫描。

此外,ClickHouse Cloud 提供了一个名为 Query insight 的功能丰富的 UI,可以通过各种可视化和表格展示查询日志。

图片

深入分析慢查询:EXPLAIN 工具的使用

在分析慢查询时,ClickHouse 提供了一项强大的工具——EXPLAIN 语句。它可以详细展示查询执行的每个阶段,而无需实际运行查询。这对于深入了解查询的执行逻辑非常重要。虽然这个工具的输出可能显得复杂,但只需掌握一些关键命令,就可以快速定位性能瓶颈。

接下来,我们将重点介绍两个常用的 EXPLAIN 命令,并通过案例展示如何分析查询。

EXPLAIN indexes = 1:查询计划分析

使用 EXPLAIN indexes = 1 命令可以检查查询计划。查询计划以树状结构展示查询的执行顺序,帮助我们直观了解 ClickHouse 如何处理每个子句。需要注意的是,查询计划的输出应从下往上阅读。

现在,我们尝试运行第一个长时间运行的查询。

EXPLAIN indexes = 1
WITH
    dateDiff('s', pickup_datetime, dropoff_datetime) AS trip_time,
    (trip_distance / trip_time) * 3600 AS speed_mph
SELECT quantiles(0.5, 0.75, 0.9, 0.99)(trip_distance)
FROM nyc_taxi.trips_small_inferred
WHERE speed_mph > 30

Query id: f35c412a-edda-4089-914b-fa1622d69868

   ┌─explain─────────────────────────────────────────────┐
1. │ Expression ((Projection + Before ORDER BY))         │
2. │   Aggregating                                       │
3. │     Expression (Before GROUP BY)                    │
4. │       Filter (WHERE)                                │
5. │         ReadFromMergeTree (nyc_taxi.trips_small_inferred) │
   └─────────────────────────────────────────────────────┘

以我们发现的其中一个长时间运行的查询为例,使用该命令后,我们可以清楚地看到:  

1. 查询从表 nyc_taxi.trips_small_inferred 读取数据。  

2. 然后应用 WHERE 子句,过滤符合条件的行。  

3. 接着对过滤后的数据进行聚合操作并计算分位数。  

4. 最后,对结果进行排序并输出。

我们还注意到,由于创建表时未定义主键,ClickHouse 只能对整张表进行全表扫描,这也是查询较慢的原因之一。

EXPLAIN Pipeline:执行策略分析

除了查询计划,`EXPLAIN Pipeline` 提供了查询的具体执行策略。通过该命令,我们可以深入了解查询实际的执行方式。以同一查询为例,Pipeline 输出显示:  

EXPLAIN PIPELINE
WITH
    dateDiff('s', pickup_datetime, dropoff_datetime) AS trip_time,
    (trip_distance / trip_time) * 3600 AS speed_mph
SELECT quantiles(0.5, 0.75, 0.9, 0.99)(trip_distance)
FROM nyc_taxi.trips_small_inferred
WHERE speed_mph > 30

Query id: c7e11e7b-d970-4e35-936c-ecfc24e3b879

    ┌─explain─────────────────────────────────────────────────────────────────────────────┐
 1. │ (Expression)                                                                        │
 2. │ ExpressionTransform × 59                                                            │
 3. │   (Aggregating)                                                                     │
 4. │   Resize 59 → 59                                                                    │
 5. │     AggregatingTransform × 59                                                       │
 6. │       StrictResize 59 → 59                                                          │
 7. │         (Expression)                                                                │
 8. │         ExpressionTransform × 59                                                    │
 9. │           (Filter)                                                                  │
10. │           FilterTransform × 59                                                      │
11. │             (ReadFromMergeTree)                                                     │
12. │             MergeTreeSelect(pool: PrefetchedReadPool, algorithm: Thread) × 59 0 → 1 │

查询使用了 59 个线程执行,这表明 ClickHouse 进行了高度并行化处理。  并行线程的增加显著缩短了查询时间,但同时也导致内存使用量较高。  

通过分析这些信息,我们能够更好地理解查询的资源消耗及性能瓶颈。

优化建议

对于每一个慢查询,您都可以采用类似的方法,通过查询日志和 EXPLAIN 工具,了解查询读取的行数、资源消耗及执行过程。这样,您可以找出不必要的复杂查询计划,优化查询性能,为后续操作奠定基础。

优化查询的方法与实践

在生产环境中定位问题查询可能是一项挑战,因为 ClickHouse 部署中的任何时刻都可能有大量查询在执行。

如果您已经知道某个用户、数据库或表存在性能问题,可以利用system.query_log 表中的字段 user、tables 或 databases 来缩小搜索范围,快速锁定目标。

一旦确定了需要优化的查询,接下来的重点是以结构化的方式逐步优化。然而,很多开发人员在这个阶段常犯的一个错误是一次性修改多个参数,随意测试结果。这种方法通常会产生混杂的效果,更重要的是,难以明确究竟是哪些改动提升了查询性能。

查询优化需要一个清晰的流程。您不需要复杂的基准测试,但一个简单的、有条理的优化方法可以帮助您更好地理解修改对性能的具体影响。

逐步优化的流程

识别慢查询:从查询日志入手,定位运行时间较长的查询。  

逐一分析改进措施:对潜在的优化点进行隔离测试。在测试过程中,确保禁用文件系统缓存 (enable_filesystem_cache=0)。  

ClickHouse 在多个阶段使用缓存来加速查询性能。虽然这对实际运行很有帮助,但在排查性能问题时,缓存可能会掩盖潜在的 I/O 瓶颈或表设计缺陷。因此,关闭缓存能够更真实地反映查询性能。

逐步实施优化:对于识别出的优化措施,建议一次只应用一个更改,以便清晰地跟踪每次调整对查询性能的具体影响。

下面的图表展示了这个通用优化方法的关键步骤:

图片

注意异常查询

在排查慢查询时,还需要小心偶然的异常情况。例如,某些查询可能因用户临时执行了代价高昂的查询而变慢,或者由于系统其他部分的压力导致性能下降。为此,您可以使用字段 normalized_query_hash 对查询分组,识别那些经常执行且代价昂贵的查询。这些查询通常是最值得深入优化的目标。

从基础入手优化 ClickHouse 查询

现在,我们已经搭建好了测试框架,可以开始实际的优化工作。

从存储方式着手

查询优化的第一步是检查数据的存储方式。对于任何数据库而言,读取的数据越少,查询速度就越快。

在数据导入时,您可能使用了 ClickHouse 自动推断表模式的功能。这种方法适合快速启动,但如果您希望进一步优化查询性能,就需要根据实际场景重新设计数据模式,以更好地适配您的需求。

减少 Nullable 列的使用

根据最佳实践,尽量避免使用 Nullable 列。虽然 Nullable 列使数据导入更灵活,但会增加额外的性能开销,因为需要处理额外的列。

运行一个简单的 SQL 查询,统计哪些列存在 NULL 值,可以快速识别哪些列真正需要使用 Nullable 类型。

-- Find non-null values columns 
SELECT
    countIf(vendor_id IS NULL) AS vendor_id_nulls,
    countIf(pickup_datetime IS NULL) AS pickup_datetime_nulls,
    countIf(dropoff_datetime IS NULL) AS dropoff_datetime_nulls,
    countIf(passenger_count IS NULL) AS passenger_count_nulls,
    countIf(trip_distance IS NULL) AS trip_distance_nulls,
    countIf(fare_amount IS NULL) AS fare_amount_nulls,
    countIf(mta_tax IS NULL) AS mta_tax_nulls,
    countIf(tip_amount IS NULL) AS tip_amount_nulls,
    countIf(tolls_amount IS NULL) AS tolls_amount_nulls,
    countIf(total_amount IS NULL) AS total_amount_nulls,
    countIf(payment_type IS NULL) AS payment_type_nulls,
    countIf(pickup_location_id IS NULL) AS pickup_location_id_nulls,
    countIf(dropoff_location_id IS NULL) AS dropoff_location_id_nulls
FROM trips_small_inferred
FORMAT VERTICAL

Query id: 4a70fc5b-2501-41c8-813c-45ce241d85ae

Row 1:
──────
vendor_id_nulls:           0
pickup_datetime_nulls:     0
dropoff_datetime_nulls:    0
passenger_count_nulls:     0
trip_distance_nulls:       0
fare_amount_nulls:         0
mta_tax_nulls:             137946731
tip_amount_nulls:          0
tolls_amount_nulls:        0
total_amount_nulls:        0
payment_type_nulls:        69305
pickup_location_id_nulls:  0
dropoff_location_id_nulls: 0

在我们的数据集中,仅有 mta_tax 和 payment_type 两列包含 NULL 值,其余字段可以移除 Nullable 类型。

利用 LowCardinality 提升性能

对于字符串类型数据,LowCardinality 是一个极具价值的优化工具。ClickHouse 使用字典编码处理 LowCardinality 列,大幅提升查询效率。

判断哪些列适合 LowCardinality 的一个简单经验是:如果某列的唯一值少于 10,000 个,就非常适合使用该类型。

您可以使用以下 SQL 查询来查找唯一值较少的列。

-- Identify low cardinality columns
SELECT
    uniq(ratecode_id),
    uniq(pickup_location_id),
    uniq(dropoff_location_id),
    uniq(vendor_id)
FROM trips_small_inferred
FORMAT VERTICAL

Query id: d502c6a1-c9bc-4415-9d86-5de74dd6d932

Row 1:
──────
uniq(ratecode_id):         6
uniq(pickup_location_id):  260
uniq(dropoff_location_id): 260
uniq(vendor_id):           3

在我们的数据集中,ratecode_id、pickup_location_id、dropoff_location_id 和 vendor_id 是理想的 LowCardinality 列。

选择合适的数据类型

ClickHouse 支持多种数据类型。在设计表模式时,选择最小的数据类型可以优化查询性能并减少磁盘存储空间。  

数字类型:检查数据的最小值和最大值,确保当前的数据精度符合实际需求。  

-- Find min/max values for the payment_type field
SELECT
    min(payment_type),max(payment_type),
    min(passenger_count), max(passenger_count)
FROM trips_small_inferred

Query id: 4306a8e1-2a9c-4b06-97b4-4d902d2233eb

   ┌─min(payment_type)─┬─max(payment_type)─┐
1. │                 1 │                 4 │
   └───────────────────┴───────────────────┘

日期类型:根据数据集和查询需求,选择适合的日期精度,避免不必要的存储开销。

应用优化并评估效果

我们通过重新设计表模式,创建一个优化后的新表并重新导入数据。

-- Create table with optimized data 
CREATE TABLE trips_small_no_pk
(
    `vendor_id` LowCardinality(String),
    `pickup_datetime` DateTime,
    `dropoff_datetime` DateTime,
    `passenger_count` UInt8,
    `trip_distance` Float32,
    `ratecode_id` LowCardinality(String),
    `pickup_location_id` LowCardinality(String),
    `dropoff_location_id` LowCardinality(String),
    `payment_type` Nullable(UInt8),
    `fare_amount` Decimal32(2),
    `extra` Decimal32(2),
    `mta_tax` Nullable(Decimal32(2)),
    `tip_amount` Decimal32(2),
    `tolls_amount` Decimal32(2),
    `total_amount` Decimal32(2)
)
ORDER BY tuple();

-- Insert the data 
INSERT INTO trips_small_no_pk SELECT * FROM trips_small_inferred

我们使用新表重新运行查询,以评估改进效果。

NameRun 1 - ElapsedElapsedRows processedPeak memory
Query 11.699 sec1.353 sec329.04 million337.12 MiB
Query 21.419 sec1.171 sec329.04 million531.09 MiB
Query 31.414 sec1.188 sec329.04 million265.05 MiB

重新运行查询后,我们观察到查询时间和内存使用均有显著改进。优化后的模式减少了存储的数据量,从而降低了内存开销和查询处理时间。

存储空间的显著缩减

让我们比较一下优化前后表的大小:  

SELECT
    `table`,
    formatReadableSize(sum(data_compressed_bytes) AS size) AS compressed,
    formatReadableSize(sum(data_uncompressed_bytes) AS usize) AS uncompressed,
    sum(rows) AS rows
FROM system.parts
WHERE (active = 1) AND ((`table` = 'trips_small_no_pk') OR (`table` = 'trips_small_inferred'))
GROUP BY
    database,
    `table`
ORDER BY size DESC

Query id: 72b5eb1c-ff33-4fdb-9d29-dd076ac6f532

   ┌─table────────────────┬─compressed─┬─uncompressed─┬──────rows─┐
1. │ trips_small_inferred │ 7.38 GiB   │ 37.41 GiB    │ 329044175 │
2. │ trips_small_no_pk    │ 4.89 GiB   │ 15.31 GiB    │ 329044175 │
   └──────────────────────┴────────────┴──────────────┴───────────┘

新表的磁盘占用减少了约 34%(从 7.38 GiB 降至 4.89 GiB)。这一优化不仅节省了存储空间,还对查询性能的提升起到了关键作用。

理解 ClickHouse 主键的独特性

在大多数传统数据库中,主键的主要作用是强制数据的唯一性和完整性。任何试图插入重复主键值的操作都会被拒绝,并且通常会创建基于 B 树或哈希的索引来加速查找操作。

然而,在 ClickHouse 中,主键的作用完全不同。它并不负责确保数据的唯一性或完整性,而是专为优化查询性能而设计。主键定义了数据在磁盘上的存储顺序,并通过稀疏索引实现,该索引记录了指向每个 granule 第一行的指针。

什么是 granule?

在 ClickHouse 中,granule 是查询执行时读取的最小数据单元。一个 granule 包含固定数量的行,其行数由 index_granularity 参数决定,默认值为 8192 行。granule 数据按主键排序并连续存储在磁盘上。

为何主键选择至关重要?

为表选择一组合适的主键对性能优化至关重要。常见的实践是,将相同的数据存储在不同的表中,为每张表设计不同的主键,以加速特定的查询场景。例如,针对时间范围的查询可以使用时间戳作为主键,而针对地理位置的查询则可以选择地理位置字段作为主键。

其他可选方案

ClickHouse 提供了一些额外的功能,例如投影 (Projection) 和物化视图 (Materialized View),可以让您在同一数据集上使用不同的主键,以满足多种查询需求。本系列博客的第二部分将深入探讨这些高级优化技术,敬请期待。

如何通过主键优化查询性能

选择适合的主键是优化 ClickHouse 查询性能的关键一步。尽管这一过程可能涉及权衡和多次实验,但遵循一些基本原则可以帮助您快速入门:  

优先选择常用于过滤条件的字段:查询中频繁用作筛选的字段是主键的首选。  

从低基数字段入手:低基数的列(如值较少的字段)通常能显著提升查询效率。  

引入时间字段:时间戳字段是许多数据集中的核心过滤条件,在主键中加入它们可以极大优化基于时间的查询。

在我们的案例中,我们尝试使用以下主键组合:passenger_count、pickup_datetime 和 dropoff_datetime。  

 passenger_count 只有 24 个唯一值,基数很低,并且在慢查询中经常使用。  pickup_datetime 和 dropoff_datetime 是时间戳字段,可以作为常见的过滤条件。

我们基于新的主键组合创建了一张表,并重新导入数据。

CREATE TABLE trips_small_pk
(
    `vendor_id` UInt8,
    `pickup_datetime` DateTime,
    `dropoff_datetime` DateTime,
    `passenger_count` UInt8,
    `trip_distance` Float32,
    `ratecode_id` LowCardinality(String),
    `pickup_location_id` UInt16,
    `dropoff_location_id` UInt16,
    `payment_type` Nullable(UInt8),
    `fare_amount` Decimal32(2),
    `extra` Decimal32(2),
    `mta_tax` Nullable(Decimal32(2)),
    `tip_amount` Decimal32(2),
    `tolls_amount` Decimal32(2),
    `total_amount` Decimal32(2)
)
PRIMARY KEY (passenger_count, pickup_datetime, dropoff_datetime);

-- Insert the data 
INSERT INTO trips_small_pk SELECT * FROM trips_small_inferred

性能优化效果

重新运行查询后,我们对比了三次实验的结果,观察查询耗时、处理行数和内存使用的变化。 

Query 1
Run 1Run 2Run 3
Elapsed1.699 sec1.353 sec0.765 sec
Rows processed329.04 million329.04 million329.04 million
Peak memory440.24 MiB337.12 MiB444.19 MiB
Query 2
Run 1Run 2Run 3
Elapsed1.419 sec1.171 sec0.248 sec
Rows processed329.04 million329.04 million41.46 million
Peak memory546.75 MiB531.09 MiB173.50 MiB
Query 3
Run 1Run 2Run 3
Elapsed1.414 sec1.188 sec0.431 sec
Rows processed329.04 million329.04 million276.99 million
Peak memory451.53 MiB265.05 MiB197.38 MiB

实验结果显示,执行时间和内存消耗方面都有显著改善。  

查询 2 从主键优化中获益最大。主键的引入让查询计划发生了明显变化。

EXPLAIN indexes = 1
SELECT
    payment_type,
    COUNT() AS trip_count,
    formatReadableQuantity(SUM(trip_distance)) AS total_distance,
    AVG(total_amount) AS total_amount_avg,
    AVG(tip_amount) AS tip_amount_avg
FROM nyc_taxi.trips_small_pk
WHERE (pickup_datetime >= '2009-01-01') AND (pickup_datetime < '2009-04-01')
GROUP BY payment_type
ORDER BY trip_count DESC

Query id: 30116a77-ba86-4e9f-a9a2-a01670ad2e15

    ┌─explain──────────────────────────────────────────────────────────────────────────────────────────────────────────┐
 1. │ Expression ((Projection + Before ORDER BY [lifted up part]))                                                     │
 2. │   Sorting (Sorting for ORDER BY)                                                                                 │
 3. │     Expression (Before ORDER BY)                                                                                 │
 4. │       Aggregating                                                                                                │
 5. │         Expression (Before GROUP BY)                                                                             │
 6. │           Expression                                                                                             │
 7. │             ReadFromMergeTree (nyc_taxi.trips_small_pk)                                                          │
 8. │             Indexes:                                                                                             │
 9. │               PrimaryKey                                                                                         │
10. │                 Keys:                                                                                            │
11. │                   pickup_datetime                                                                                │
12. │                 Condition: and((pickup_datetime in (-Inf, 1238543999]), (pickup_datetime in [1230768000, +Inf))) │
13. │                 Parts: 9/9                                                                                       │
14. │                 Granules: 5061/40167                                                                             │
    └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

  

仅一部分 granule 被选中供查询处理,而非全表扫描。由于 ClickHouse 需要处理的数据量大幅减少,查询性能得到了显著提升。

总结与展望

ClickHouse 是一款极具性能优势的分析型数据库,其核心设计中包含丰富的优化机制。但要真正释放其性能潜力,了解数据库的工作方式以及如何设计最佳数据模式至关重要。

通过本文的内容,您可以学会如何识别低效查询,并通过对数据模式进行简单却高效的优化,获得显著的性能提升。这是初学者优化 ClickHouse 的理想起点。

如果您已经是一名资深用户,可能对本文讨论的内容感到熟悉。在下一篇博客中,我们将深入探索更高级的优化技术,例如投影 (Projection)、物化视图 (Materialized Views) 和跳过索引 (Data Skipping Index)。敬请关注!

征稿启示

面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值