Skip to main content

简介

ClickHouse 提供了多种机制,可在实时场景下加速海量数据上的分析查询。其中一种加速 查询的机制是使用 Projections。Projections 通过按照关注的属性对数据重新排序来优化 查询。具体可以表现为:
  1. 完全重新排序
  2. 原始表的一个子集,但采用不同的排序方式
  3. 预先计算的 aggregation (类似于 materialized view) ,但其排序 与 aggregation 保持一致。

投影如何工作?

实际上,Projection 可以看作原始表的一个额外隐藏表。Projection 可以采用与原始表不同的行顺序,因此也会拥有 不同于原始表的主索引,还能够自动 以增量方式预先计算聚合值。因此,使用投影 可以通过两个“调优旋钮”来加速查询执行:
  • 合理使用主索引
  • 预先计算聚合
投影在某些方面与 Materialized Views 类似,后者同样允许使用多种行顺序,并在 写入时预先计算聚合。 不过,投影会自动更新, 并与原始表保持同步;而 Materialized Views 则需要 显式更新。当查询针对原始表时, ClickHouse 会自动对主键进行采样,并选择一个能够 生成相同正确结果、但需要读取数据量最少的表, 如下图所示:

使用 _part_offset 实现更智能的存储

从 25.5 版本开始,ClickHouse 在 投影中支持虚拟列 _part_offset,为定义投影提供了一种新方式。 现在有两种定义投影的方式:
  • 存储普通列 (原始行为) :投影包含完整 数据,可直接读取;当过滤条件与 投影的排序方式匹配时,性能会更快。
  • 仅存储排序键 + _part_offset:投影的工作方式类似于索引。 ClickHouse 使用投影的主索引来定位匹配的行,但会从 基础表中读取实际数据。这可以减少存储开销,但代价是 查询时 I/O 会略有增加。
上述两种方式也可以混合使用,在投影中存储部分列,并通过 _part_offset 间接引用其他列。

何时使用投影?

对于新用户来说,投影是一项很有吸引力的功能,因为它们会在数据插入时自动维护。此外,查询只需发送到 单个表,在可能的情况下便会利用投影来加快 响应时间。 这与 Materialized Views 形成对比:用户必须根据 过滤条件选择合适的优化目标表,或重写查询。这会给用户应用带来更高要求,并增加 客户端侧的复杂性。 尽管有这些优势,投影也有一些固有的限制, 你应了解这些限制,因此应谨慎使用。
  • 投影不允许为源表和 (隐藏的) 目标表设置不同的生存时间 (TTL),而 materialized views 允许使用不同的 TTL。
  • 带有投影的表不支持轻量级更新和删除。
  • Materialized Views 可以形成链式关系:一个 materialized view 的目标表 可以作为另一个 materialized view 的源表,依此类推。而投影 无法做到这一点。
  • 投影定义不支持 join,但 Materialized Views 支持。不过,对带有投影的表执行的查询仍可自由使用 join。
  • 投影定义不支持过滤 (WHERE 子句) ,但 Materialized Views 支持。不过,对带有投影的表执行的查询仍可自由过滤。
我们建议在以下情况下使用投影:
  • 需要对数据进行完全重排序时。虽然投影中的表达式 理论上可以使用 GROUP BY,,但 materialized views 在维护聚合方面更高效。查询优化器也更可能 利用采用简单重排序的投影,即 SELECT * ORDER BY x。 你还可以在该表达式中仅选择部分列,以减少存储 占用。
  • 用户能够接受由此带来的存储占用增加 以及数据写入两次的额外开销。请测试其对插入速度的影响,并 评估存储开销

示例

对不在主键中的列进行过滤

在本示例中,我们将演示如何为表添加投影。 我们还会看看如何利用投影来加速那些 对不在表主键中的列进行过滤的查询。 本示例将使用 sql.clickhouse.com 上提供的 New York Taxi Data 数据集,该数据集按 pickup_datetime 排序。 我们先编写一个简单的查询,找出所有乘客 给司机支付超过 200 美元小费的行程 ID: 请注意,由于这里是按 tip_amount 进行过滤,而它并不在 ORDER BY 中,因此 ClickHouse 不得不执行全表扫描。下面我们来加速这个查询。 为了保留原始表和查询结果,我们将创建一个新表,并使用 INSERT INTO SELECT 复制数据:
要添加投影,可结合使用 ALTER TABLE 语句和 ADD PROJECTION 语句:
添加投影后,需要使用 MATERIALIZE PROJECTION 语句,以便其中的数据根据上面指定的查询进行物理排序和重写:
现在我们已经添加了投影,再次运行该查询: 可以看到,查询耗时显著缩短了,扫描的 行数也更少了。 我们可以通过查询 system.query_log 表, 来确认上面的查询确实使用了我们创建的投影:

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

