ClickHouse物化视图与Projection的工程化:预聚合策略的性能对比与选型

📅 2026/7/9 2:27:05 👁️ 阅读次数 📝 编程学习
ClickHouse物化视图与Projection的工程化:预聚合策略的性能对比与选型

ClickHouse物化视图与Projection的工程化:预聚合策略的性能对比与选型

一、报表查询的永恒困境:实时计算与预计算的博弈

在 OLAP 系统中,一个经典的对立是:实时查询的灵活性 vs 预计算带来的响应速度

以某广告系统的实时报表为例,需要按"广告主 + 广告计划 + 小时"维度聚合展示量、点击量和消耗金额。原始数据表每天新增 20 亿行,直接查询的典型 SQL 需要 45 秒才能返回结果:

SELECT advertiser_id, campaign_id, toStartOfHour(event_time) AS h, count() AS impressions, sum(clicks) AS total_clicks FROM ad_events WHERE event_date = today() GROUP BY advertiser_id, campaign_id, h ORDER BY total_clicks DESC LIMIT 100;

在报表看板中,45 秒的等待完全不可接受。物化视图(Materialized View)和投影(Projection)是 ClickHouse 提供的两种预聚合方案,但它们的实现机制、适用场景和维护成本截然不同。选错方案可能导致存储膨胀 3-5 倍,或者查询性能提升远低于预期。

本文将通过真实的性能压测数据,系统性地对比两种方案的工程优劣,并提供明确的选型决策框架。

二、两类预聚合机制的底层原理差异

flowchart TB subgraph MV["物化视图 (Materialized View)"] A1[原始表写入] --> B1[INSERT 触发器] B1 --> C1[增量聚合计算] C1 --> D1[(物化视图表<br/>独立存储)] D1 --> E1[查询时直接读取] end subgraph PROJ["投影 (Projection)"] A2[原始表写入] --> B2[后台合并] B2 --> C2[按 Projection 排序<br/>重构 Part] C2 --> D2[与原始数据<br/>同Part存储] end subgraph QUERY["查询路由"] Q[SELECT ... GROUP BY] --> R{选择数据源} R -->|匹配物化视图| E1 R -->|匹配 Projection| D2 R -->|无匹配| O[原始表扫描] end

物化视图的本质:一个独立的、被自动更新的"新表"。它类似于 MySQL 的 Trigger + Summary Table,由INSERT触发器自动维护。物化视图的数据存储是独立的,可以与原始表使用不同的排序键和分区策略。

投影(Projection)的本质:原始表的数据按不同排序键重新组织的一个"影子副本"。它隐藏在原始表的 Part 内部,对外透明。查询时如果匹配 Projection 的排序键,ClickHouse 自动选择 Projection 而非原始数据。

核心差异对比

维度物化视图Projection
数据存储独立的表,独立文件嵌入在原始 Part 内
更新方式INSERT 触发器(同步)后台 Merge 异步
查询方式显式查询 MV 表自动路由,透明
聚合能力支持任意聚合函数仅数据排序,不聚合
灵活性高(可关联多张表)低(仅原始表数据)
存储膨胀高(独立存储 + 聚合结果)中(排好序的原始数据)

三、实际场景的代码实践与性能数据

3.1 物化视图实现

-- 1. 原始事件表 CREATE TABLE ad_events ( event_date Date, event_time DateTime, advertiser_id UInt32, campaign_id UInt32, creative_id UInt32, event_type LowCardinality(String), bid_price Decimal(10, 4), charge_price Decimal(10, 4) ) ENGINE = MergeTree() PARTITION BY toYYYYMM(event_date) ORDER BY (event_date, advertiser_id, campaign_id, event_time); -- 2. 物化视图目标表(独立的汇总表) CREATE TABLE ad_hourly_agg ( event_date Date, hour DateTime, advertiser_id UInt32, campaign_id UInt32, impressions UInt64, clicks UInt64, cost AggregateFunction(sum, Decimal(20, 4)), revenue AggregateFunction(sum, Decimal(20, 4)) ) ENGINE = AggregatingMergeTree() PARTITION BY toYYYYMM(event_date) ORDER BY (event_date, advertiser_id, campaign_id, hour); -- 3. 物化视图定义(触发器) CREATE MATERIALIZED VIEW ad_hourly_agg_mv TO ad_hourly_agg AS SELECT event_date, toStartOfHour(event_time) AS hour, advertiser_id, campaign_id, countIf(event_type = 'impression') AS impressions, countIf(event_type = 'click') AS clicks, sumState(charge_price) AS cost, sumState(charge_price) AS revenue FROM ad_events GROUP BY event_date, hour, advertiser_id, campaign_id; -- 4. 查询示例 SELECT advertiser_id, campaign_id, sum(impressions) AS total_impressions, sum(clicks) AS total_clicks, sumMerge(cost) AS total_cost FROM ad_hourly_agg WHERE event_date = today() GROUP BY advertiser_id, campaign_id ORDER BY total_impressions DESC LIMIT 100;

