跳到主要内容
跳到主要内容

表分区

在 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 不会创建一个包含所有插入行的单一数据部分(如 这里所述),而是为插入行中每个唯一的分区键值创建一个新的数据部分:

INSERT PROCESSING

ClickHouse 服务器首先根据分区键值 toStartOfMonth(date) 将插入的示例行(如上图中草图所示的 4 行)进行拆分。 然后,对于每个已识别的分区,这些行会按 常规 进行处理,执行几个顺序步骤(① 排序,② 拆分成列,③ 压缩,④ 写入磁盘)。

请注意,启用分区后,ClickHouse 会自动为每个数据部分创建 MinMax 索引。这些索引是用于分区键表达式的每个表列的文件,包含该列在数据部分内的最小值和最大值。

每个分区的合并

启用分区后,ClickHouse 只会在分区内 合并 数据部分,而不会跨分区合并。我们为上面的示例表进行草图:

PART MERGES

如上图所示,属于不同分区的部分是不会合并的。如果选择了高基数的分区键,那么分散在数千个分区中的部分将永远不会成为合并候选——超过预配置的限制并导致让人头疼的 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 通过应用一系列修剪技术来处理该查询,以避免评估不相关的数据:

PART MERGES 2

分区修剪:使用 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.