Skip to content
Learning Platform

Главная ментальная модель: MV — это триггер, а не кэш

Если вы пришли в ClickHouse из PostgreSQL или Oracle, слово «materialized view» сбивает с толку. Там MV — это снимок результата запроса, который надо периодически пересчитывать командой REFRESH. Пока вы не сделали refresh, представление показывает старые данные. Это «кэш с ручным или расписанным обновлением».

В ClickHouse классический MV устроен принципиально иначе. Это не снимок и не кэш. Это INSERT-триггер: каждый раз, когда в исходную таблицу прилетает блок строк, ClickHouse прогоняет ваш SELECT по этому блоку и записывает результат в целевую таблицу. Никакого периодического пересчёта нет. Представление обновляется ровно тогда, когда меняется источник, и обновляется не целиком, а инкрементально — только на новых данных.

Запомните эту фразу, она объясняет 90% всех сюрпризов: MV в ClickHouse видит только то, что вставляется после его создания, и только то, что проходит через INSERT в исходную таблицу. Всё остальное в этой статье — следствия из этого факта.

Это бесплатный самостоятельный разбор. Он пересекается с модулем «Материализованные представления и проекции» нашего платного курса ClickHouse, но читается отдельно.

Что физически происходит на вставке

Разберём механику по шагам. У нас есть таблица сырых событий и MV, который считает агрегаты.

CREATE TABLE events (
    ts        DateTime,
    user_id   UInt64,
    revenue   Decimal(18, 2)
) ENGINE = MergeTree
ORDER BY (ts, user_id);

CREATE MATERIALIZED VIEW events_daily_mv
TO events_daily            -- явная целевая таблица
AS
SELECT
    toDate(ts)    AS day,
    count()       AS events,
    sum(revenue)  AS revenue
FROM events
GROUP BY day;

Когда выполняется INSERT INTO events VALUES (...), происходит следующее в рамках одной операции вставки:

  1. ClickHouse формирует из входящих строк блок и пишет его как новый кусок (part) в events.
  2. Сразу же этот тот же самый блок подаётся на вход SELECT-у материализованного представления. Важно: MV видит не таблицу events целиком, а именно вставляемый блок как виртуальную таблицу FROM events.
  3. Результат SELECT (агрегаты по этому блоку) записывается в целевую таблицу events_daily как ещё один кусок.

Ключевой и самый недооценённый нюанс: GROUP BY day в MV агрегирует внутри одного блока, а не по всей истории. Если за день пришло десять вставок, в events_daily появится десять частичных строк за этот день — по одной на блок. Это не баг. Окончательное «схлопывание» этих частичных строк в один итог за день — задача не MV, а движка целевой таблицы и фонового слияния. Поэтому целевую таблицу почти всегда делают не MergeTree, а SummingMergeTree или AggregatingMergeTree.

Почему нужен AggregatingMergeTree и State/Merge

Если целевая таблица — обычный MergeTree, то десять частичных строк за день так и останутся десятью строками, и любой SELECT будет обязан добивать GROUP BY day руками. Для count и sum спасает SummingMergeTree: при фоновом слиянии кусков он складывает числовые столбцы у строк с одинаковым ORDER BY-ключом.

Но sum и count — простые аддитивные функции. А как быть с uniq, avg, quantile? Их нельзя «досложить»: среднее двух средних не равно общему среднему, а множество уникальных значений нельзя сложить как числа. Для них существует AggregatingMergeTree и пара комбинаторов -State / -Merge.

Идея такая. Агрегатная функция имеет промежуточное состояние: для avg это пара (сумма, количество), для uniq — HyperLogLog-скетч, для quantile — сжатая выборка. Комбинатор -State говорит функции: «не возвращай финальное число, верни сериализованное состояние». Комбинатор -Merge делает обратное: «возьми набор состояний и слей их в одно финальное число». AggregatingMergeTree при фоновом слиянии кусков умеет объединять состояния между собой.

CREATE TABLE events_daily
(
    day      Date,
    events   AggregateFunction(count),
    revenue  AggregateFunction(sum, Decimal(18, 2)),
    uniqs    AggregateFunction(uniq, UInt64)
) ENGINE = AggregatingMergeTree
ORDER BY day;

CREATE MATERIALIZED VIEW events_daily_mv TO events_daily AS
SELECT
    toDate(ts)              AS day,
    countState()            AS events,
    sumState(revenue)       AS revenue,
    uniqState(user_id)      AS uniqs