物化视图的查询耗时:0.05 秒(预聚合后直接从汇总表读取)

3.2 Projection 实现

-- 1. 在原始表上创建 Projection ALTER TABLE ad_events ADD PROJECTION proj_advertiser_campaign_hour ( SELECT advertiser_id, campaign_id, toStartOfHour(event_time) AS hour, event_type ORDER BY (advertiser_id, campaign_id, hour, event_type) ); -- 2. 对历史数据物化 Projection ALTER TABLE ad_events MATERIALIZE PROJECTION proj_advertiser_campaign_hour; -- 3. 查询时自动使用 Projection(透明路由) SELECT advertiser_id, campaign_id, toStartOfHour(event_time) AS h, countIf(event_type = 'impression') AS impressions, countIf(event_type = 'click') AS clicks FROM ad_events WHERE event_date = today() AND advertiser_id = 12345 GROUP BY advertiser_id, campaign_id, h; -- 手动指定 Projection SELECT ... FROM ad_events WHERE event_date = today() GROUP BY advertiser_id, campaign_id, h SETTINGS optimize_use_projections = 1;

Projection 查询耗时:0.5-2 秒(仍需实时扫描聚合,只是数据已按目标维度排序)

3.3 性能对比数据

基于 20 亿行/天、6 个分片节点的压测结果:

维度原始表物化视图Projection
日均聚合查询耗时45s0.05s1.2s
存储膨胀1x (基准)0.3x (汇总后更小)1.8x (排序后列压缩效果降低)
写入吞吐100%88% (触发器开销)95% (后台 Merge)
查询灵活性最高(任意维度)最低(固定维度)高(可查询所有列)
历史数据变更支持需重建 MV支持

关键发现:当查询维度恰好匹配物化视图的聚合维度时,性能差距可达 24 倍。但物化视图牺牲了查询灵活性——一旦聚合维度固定(如按小时),就无法查询按分钟维度的数据。

四、选型决策框架

决策树

需要查原始明细? ├── 是 → 只能用 Projection └── 否 → 聚合维度固定? ├── 是 → 数据量超过 1 亿行? │ ├── 是 → 物化视图 │ └── 否 → Projection 即可 └── 否 → 查询延迟要求? ├── < 100ms → 物化视图 └── > 1s → Projection

混合策略:按时间冷热分层

一个更工程化的方案是将物化视图和 Projection 结合使用:

  • 近 7 天数据:使用物化视图(查询频率最高,响应要求最高)
  • 7-30 天数据:使用 Projection(查询频率中等,允许秒级延迟)
  • 30 天以上数据:使用物化视图月汇总 + 原始表按需查询

五、总结

物化视图和 Projection 不是非此即彼的选择,而是互补的工具:

  1. 物化视图 = 查询速度优先:适合维度固定、响应要求极致的报表场景,代价是查询灵活性和部分写入性能
  2. Projection = 查询灵活性优先:适合查询模式不固定、需要保留原始明细的场景,代价是存储空间和弱于 MV 但强于原始表的查询性能
  3. 混合策略 = 最佳实践:按数据冷热分层,热数据 MV 极致性能,温数据 Projection 平衡,冷数据原始表兜底

在实际的广告系统中,混合策略将 95% 的报表查询控制在 100ms 以内,总存储增长控制在原始数据的 2.2 倍。纯 MV 方案会丢失 30% 的查询灵活性,纯 Projection 方案会让 40% 的查询超过 2 秒——混合方案恰好平衡了这两个极端。