Инкрементальные MV: триггер на вставку
Materialized View (MV) в ClickHouse — это триггер, а не кэш запроса. MV срабатывает при каждом INSERT в source-таблицу, применяет SELECT к вставленному блоку данных и записывает результат в target-таблицу. Это фундаментально отличается от MV в PostgreSQL, где materialized view — снимок результата запроса, обновляемый вручную через REFRESH.
Триггер-на-вставку: ключевая семантика
Самое важное правило MV в ClickHouse:
MV SELECT применяется к блоку вставки, а не ко всей таблице.
Когда вы выполняете INSERT INTO source_table, ClickHouse:
- Принимает блок данных (INSERT block)
- Записывает блок в source-таблицу (если source не Null engine)
- Для каждой MV, привязанной к source: применяет MV SELECT к этому блоку
- Результат MV SELECT вставляется в target-таблицу
MV не видит данные, которые уже были в source-таблице. MV не перечитывает всю таблицу. MV обрабатывает только текущий INSERT block.
Source/Target паттерн с TO clause
MV определяется с ключевым словом TO, которое указывает целевую таблицу:
-- 1. Source-таблица: данные поступают сюда
CREATE TABLE raw_events (
event_date Date,
user_id UInt64,
event_type String,
duration_ms UInt32
) ENGINE = MergeTree()
ORDER BY (event_date, user_id);
-- 2. Target-таблица: MV записывает результат сюда
CREATE TABLE daily_summary (
event_date Date,
total_events UInt64,
total_duration UInt64
) ENGINE = SummingMergeTree()
ORDER BY event_date;
-- 3. MV: связывает source и target через SELECT
CREATE MATERIALIZED VIEW daily_summary_mv TO daily_summary AS
SELECT
event_date,
count() AS total_events,
sum(duration_ms) AS total_duration
FROM raw_events
GROUP BY event_date;
Клауза TO daily_summary означает: “результат этого SELECT вставляй в таблицу daily_summary”. Target-таблица создаётся отдельно и существует независимо от MV.
Два INSERT — два вызова MV
Каждый INSERT — отдельный триггер. Это критически важно для понимания:
-- Первый INSERT: MV обрабатывает 3 строки
INSERT INTO raw_events VALUES
('2024-01-15', 1, 'click', 100),
('2024-01-15', 2, 'view', 200),
('2024-01-15', 3, 'click', 150);
-- MV SELECT на первом блоке:
-- event_date=2024-01-15, total_events=3, total_duration=450
-- Этот результат вставляется в daily_summary
-- Второй INSERT: MV обрабатывает 2 строки (ОТДЕЛЬНО!)
INSERT INTO raw_events VALUES
('2024-01-15', 4, 'view', 300),
('2024-01-15', 5, 'click', 50);
-- MV SELECT на втором блоке:
-- event_date=2024-01-15, total_events=2, total_duration=350
-- Этот результат ТОЖЕ вставляется в daily_summary
После двух INSERT в daily_summary будут две строки с event_date=2024-01-15: (3, 450) и (2, 350). SummingMergeTree объединит их при фоновом merge: (5, 800). Если бы MV видел всю таблицу, была бы сразу одна строка (5, 800) — но MV работает на уровне блока.
Null engine: source как транзитный слой
Иногда исходные данные не нужны после обработки MV — нужен только агрегированный результат. Null engine отбрасывает все вставленные данные, сохраняя только результат MV:
-- Source: Null engine -- не хранит данные
CREATE TABLE raw_metrics (
ts DateTime,
host String,
cpu Float64,
mem Float64
) ENGINE = Null;
-- Target: хранит только агрегаты
CREATE TABLE host_stats (
hour DateTime,
host String,
avg_cpu Float64,
max_mem Float64
) ENGINE = MergeTree()
ORDER BY (hour, host);
-- MV: агрегирует и записывает в target
CREATE MATERIALIZED VIEW host_stats_mv TO host_stats AS
SELECT
toStartOfHour(ts) AS hour,
host,
avg(cpu) AS avg_cpu,
max(mem) AS max_mem
FROM raw_metrics
GROUP BY hour, host;
Данные, вставленные в raw_metrics, обрабатываются MV и немедленно отбрасываются Null engine. raw_metrics всегда пуст. Это экономит дисковое пространство, когда детальные данные не нужны.
Fan-out: несколько MV на одном source
К одной source-таблице можно привязать несколько MV. Каждый INSERT триггерит все привязанные MV:
-- Один source, три разных агрегации
CREATE MATERIALIZED VIEW mv_hourly TO hourly_stats AS
SELECT toStartOfHour(ts) AS hour, count() AS events
FROM raw_metrics GROUP BY hour;
CREATE MATERIALIZED VIEW mv_by_host TO host_stats AS
SELECT host, avg(cpu) AS avg_cpu, max(mem) AS max_mem
FROM raw_metrics GROUP BY host;
CREATE MATERIALIZED VIEW mv_alerts TO alert_log AS
SELECT ts, host, cpu
FROM raw_metrics
WHERE cpu > 90.0;
При каждом INSERT в raw_metrics все три MV срабатывают параллельно. Каждая MV обрабатывает один и тот же INSERT block, но применяет свой SELECT.
Каскадные MV
MV может использовать target другой MV как source. Это создаёт цепочку трансформаций:
-- Уровень 1: сырые данные -> почасовые агрегаты
CREATE MATERIALIZED VIEW mv_hourly TO hourly_stats AS
SELECT toStartOfHour(ts) AS hour, sum(amount) AS total
FROM raw_events GROUP BY hour;
-- Уровень 2: почасовые -> дневные (MV на target первой MV)
CREATE MATERIALIZED VIEW mv_daily TO daily_stats AS
SELECT toDate(hour) AS day, sum(total) AS daily_total
FROM hourly_stats GROUP BY day;
INSERT в raw_events триггерит mv_hourly, которая вставляет в hourly_stats. Вставка в hourly_stats триггерит mv_daily, которая вставляет в daily_stats.
Каскадные MV с CollapsingMergeTree или ReplacingMergeTree в промежуточных target-таблицах могут давать неожиданные результаты — эти движки изменяют данные при merge, но MV работает с блоком до merge. Используйте AggregatingMergeTree для каскадных паттернов.
Когда MV не работает
MV не срабатывает при:
- ALTER TABLE UPDATE/DELETE (мутации) — это не INSERT
- OPTIMIZE TABLE (merge) — это не INSERT
- Прямой вставке в target-таблицу (обходит MV)
- Replikации данных между нодами (INSERT trigger локальный)
MV срабатывает только при INSERT (включая INSERT … SELECT) в source-таблицу.
VIEW и MATERIALIZED VIEW в PostgreSQL Streaming: event time, watermarks, windows