FROM events
GROUP BY day;

-- читаем готовые числа: -State на запись, -Merge на чтение
SELECT
    day,
    countMerge(events)   AS events,
    sumMerge(revenue)    AS revenue,
    uniqMerge(uniqs)     AS uniqs
FROM events_daily
GROUP BY day;

Правило, которое экономит часы отладки: на запись через MV всегда -State, на чтение из целевой таблицы всегда -Merge, а тип столбца — AggregateFunction(...). Если на чтении забыть -Merge, вы получите нечитаемый бинарный мусор сериализованного состояния, а не число.

Почему count тоже обернули в countState? Потому что фоновое слияние AggregatingMergeTree объединяет строки только по правилам AggregateFunction. Смешивать сырой UInt64 и состояния в одной таблице нельзя — движок не будет их складывать.

Когда состояния сливаются на самом деле

Тут прячется вторая частая ошибка. AggregatingMergeTree объединяет состояния в фоне и асинхронно, при слиянии кусков, а не сразу после вставки. Пока куски не слиты, в таблице физически лежит несколько строк за один и тот же day, каждая со своим частичным состоянием. Если вы прочитаете таблицу «как есть», без агрегации, вы увидите эти дубли по ключу.

Поэтому чтение из AggregatingMergeTree всегда должно идти с GROUP BY по ключу и -Merge поверх. Слияние при чтении долавливает то, что фон ещё не успел схлопнуть. Альтернатива — FINAL, но он дороже и его обычно избегают на горячем пути. Никогда не полагайтесь на то, что «фон уже всё слил»: гарантий по времени слияния нет.

Почему агрегация именно поблочная, а не построчная

Поблочность — не случайность реализации, а ядро производительности. ClickHouse — векторизованный движок: он не обрабатывает строки по одной, он гоняет операции над колоночными векторами в несколько тысяч значений за раз, попадающими в кэш CPU. Вставка тоже идёт блоками. Поэтому естественная единица работы для MV — это блок, а не строка и не «вся таблица».

Отсюда вытекает практическое следствие про размер блока. Если вы вставляете по одной строке миллион раз, MV сработает миллион раз, и в целевую таблицу прилетит миллион частичных состояний — фону потом придётся их долго схлопывать, а сама вставка будет медленной. Если вы вставляете батчами по десятки-сотни тысяч строк, MV сработает на каждый батч один раз, агрегируя его целиком, и частичных состояний будет на порядки меньше. Правило «вставляйте большими батчами» в ClickHouse касается не только сырой таблицы — оно прямо влияет на то, сколько мусорных частичных строк породят ваши MV.

Есть ещё тонкость с дедупликацией. У реплицируемых движков ClickHouse дедуплицирует одинаковые вставляемые блоки по их хэшу, чтобы повторная отправка батча не задвоила данные. Эта дедупликация работает на уровне блоков источника. Если ваш MV из одного блока источника порождает блок в целевой таблице, поведение предсказуемо; но как только в цепочке появляются недетерминированные функции или зависящая от порядка логика, повтор может дать иной результат на целевой стороне. Для потоковых пайплайнов это означает: держите SELECT материализованного представления детерминированным.

populate: разовый бэкфилл и его ловушка

Раз MV видит только новые вставки, как загрузить в него уже накопленную историю? Есть POPULATE:

CREATE MATERIALIZED VIEW events_daily_mv TO events_daily
POPULATE AS SELECT ... FROM events GROUP BY day;

POPULATE при создании один раз прогоняет SELECT по всем существующим данным источника и заливает результат в целевую таблицу. Звучит удобно, но у него есть опасная дыра: данные, вставленные в источник между началом POPULATE и завершением создания MV, теряются — они уже не попадут в исходный снимок и ещё не перехватываются триггером. На потоковой нагрузке это тихая потеря данных.

Промышленный паттерн надёжнее: создать MV без POPULATE (с этого момента весь новый поток перехватывается корректно), а историю долить отдельным управляемым INSERT INTO events_daily SELECT ... FROM events WHERE ts < 'момент_создания'. Так нет окна потери и нет монолитной долгой операции, держащей создание представления.

Цепочки MV: триггер на триггере