为了演示如何使用 投影 提升查询性能,我们来看一个基于真实数据集的示例。在这个示例中,我们将使用 UK Property Price Paid 教程中的表,该表包含 3003 万行。这个数据集也可以在我们的 sql.clickhouse.com 环境中查看。 如果你想了解这张表是如何创建的以及数据是如何插入的,可以参考“The UK property prices dataset”页面。 我们可以在这个数据集上运行两个简单的查询。第一个列出伦敦成交价最高的几个郡,第二个计算各郡的平均房价: 请注意,尽管这两个查询都非常快,但由于我们在创建该表时,townprice 都不在 ORDER BY 子句中,因此这两个查询都对全部 3003 万行进行了全表扫描:
让我们看看能否利用 投影 来加快这个查询。 为了保留原始表和查询结果,我们将创建一个新表,并使用 INSERT INTO SELECT 复制数据:
我们创建并填充投影 prj_oby_town_price,它会生成一张额外的 (隐藏) 表,并创建一个按 town 和 price 排序的主索引,以 优化这样一条查询:列出特定 town 中成交价格最高的房产所在的 counties:
mutations_sync 设置 用于强制以同步方式执行。 我们创建并填充投影 prj_gby_county——这是一个额外的 (隐藏) 表, 会以增量方式为英国现有的 130 个郡预先计算 avg(price) 聚合值:
如果在投影中使用了 GROUP BY 子句,就像上面的 prj_gby_county 投影一样,那么该 (隐藏) 表的底层存储引擎 就会变为 AggregatingMergeTree,并且所有聚合函数都会被转换为 AggregateFunction。这可确保增量数据得到正确聚合。
下图直观展示了主表 uk_price_paid_with_projections 及其两个投影: 如果我们现在再次运行该查询,列出伦敦成交价最高的三个房产所在的郡, 就会看到查询性能有所提升: 同样,对于列出英国平均成交价最高的三个郡的查询: 请注意,这两个查询都针对原始表,而且在我们 创建这两个投影之前,这两个查询都进行了全表扫描 (全部 3003 万行都从磁盘流式读取) 。 另外请注意,列出伦敦各郡中价格最高的三套房产所属郡的那个查询 会流式处理 217 万行。而当我们直接使用针对该查询优化的第二张表时, 只从磁盘流式读取了 8.192 万行。 之所以会有这种差异,是因为目前,上面提到的 optimize_read_in_order 优化还不支持投影。 我们查看 system.query_log 表,可以看到 ClickHouse 已自动为上面的两个查询使用了这两个投影 (请参见下方的 projections 列) :

更多示例

下面的示例使用同一个英国价格数据集,对比了使用投影与不使用投影时的查询。 为了保留原始表 (以及其性能) ,我们再次使用 CREATE ASINSERT INTO SELECT 创建该表的副本。

创建投影

我们按维度 toYear(date)districttown 创建一个聚合投影:
为现有数据填充该投影。 (如果不将其物化,该投影将只针对新插入的数据创建) :
以下查询对比了使用 projections 与不使用 projections 时的性能差异。若要禁用 projections 的使用,我们使用设置 optimize_use_projections,该设置默认处于启用状态。

查询 1. 每年平均价格

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

查询 2. 伦敦每年的平均房价

查询 3. 最昂贵的街区

条件 (date >= '2020-01-01') 需要调整为与 projection 的维度一致 (toYear(date) >= 2020)) : 同样,结果没有变化,但请注意第 2 个查询的查询性能有所提升。

在单个查询中组合使用投影

从 25.6 版本开始,在前一版本引入的 _part_offset 支持基础上,ClickHouse 现在可以使用多个投影来加速带有多个过滤条件的单个查询。 需要注意的是,ClickHouse 仍然只会从一个投影 (或基表) 中读取数据, 但在读取之前,可以借助其他投影的主索引来剪除不必要的 parts。 这对于按多个列进行过滤的查询尤其有用,因为每个列 都可能对应不同的投影。
目前,该机制只能剪除整个 parts。尚不支持粒度级别的剪除。
为演示这一点,我们定义这张表 (其中投影使用 _part_offset 列) , 并插入五个与上图对应的示例行。
然后将数据插入表中:
注意:该表为了演示使用了自定义设置,例如单行粒度 和禁用 parts 合并,这些设置不建议在生产环境中使用。
此设置会产生:
  • 五个独立的 parts (每个插入的行一个)
  • 每行对应一个主索引条目 (在基表和每个 projection 中)
  • 每个 part 都恰好包含一行
在此设置下,我们运行一个同时按 regionuser_id 过滤的查询。 由于基表的主索引是基于 event_dateid 构建的,因此 在这里派不上用场,所以 ClickHouse 会使用:
  • region_proj 按区域裁剪 parts
  • user_id_proj 进一步按 user_id 裁剪
这种行为可以通过 EXPLAIN projections = 1 观察到,它展示了 ClickHouse 如何选择并应用 projections。
EXPLAIN 的输出 (如上所示) 展示了逻辑查询计划,从上到下: 最终,基表 5 个 parts 中只读取了 1 个。 通过结合多个 projection 的索引分析,ClickHouse 显著减少了扫描的数据量, 在保持较低存储开销的同时提升了性能。
最后修改于 2026年7月23日