在 ClickHouse 中,表分区是什么?
分区将表的 数据部分 组织成有序的逻辑单元,这些单元在 MergeTree 引擎系列 中是有概念意义的,符合特定标准,例如时间范围、类别或其他关键属性。这些逻辑单元使得数据更容易管理、查询和优化。
按分区
在通过 PARTITION BY 子句 初始定义表时,可以启用分区。该子句可以包含任何列的 SQL 表达式,其结果将定义行所属的分区。
为了说明这一点,我们 增强 查询示例表,在其中添加 PARTITION BY toStartOfMonth(date)
子句,这将基于房产销售的月份组织表的数据部分:
CREATE TABLE uk.uk_price_paid_simple_partitioned
(
date Date,
town LowCardinality(String),
street LowCardinality(String),
price UInt32
)
ENGINE = MergeTree
ORDER BY (town, street)
PARTITION BY toStartOfMonth(date);
你可以在我们的 ClickHouse SQL Playground 中 查询该表。
磁盘上的结构
每当一组行插入到表中时,ClickHouse 不会创建一个包含所有插入行的单一数据部分(如 这里所述),而是为插入行中每个唯一的分区键值创建一个新的数据部分:
ClickHouse 服务器首先根据分区键值 toStartOfMonth(date)
将插入的示例行(如上图中草图所示的 4 行)进行拆分。
然后,对于每个已识别的分区,这些行会按 常规 进行处理,执行几个顺序步骤(① 排序,② 拆分成列,③ 压缩,④ 写入磁盘)。
请注意,启用分区后,ClickHouse 会自动为每个数据部分创建 MinMax 索引。这些索引是用于分区键表达式的每个表列的文件,包含该列在数据部分内的最小值和最大值。
每个分区的合并
启用分区后,ClickHouse 只会在分区内 合并 数据部分,而不会跨分区合并。我们为上面的示例表进行草图:
如上图所示,属于不同分区的部分是不会合并的。如果选择了高基数的分区键,那么分散在数千个分区中的部分将永远不会成为合并候选——超过预配置的限制并导致让人头疼的 Too many ^^parts^^
错误。解决这个问题很简单:选择一个基数在 1000 到 10000 之间的合理分区键。
监控分区
你可以通过使用虚拟列 /_partition_value 来 查询 我们示例表中所有现有唯一分区的列表:
SELECT DISTINCT _partition_value AS partition
FROM uk.uk_price_paid_simple_partitioned
ORDER BY partition ASC;
另外,ClickHouse 在 system.parts 系统表中跟踪所有表的所有部分和分区,以下查询 返回 我们上面的示例表中所有分区的列表,此外还有当前活动部分的数量和每个分区的这些部分中的行总数:
SELECT
partition,
count() AS parts,
sum(rows) AS rows
FROM system.parts
WHERE (database = 'uk') AND (`table` = 'uk_price_paid_simple_partitioned') AND active
GROUP BY partition
ORDER BY partition ASC;
表分区的用途是什么?
数据管理
在 ClickHouse 中,分区主要是一种数据管理功能。通过根据分区表达式逻辑组织数据,每个分区可以独立管理。例如,上述示例表中的分区方案能够实现只在主表中保留过去 12 个月的数据,通过自动删除旧数据来实现 TTL 规则(见已添加的 DDL 语句的最后一行):
CREATE TABLE uk.uk_price_paid_simple_partitioned
(
date Date,
town LowCardinality(String),
street LowCardinality(String),
price UInt32
)
ENGINE = MergeTree
PARTITION BY toStartOfMonth(date)
ORDER BY (town, street)
TTL date + INTERVAL 12 MONTH DELETE;
由于表是通过 toStartOfMonth(date)
分区的,满足 TTL 条件的整个分区(表部分 的集合)将被删除,从而使得清理操作更高效,而无需重写部分。
类似地,不必删除旧数据,它可以被自动和高效地移动到更具成本效益的 存储层级:
CREATE TABLE uk.uk_price_paid_simple_partitioned
(
date Date,
town LowCardinality(String),
street LowCardinality(String),
price UInt32
)
ENGINE = MergeTree
PARTITION BY toStartOfMonth(date)
ORDER BY (town, street)
TTL date + INTERVAL 12 MONTH TO VOLUME 'slow_but_cheap';
查询优化
分区可以帮助查询性能,但这在很大程度上依赖于访问模式。如果查询只针对少数几个分区(理想情况下是一个),性能可能会有所提高。通常只有在分区键不在主键中,并且你按它进行过滤时,这才是有用的,如以下示例查询所示。
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple_partitioned
WHERE date >= '2020-12-01'
AND date <= '2020-12-31'
AND town = 'LONDON';
该查询在我们上面的示例表上运行,并 计算 2020 年 12 月在伦敦售出的所有房产的最高价格,通过在同时使用表的分区键中的列(date
)和表的主键中使用的列(town
)进行过滤(并且 date
不是主键的一部分)。
ClickHouse 通过应用一系列修剪技术来处理该查询,以避免评估不相关的数据:
① 分区修剪:使用 MinMax 索引 来忽略逻辑上无法与查询的列过滤条件相匹配的整个分区(部分集合)。
② 颗粒修剪:在第 ① 步骤后的剩余数据部分中,使用它们的 主索引 来忽略所有逻辑上无法与查询的主键列过滤条件相匹配的 颗粒(行块)。
我们可以通过 检查 我们上面的示例查询的物理查询执行计划,通过 EXPLAIN 子句:
EXPLAIN indexes = 1
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple_partitioned
WHERE date >= '2020-12-01'
AND date <= '2020-12-31'
AND town = 'LONDON';
┌─explain──────────────────────────────────────────────────────────────────────────────────────────────────────┐
1. │ Expression ((Project names + Projection)) │
2. │ Aggregating │
3. │ Expression (Before GROUP BY) │
4. │ Expression │
5. │ ReadFromMergeTree (uk.uk_price_paid_simple_partitioned) │
6. │ Indexes: │
7. │ MinMax │
8. │ Keys: │
9. │ date │
10. │ Condition: and((date in (-Inf, 18627]), (date in [18597, +Inf))) │
11. │ Parts: 1/436 │
12. │ Granules: 11/3257 │
13. │ Partition │
14. │ Keys: │
15. │ toStartOfMonth(date) │
16. │ Condition: and((toStartOfMonth(date) in (-Inf, 18597]), (toStartOfMonth(date) in [18597, +Inf))) │
17. │ Parts: 1/1 │
18. │ Granules: 11/11 │
19. │ PrimaryKey │
20. │ Keys: │
21. │ town │
22. │ Condition: (town in ['LONDON', 'LONDON']) │
23. │ Parts: 1/1 │
24. │ Granules: 1/11 │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
上述输出显示:
① 分区修剪:EXPLAIN 输出的第 7 到 18 行显示,ClickHouse 首先使用 date
字段的 MinMax 索引 来识别 3257 个现有 颗粒 中的 11 个(行块),这些颗粒存储在 436 个现有的活跃数据部分中,包含与查询的 date
过滤条件匹配的行。
② 颗粒修剪:EXPLAIN 输出的第 19 到 24 行指示,ClickHouse 然后使用步骤 ① 中标识的数据部分的 主索引(在 town
字段上创建)进一步减少颗粒的数量(包含可能也与查询的 town
过滤条件相匹配的行)从 11 个减少到 1 个。这也在我们上面为查询运行打印的 ClickHouse 客户端输出中反映出来:
... Elapsed: 0.006 sec. Processed 8.19 thousand rows, 57.34 KB (1.36 million rows/s., 9.49 MB/s.)
Peak memory usage: 2.73 MiB.
这意味着 ClickHouse 在 6 毫秒内扫描并处理了 1 个颗粒(8192 行),以计算查询结果。
分区主要是一种数据管理功能
请注意,跨所有分区的查询通常比在非分区表上运行相同的查询要慢。
通过分区,数据通常在更多的数据部分之间分布,这通常会导致 ClickHouse 扫描和处理更大数量的数据。
我们可以通过同时在 What are table parts 示例表(未启用分区)和我们上述示例表(启用分区)上运行相同的查询来演示这一点。这两个表 包含 相同的数据和行数:
SELECT
table,
sum(rows) AS rows
FROM system.parts
WHERE (database = 'uk') AND (table IN ['uk_price_paid_simple', 'uk_price_paid_simple_partitioned']) AND active
GROUP BY table;
但是,启用分区的表 具有 更多的活动 数据部分,因为,如上所述,ClickHouse 只能 合并 属于分区内的数据部分,而不能跨分区合并:
SELECT
table,
count() AS parts
FROM system.parts
WHERE (database = 'uk') AND (table IN ['uk_price_paid_simple', 'uk_price_paid_simple_partitioned']) AND active
GROUP BY table;
如上文所示,分区表 uk_price_paid_simple_partitioned
有超过 600 个分区,因此有 600 306 个活跃数据部分。而我们的非分区表 uk_price_paid_simple
所有 初始 数据部分都可以通过后台合并合并为一个活跃部分。
当我们 检查 我们从上面示例查询的物理查询执行计划,使用 EXPLAIN 子句,运行时没有分区过滤在分区表上,我们可以看到,在输出的第 19 和 20 行,ClickHouse 识别出 671 个现有的 颗粒(行块),分散在 436 个现有的活跃数据部分中,这些部分可能包含与查询过滤条件匹配的行,因此将被查询引擎扫描和处理:
EXPLAIN indexes = 1
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple_partitioned
WHERE town = 'LONDON';
┌─explain─────────────────────────────────────────────────────────┐
1. │ Expression ((Project names + Projection)) │
2. │ Aggregating │
3. │ Expression (Before GROUP BY) │
4. │ Expression │
5. │ ReadFromMergeTree (uk.uk_price_paid_simple_partitioned) │
6. │ Indexes: │
7. │ MinMax │
8. │ Condition: true │
9. │ Parts: 436/436 │
10. │ Granules: 3257/3257 │
11. │ Partition │
12. │ Condition: true │
13. │ Parts: 436/436 │
14. │ Granules: 3257/3257 │
15. │ PrimaryKey │
16. │ Keys: │
17. │ town │
18. │ Condition: (town in ['LONDON', 'LONDON']) │
19. │ Parts: 431/436 │
20. │ Granules: 671/3257 │
└─────────────────────────────────────────────────────────────────┘
运行在没有分区的表上的同一查询的物理查询执行计划 显示 在输出的第 11 和 12 行,ClickHouse 识别出 241 个现有的行块,存在于表的单个活跃数据部分中,这些行块可能包含与查询过滤条件匹配的行:
EXPLAIN indexes = 1
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple
WHERE town = 'LONDON';
┌─explain───────────────────────────────────────────────┐
1. │ Expression ((Project names + Projection)) │
2. │ Aggregating │
3. │ Expression (Before GROUP BY) │
4. │ Expression │
5. │ ReadFromMergeTree (uk.uk_price_paid_simple) │
6. │ Indexes: │
7. │ PrimaryKey │
8. │ Keys: │
9. │ town │
10. │ Condition: (town in ['LONDON', 'LONDON']) │
11. │ Parts: 1/1 │
12. │ Granules: 241/3083 │
└───────────────────────────────────────────────────────┘
对于 在分区版本的表上运行 查询,ClickHouse 在 90 毫秒内扫描和处理了 671 个行块(约 550 万行):
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple_partitioned
WHERE town = 'LONDON';
┌─highest_price─┐
│ 594300000 │ -- 594.30 million
└───────────────┘
1 row in set. Elapsed: 0.090 sec. Processed 5.48 million rows, 27.95 MB (60.66 million rows/s., 309.51 MB/s.)
Peak memory usage: 163.44 MiB.
而对于 在未分区表上运行 查询,ClickHouse 在 12 毫秒内扫描和处理了 241 个块(约 200 万行):
SELECT MAX(price) AS highest_price
FROM uk.uk_price_paid_simple
WHERE town = 'LONDON';
┌─highest_price─┐
│ 594300000 │ -- 594.30 million
└───────────────┘
1 row in set. Elapsed: 0.012 sec. Processed 1.97 million rows, 9.87 MB (162.23 million rows/s., 811.17 MB/s.)
Peak memory usage: 62.02 MiB.