MV срабатывает на вставку в источник. А целевая таблица MV — это тоже таблица, в которую идёт вставка. Значит, на неё можно повесить ещё один MV. Получается каскад: сырьё -> минутные агрегаты -> часовые -> дневные. Каждый уровень — отдельный MV, срабатывающий на вставку предыдущего уровня.

Здесь важно понимать порядок и атомарность. Все MV в цепочке исполняются в рамках одной исходной вставки, последовательно, в одном потоке вставки. Если MV на третьем уровне упадёт с ошибкой, по умолчанию падает вся вставка целиком — данные не попадут даже в исходную таблицу. Это поведение настраивается, но дефолт именно такой, и он часто удивляет: «я вставлял в сырую таблицу, почему ошибка про какую-то агрегатную таблицу?». Потому что ваш INSERT синхронно протащил блок через всю цепочку триггеров.

Вторая практическая деталь каскадов — наблюдаемость и порядок. MV не имеют имён в плане запроса так, как привыкли разработчики реляционных СУБД: вставка просто «расходится» по триггерам. Чтобы понять, что и куда поехало, смотрят системные таблицы (system.query_log, system.parts) и явно держат в голове граф зависимостей. Чем длиннее цепочка, тем дороже каждая вставка, потому что один INSERT синхронно оплачивает все уровни агрегации сразу. Это компромисс: вы переносите стоимость с чтения на запись осознанно, поэтому глубину каскада проектируют под реальные паттерны чтения, а не «на всякий случай».

Сводка частых ошибок

СимптомПричинаЧто делать
MV «не видит» старые данныеMV — триггер на новые вставки, история не перехватываетсяPOPULATE при создании или ручной бэкфилл INSERT ... SELECT
Потерялись данные при создании MV с POPULATEОкно между снимком и активацией триггераСоздать MV без POPULATE, историю долить отдельным INSERT
В целевой таблице дубли строк по ключуСостояния AggregatingMergeTree ещё не слиты фономЧитать с GROUP BY ... -Merge, не полагаться на момент слияния
На чтении бинарный мусор вместо числаЗабыт комбинатор -Merge поверх AggregateFunctionsumMerge, uniqMerge, countMerge на чтении
MV молчит при INSERT другим способомДанные пришли не через INSERT в источник (напр. ATTACH PART)MV ловит только логические вставки в исходную таблицу
INSERT падает с ошибкой про чужую таблицуУпал MV где-то в цепочке триггеровЧинить логику MV; вставка синхронна со всей цепочкой

Чего MV принципиально не делает

MV не реагирует на ALTER ... UPDATE/DELETE и мутации источника — это не вставки, триггер их не видит. MV не пересчитывается, если вы поменяли его SELECT: старые данные в целевой таблице останутся как были, новая логика применится только к будущим вставкам. MV не делает JOIN «вживую» с большой таблицей дёшево — на каждый блок вставки он будет выполнять этот JOIN, и тяжёлый правый стол убьёт пропускную способность вставки. И MV не гарантирует, что один и тот же SELECT поверх источника и поверх целевой таблицы дадут идентичный результат — потому что MV агрегирует поблочно, а источник вы агрегируете целиком.

Когда это нужно и что дальше

Инкрементальный MV — это способ заплатить за агрегацию один раз, на вставке, и потом читать готовые «скошенные» данные на порядки быстрее. Классические применения: предрасчёт дневных/часовых метрик, последняя версия записи через ReplacingMergeTree, перекладка одного и того же потока в несколько раскладок под разные ключи сортировки. Цена — дисциплина: правильный движок целевой таблицы, аккуратные -State/-Merge, осознанный бэкфилл и понимание, что вставка теперь тащит за собой всю цепочку триггеров.

Ментальная модель «MV = синхронный INSERT-триггер с инкрементальной поблочной агрегацией» закрывает почти все вопросы. Дальше идут детали: как AggregatingMergeTree сериализует состояния на диск, как ведёт себя цепочка при репликации, чем ReplacingMergeTree отличается от CollapsingMergeTree под MV, и как проектировать ключ сортировки целевой таблицы под паттерн чтения.

Эти темы — внутри платного курса ClickHouse (модуль про материализованные представления и проекции; вводная часть открыта бесплатно). Если строите аналитику и хранилища целиком, посмотрите подборку материалов и курсов по направлению Data Engineering.

Ещё в направлении · Data Engineering

Все материалы направления →