跳至主要内容
跳至主要内容

投影

简介

ClickHouse 提供了各种机制来加速对大量数据的分析查询,以满足实时场景的需求。其中一种加速查询的机制是使用投影。投影通过按感兴趣的属性重新排序数据来帮助优化查询。这可以是

  1. 一次完整的重新排序
  2. 原始表的一个子集,具有不同的排序方式
  3. 预计算的聚合(类似于物化视图),但聚合的排序方式与之对齐。

投影是如何工作的?

从实践上讲,可以将投影视为原始表的一个额外的、隐藏的表。投影可以具有不同的行顺序,因此与原始表不同的主索引,并且可以自动且增量地预计算聚合值。因此,使用投影提供了两个“调整旋钮”来加速查询执行

  • 正确使用主索引
  • 预计算聚合

在某些方面,投影类似于 物化视图,后者也允许您拥有多个行顺序并在插入时预计算聚合。与需要显式更新的物化视图不同,投影会自动更新并与原始表保持同步。当查询针对原始表时,ClickHouse 会自动采样主键并选择一个可以生成相同正确结果的表,但所需的读取数据量最少,如图所示

更智能的存储与 _part_offset

自版本 25.5 起,ClickHouse 支持投影中的虚拟列 _part_offset,它提供了一种定义投影的新方法。

现在有两种定义投影的方法

  • 存储完整列(原始行为):投影包含完整数据,可以直接读取,在过滤器与投影的排序方式匹配时提供更快的性能。

  • 仅存储排序键 + _part_offset:投影就像一个索引。ClickHouse 使用投影的主索引定位匹配的行,但从基本表读取实际数据。这降低了存储开销,但以查询时略微增加 I/O 为代价。

上述方法也可以混合使用,将一些列存储在投影中,另一些列通过 _part_offset 间接存储。

何时使用投影?

投影对于新用户来说是一个有吸引力的功能,因为它们会在插入数据时自动维护。此外,查询可以只发送到单个表,在可能的情况下利用投影来加快响应时间。

这与物化视图相反,在物化视图中,用户必须选择适当的优化目标表或根据过滤器重写查询。这给用户应用程序带来更大的压力,并增加了客户端的复杂性。

尽管具有这些优势,但投影也存在一些固有的局限性,您应该注意并因此谨慎部署。

  • 投影不允许为源表和(隐藏)目标表使用不同的 TTL,而物化视图允许不同的 TTL。
  • 对于带有投影的表,不支持轻量级更新和删除。
  • 物化视图可以链式连接:一个物化视图的目标表可以是另一个物化视图的源表,依此类推。投影不支持此操作。
  • 投影定义不支持连接,但物化视图支持。但是,使用投影的表的查询可以自由使用连接。
  • 投影定义不支持过滤器(WHERE 子句),但物化视图支持。但是,使用投影的表的查询可以自由过滤。

我们建议在以下情况下使用投影

  • 需要对数据进行完全重新排序。虽然投影中的表达式在理论上可以使用 GROUP BY,但物化视图更有效地维护聚合。查询优化器也更有可能利用使用简单重新排序的投影,即 SELECT * ORDER BY x。您可以在此表达式中选择一个子集列以减少存储空间。
  • 用户对潜在的存储空间增加和两次写入数据的开销感到满意。测试对插入速度的影响并 评估存储开销

示例

过滤不在主键中的列

在此示例中,我们将向表中添加一个投影。我们还将查看如何使用投影来加速过滤表中不在主键中的列的查询。

对于此示例,我们将使用 sql.clickhouse.com 上提供的纽约出租车数据数据集,该数据集按 pickup_datetime 排序。

让我们编写一个简单的查询,以查找乘客小费超过 200 美元的行程 ID

SELECT
  tip_amount,
  trip_id,
  dateDiff('minutes', pickup_datetime, dropoff_datetime) AS trip_duration_min
FROM nyc_taxi.trips WHERE tip_amount > 200 AND trip_duration_min > 0
ORDER BY tip_amount, trip_id ASC

请注意,由于我们正在过滤 tip_amount,而 tip_amount 不在 ORDER BY 中,因此 ClickHouse 必须执行全表扫描。让我们加速此查询。

为了保留原始表和结果,我们将使用 INSERT INTO SELECT 创建一个新表并复制数据

CREATE TABLE nyc_taxi.trips_with_projection AS nyc_taxi.trips;
INSERT INTO nyc_taxi.trips_with_projection SELECT * FROM nyc_taxi.trips;

要添加投影,我们使用 ALTER TABLE 语句和 ADD PROJECTION 语句

ALTER TABLE nyc_taxi.trips_with_projection
ADD PROJECTION prj_tip_amount
(
    SELECT *
    ORDER BY tip_amount, dateDiff('minutes', pickup_datetime, dropoff_datetime)
)

在添加投影后,必须使用 MATERIALIZE PROJECTION 语句,以便投影中的数据根据上述查询进行物理排序和重写

ALTER TABLE nyc.trips_with_projection MATERIALIZE PROJECTION prj_tip_amount

现在我们添加了投影,让我们再次运行查询

SELECT
  tip_amount,
  trip_id,
  dateDiff('minutes', pickup_datetime, dropoff_datetime) AS trip_duration_min
FROM nyc_taxi.trips_with_projection WHERE tip_amount > 200 AND trip_duration_min > 0
ORDER BY tip_amount, trip_id ASC

请注意,我们能够大大减少查询时间,并且需要扫描的行数更少。

我们可以通过查询 system.query_log 表来确认我们的查询确实使用了我们创建的投影

SELECT query, projections 
FROM system.query_log 
WHERE query_id='<query_id>'
   ┌─query─────────────────────────────────────────────────────────────────────────┬─projections──────────────────────┐
   │ SELECT                                                                       ↴│ ['default.trips.prj_tip_amount'] │
   │↳  tip_amount,                                                                ↴│                                  │
   │↳  trip_id,                                                                   ↴│                                  │
   │↳  dateDiff('minutes', pickup_datetime, dropoff_datetime) AS trip_duration_min↴│                                  │
   │↳FROM trips WHERE tip_amount > 200 AND trip_duration_min > 0                   │                                  │
   └───────────────────────────────────────────────────────────────────────────────┴──────────────────────────────────┘

使用投影加速英国房价查询

为了演示如何使用投影来加速查询性能,让我们看一个使用真实数据集的示例。对于此示例,我们将使用我们的 英国房价 教程中的表,其中包含 3003 万行。此数据集也可用在我们的 sql.clickhouse.com 环境中。

如果您想了解如何创建表和插入数据,可以参考 “英国房价数据集” 页面。

我们可以在此数据集上运行两个简单的查询。第一个列出伦敦房价最高的县,第二个计算各县的平均价格

SELECT
  county,
  price
FROM uk.uk_price_paid
WHERE town = 'LONDON'
ORDER BY price DESC
LIMIT 3
SELECT
    county,
    avg(price)
FROM uk.uk_price_paid
GROUP BY county
ORDER BY avg(price) DESC
LIMIT 3

请注意,尽管速度很快,但由于 townprice 都不在我们的 ORDER BY 语句中,因此两个查询都发生了 3003 万行数据的全表扫描

CREATE TABLE uk.uk_price_paid
(
  ...
)
ENGINE = MergeTree
--highlight-next-line
ORDER BY (postcode1, postcode2, addr1, addr2);

让我们看看是否可以使用投影来加速此查询。

为了保留原始表和结果,我们将使用 INSERT INTO SELECT 创建一个新表并复制数据

CREATE TABLE uk.uk_price_paid_with_projections AS uk_price_paid;
INSERT INTO uk.uk_price_paid_with_projections SELECT * FROM uk.uk_price_paid;

我们创建并填充投影 prj_oby_town_price,该投影生成一个额外的(隐藏的)表,其中包含主索引,按城镇和价格排序,以优化列出特定城镇房价最高的县的查询

ALTER TABLE uk.uk_price_paid_with_projections
  (ADD PROJECTION prj_obj_town_price
  (
    SELECT *
    ORDER BY
        town,
        price
  ))
ALTER TABLE uk.uk_price_paid_with_projections
  (MATERIALIZE PROJECTION prj_obj_town_price)
SETTINGS mutations_sync = 1

mutations_sync 设置用于强制同步执行。

我们创建并填充投影 prj_gby_county – 一个额外的(隐藏的)表,该表增量地预计算所有 130 个英国县的 avg(price) 聚合值

ALTER TABLE uk.uk_price_paid_with_projections
  (ADD PROJECTION prj_gby_county
  (
    SELECT
        county,
        avg(price)
    GROUP BY county
  ))
ALTER TABLE uk.uk_price_paid_with_projections
  (MATERIALIZE PROJECTION prj_gby_county)
SETTINGS mutations_sync = 1
注意

如果在投影中使用了 GROUP BY 子句(如上述 prj_gby_county 投影中),则(隐藏的)表的底层存储引擎将变为 AggregatingMergeTree,并且所有聚合函数都将转换为 AggregateFunction。这确保了适当的增量数据聚合。

下图是主表 uk_price_paid_with_projections 及其两个投影的可视化

现在,如果我们再次运行列出伦敦房价最高的三个县的查询,我们会看到查询性能有所提高

SELECT
  county,
  price
FROM uk.uk_price_paid_with_projections
WHERE town = 'LONDON'
ORDER BY price DESC
LIMIT 3

同样,对于列出英国房价最高的三个平均价格县的查询

SELECT
    county,
    avg(price)
FROM uk.uk_price_paid_with_projections
GROUP BY county
ORDER BY avg(price) DESC
LIMIT 3

请注意,这两个查询都针对原始表,并且在创建两个投影之前,两个查询都导致了全表扫描(从磁盘流式传输了所有 3003 万行)。

此外,请注意,列出伦敦房价最高的三个县的查询正在流式传输 217 万行。当我们直接使用针对此查询优化的第二个表时,仅从磁盘流式传输了 8.19 万行。

差异的原因是,目前,上述 optimize_read_in_order 优化不支持投影。

我们检查 system.query_log 表,以查看 ClickHouse 自动使用了上述两个查询的两个投影(请参见下面的 projections 列)

SELECT
  tables,
  query,
  query_duration_ms::String ||  ' ms' AS query_duration,
        formatReadableQuantity(read_rows) AS read_rows,
  projections
FROM clusterAllReplicas(default, system.query_log)
WHERE (type = 'QueryFinish') AND (tables = ['default.uk_price_paid_with_projections'])
ORDER BY initial_query_start_time DESC
  LIMIT 2
FORMAT Vertical
Row 1:
──────
tables:         ['uk.uk_price_paid_with_projections']
query:          SELECT
    county,
    avg(price)
FROM uk_price_paid_with_projections
GROUP BY county
ORDER BY avg(price) DESC
LIMIT 3
query_duration: 5 ms
read_rows:      132.00
projections:    ['uk.uk_price_paid_with_projections.prj_gby_county']

Row 2:
──────
tables:         ['uk.uk_price_paid_with_projections']
query:          SELECT
  county,
  price
FROM uk_price_paid_with_projections
WHERE town = 'LONDON'
ORDER BY price DESC
LIMIT 3
SETTINGS log_queries=1
query_duration: 11 ms
read_rows:      2.29 million
projections:    ['uk.uk_price_paid_with_projections.prj_obj_town_price']

2 rows in set. Elapsed: 0.006 sec.

更多示例

以下示例使用相同的英国房价数据集,对比使用和不使用投影的查询。

为了保留我们原始的表(和性能),我们再次使用 CREATE ASINSERT INTO SELECT 创建表的副本。

CREATE TABLE uk.uk_price_paid_with_projections_v2 AS uk.uk_price_paid;
INSERT INTO uk.uk_price_paid_with_projections_v2 SELECT * FROM uk.uk_price_paid;

构建一个投影

让我们通过维度 toYear(date)districttown 创建一个聚合投影

ALTER TABLE uk.uk_price_paid_with_projections_v2
    ADD PROJECTION projection_by_year_district_town
    (
        SELECT
            toYear(date),
            district,
            town,
            avg(price),
            sum(price),
            count()
        GROUP BY
            toYear(date),
            district,
            town
    )

填充现有数据的投影。(如果没有将其具体化,投影将仅为新插入的数据创建)

ALTER TABLE uk.uk_price_paid_with_projections_v2
    MATERIALIZE PROJECTION projection_by_year_district_town
SETTINGS mutations_sync = 1

以下查询对比了有和没有投影时的性能。要禁用投影,我们使用设置 optimize_use_projections,默认情况下它是启用的。

查询 1. 每年平均价格

SELECT
    toYear(date) AS year,
    round(avg(price)) AS price,
    bar(price, 0, 1000000, 80)
FROM uk.uk_price_paid_with_projections_v2
GROUP BY year
ORDER BY year ASC
SETTINGS optimize_use_projections=0
SELECT
    toYear(date) AS year,
    round(avg(price)) AS price,
    bar(price, 0, 1000000, 80)
FROM uk.uk_price_paid_with_projections_v2
GROUP BY year
ORDER BY year ASC

结果应该相同,但后一个示例的性能更好!

查询 2. 伦敦每年平均价格

SELECT
    toYear(date) AS year,
    round(avg(price)) AS price,
    bar(price, 0, 2000000, 100)
FROM uk.uk_price_paid_with_projections_v2
WHERE town = 'LONDON'
GROUP BY year
ORDER BY year ASC
SETTINGS optimize_use_projections=0
SELECT
    toYear(date) AS year,
    round(avg(price)) AS price,
    bar(price, 0, 2000000, 100)
FROM uk.uk_price_paid_with_projections_v2
WHERE town = 'LONDON'
GROUP BY year
ORDER BY year ASC

查询 3. 最昂贵的街区

条件 (date >= '2020-01-01') 需要修改,使其与投影维度 (toYear(date) >= 2020) 匹配

SELECT
    town,
    district,
    count() AS c,
    round(avg(price)) AS price,
    bar(price, 0, 5000000, 100)
FROM uk.uk_price_paid_with_projections_v2
WHERE toYear(date) >= 2020
GROUP BY
    town,
    district
HAVING c >= 100
ORDER BY price DESC
LIMIT 100
SETTINGS optimize_use_projections=0
SELECT
    town,
    district,
    count() AS c,
    round(avg(price)) AS price,
    bar(price, 0, 5000000, 100)
FROM uk.uk_price_paid_with_projections_v2
WHERE toYear(date) >= 2020
GROUP BY
    town,
    district
HAVING c >= 100
ORDER BY price DESC
LIMIT 100

同样,结果相同,但请注意第二个查询的性能提升。

在一个查询中组合投影

从版本 25.6 开始,在上一版本中引入的 _part_offset 支持的基础上,ClickHouse 现在可以使用多个投影来加速具有多个过滤器的单个查询。

重要的是,ClickHouse 仍然只从一个投影(或基本表)读取数据,但可以使用其他投影的主索引来在读取之前剪除不必要的部分。这对于过滤多个列的查询特别有用,每个列可能匹配不同的投影。

目前,此机制仅剪除整个部分。粒度级别的剪除尚未支持。

为了演示这一点,我们定义了表(使用 _part_offset 列的投影)并插入了五个示例行,与上面的图表匹配。

CREATE TABLE page_views
(
    id UInt64,
    event_date Date,
    user_id UInt32,
    url String,
    region String,
    PROJECTION region_proj
    (
        SELECT _part_offset ORDER BY region
    ),
    PROJECTION user_id_proj
    (
        SELECT _part_offset ORDER BY user_id
    )
)
ENGINE = MergeTree
ORDER BY (event_date, id)
SETTINGS
  index_granularity = 1, -- one row per granule
  max_bytes_to_merge_at_max_space_in_pool = 1; -- disable merge

然后我们将数据插入到表中

INSERT INTO page_views VALUES (
1, '2025-07-01', 101, 'https://example.com/page1', 'europe');
INSERT INTO page_views VALUES (
2, '2025-07-01', 102, 'https://example.com/page2', 'us_west');
INSERT INTO page_views VALUES (
3, '2025-07-02', 106, 'https://example.com/page3', 'us_west');
INSERT INTO page_views VALUES (
4, '2025-07-02', 107, 'https://example.com/page4', 'us_west');
INSERT INTO page_views VALUES (
5, '2025-07-03', 104, 'https://example.com/page5', 'asia');
注意

注意:该表使用自定义设置进行说明,例如单行粒度和禁用了部分合并,不建议在生产环境中使用。

此设置产生

  • 五个单独的部分(每个插入的行一个)
  • 每个行一个主索引条目(在基本表和每个投影中)
  • 每个部分恰好包含一行

有了这个设置,我们运行一个过滤 regionuser_id 的查询。由于基本表的主索引是由 event_dateid 构建的,因此在这里没有帮助,ClickHouse 因此使用

  • region_proj 按区域剪除部分
  • user_id_proj 进一步按 user_id 剪除

可以使用 EXPLAIN projections = 1 查看此行为,它显示了 ClickHouse 如何选择和应用投影。

EXPLAIN projections=1
SELECT * FROM page_views WHERE region = 'us_west' AND user_id = 107;
    ┌─explain────────────────────────────────────────────────────────────────────────────────┐
 1. │ Expression ((Project names + Projection))                                              │
 2. │   Expression                                                                           │                                                                        
 3. │     ReadFromMergeTree (default.page_views)                                             │
 4. │     Projections:                                                                       │
 5. │       Name: region_proj                                                                │
 6. │         Description: Projection has been analyzed and is used for part-level filtering │
 7. │         Condition: (region in ['us_west', 'us_west'])                                  │
 8. │         Search Algorithm: binary search                                                │
 9. │         Parts: 3                                                                       │
10. │         Marks: 3                                                                       │
11. │         Ranges: 3                                                                      │
12. │         Rows: 3                                                                        │
13. │         Filtered Parts: 2                                                              │
14. │       Name: user_id_proj                                                               │
15. │         Description: Projection has been analyzed and is used for part-level filtering │
16. │         Condition: (user_id in [107, 107])                                             │
17. │         Search Algorithm: binary search                                                │
18. │         Parts: 1                                                                       │
19. │         Marks: 1                                                                       │
20. │         Ranges: 1                                                                      │
21. │         Rows: 1                                                                        │
22. │         Filtered Parts: 2                                                              │
    └────────────────────────────────────────────────────────────────────────────────────────┘

EXPLAIN 输出(如上所示)揭示了逻辑查询计划,从上到下

行号描述
3page_views 基本表读取的计划
5-13使用 region_proj 识别 3 个区域 = 'us_west' 的部分,剪除 5 个部分中的 2 个
14-22使用 user_id_proj 识别 1 个 user_id = 107 的部分,进一步剪除剩余 3 个部分中的 2 个

最终,只有 1 个 5 个部分 从基本表读取。通过组合多个投影的索引分析,ClickHouse 显著减少了扫描的数据量,提高了性能,同时保持了较低的存储开销。

    © . This site is unofficial and not affiliated with ClickHouse, Inc.