Аналитик открывает сводную таблицу в Excel — продажи по регионам за год — и нажимает «Обновить». Проходит двадцать секунд. Он идёт за кофе.

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

Аналитик нажимает «Обновить». Двадцать секунд.

Эта статья о том, почему так происходит, что с этим делать, и почему ответ в итоге лежит не в базе.

Как сводная Excel попадает в ClickHouse

Для дальнейшего нужно понимать одно: сводная таблица Excel ничего не считает сама. Каждое действие пользователя — раскрыть регион, поставить фильтр, добавить колонку — превращается в запрос к серверу, а сервер превращает его в SQL и отправляет в базу. Сводная на скриншоте ниже работает поверх ClickHouse через XLTable — сервер, который отдаёт Excel кубы из аналитических баз.

Сводная Excel: сумма продаж по месяцам и регионам, 2025 год, регионы Central и South
Сводная Excel: сумма продаж по месяцам и регионам, 2025 год, регионы Central и South

Вот в какой SQL такая сводная обычно превращается — это распространённая форма запроса, в которую BI-инструменты переводят отчёт «регионы по месяцам», в упрощённом виде:

SELECT st.region_name,
       toStartOfMonth(s.date) AS month,
       sum(s.amount)          AS amount
FROM sales AS s
JOIN stores AS st ON st.store_id = s.store_id       -- присоединили справочник
WHERE st.region_name IN ('Central', 'South')        -- отфильтровали по названию
  AND s.date >= '2025-01-01' AND s.date < '2026-01-01'
GROUP BY st.region_name, month                      -- сгруппировали по названию

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

Важно: это не ошибка, а форма из учебника. Так пишут BI-инструменты, так пишет человек, и ровно так напишет языковая модель — она училась на таком SQL. И до недавнего времени эта форма была лучшей из возможных: оптимизатор сам проталкивает фильтры к таблице фактов, JOIN с маленьким справочником стоит копейки, а подзапросы, наоборот, старые движки оптимизировали плохо — BI-инструменты избегали их сознательно. Три секунды на миллиард строк без всяких агрегатов — для базы отличный результат.

Правила поменяли сами базы. Проекции в ClickHouse, переписывание запросов на materialized views в StarRocks, smart tuning в BigQuery — всё это появилось и созрело в 2021–2024 годах. И оказалось, что форма, оптимальная для «честного» чтения таблицы, мешает базе увидеть предагрегат. Запомните эту форму — мы к ней вернёмся.

Что такое предагрегат и кто его подставляет

Предагрегат — это заранее посчитанная таблица. Вместо миллиарда чеков — полмиллиона строк «магазин, день, сумма продаж, количество». Ответить из неё на вопрос «продажи по регионам по месяцам» — значит прочитать полмиллиона строк, а не миллиард. В разных базах это называется по-разному: materialized view (материализованное представление, дальше — MV), projection, rollup, summary table, — но суть одна.

Есть только одна проблема: кто-то должен подставить предагрегат вместо большой таблицы. Запрос-то написан к таблице sales. Вариантов два:

  • База подставляет сама. Оптимизатор смотрит на запрос, видит, что на него можно ответить из предагрегата, и молча переписывает запрос. Пользователь и BI-инструмент ничего об этом не знают. Так умеют ClickHouse (проекции), StarRocks, BigQuery, Snowflake.

  • Подставляет тот, кто пишет запрос. База хранит предагрегат как обычную таблицу, обращаться к нему надо по имени. Так устроено в Databricks, Trino, Greenplum. В DuckDB предагрегатов как механизма нет вообще.

Полная таблица по восьми базам — в приложении. Пока важно другое: у нас ClickHouse, он умеет подставлять сам. Агрегат есть. Почему не сработало?

Эксперимент: проекция есть, а база её не видит

Стенд: таблица продаж на 1 миллиард строк, 500 магазинов, 5000 товаров, 32 месяца, 7,5 ГБ на диске. ClickHouse 25.3 на 12 ядрах. Добавим проекцию — так в ClickHouse называется встроенный предагрегат, который живёт внутри таблицы и обновляется вместе с ней:

ALTER TABLE sales ADD PROJECTION p_store_day (
    SELECT store_id, date, sum(amount), sum(qty), count()
    GROUP BY store_id, date
);
ALTER TABLE sales MATERIALIZE PROJECTION p_store_day;

Полмиллиона строк, 5 мегабайт, построилась за 16 секунд. Теперь запускаем запрос сводной — тот самый, из предыдущего раздела — и смотрим план выполнения:

Aggregating
  Join
    ReadFromMergeTree (sales)        ← читает таблицу фактов
      Granules: 45752/45752          ← целиком за год
    ReadFromMergeTree (stores)

Проекция не использована. 375 миллионов строк прочитано, 5 гигабайт, 3 секунды. Как будто её нет.

Причина проста, и она не в ClickHouse. Проекция — это агрегат по одной таблице: «сверни sales по магазину и дню». А в запросе агрегация стоит после JOIN и группирует по колонке из другой таблицы — region_name. С точки зрения оптимизатора это уже не «сверни sales», это «сверни результат склейки двух таблиц». Такого предагрегата у него нет, и он честно идёт в сырые данные.

Обратите внимание: в запросе всё нужное для проекции есть. Фильтр по двум регионам — на самом деле фильтр по 200 магазинам. Группировка по региону — группировка по магазину с последующей склейкой. Но оптимизатор не переписывает запрос так глубоко. Он консервативен, и это нормально: он не знает, что справочник магазинов маленький, а связь «магазин → регион» однозначная.

Сначала свернуть, потом склеить

Раз база не переставляет операции сама, переставим их за неё. Правило одно: сначала свернуть таблицу фактов по ключам, потом присоединить справочники к результату.

SELECT st.region_name, f.month, sum(f.amount) AS amount
FROM (
    SELECT store_id, toStartOfMonth(date) AS month, sum(amount) AS amount
    FROM sales
    WHERE store_id IN (SELECT store_id FROM stores              -- фильтр по ключу,
                       WHERE region_name IN ('Central', 'South')) -- не по названию
      AND date >= '2025-01-01' AND date < '2026-01-01'
    GROUP BY store_id, month                                    -- свернули по ключам
) AS f
JOIN stores AS st ON st.store_id = f.store_id                   -- справочник — к результату
GROUP BY st.region_name, f.month

Внутри — простой запрос к одной таблице: «сгруппируй sales по магазину и месяцу». Именно такое ClickHouse умеет направлять в проекцию:

Aggregating
  Join
    Aggregating
      ReadFromMergeTree (p_store_day)   ← читает проекцию
        Granules: 24/45752
    ReadFromMergeTree (stores)

184 тысячи строк вместо 375 миллионов. 37 миллисекунд вместо 3 секунд.

Тот же отчёт, та же проекция: 3,0 с и 375 млн строк против 37 мс и 184 тыс. строк
Тот же отчёт, та же проекция: 3,0 с и 375 млн строк против 37 мс и 184 тыс. строк

Тот же отчёт, та же проекция в таблице, тот же результат до копейки. Изменилась только форма запроса.

Отдельно стоит отметить: проекция построена по дням (GROUP BY store_id, date), а запрос просит месяцы (toStartOfMonth(date) AS month … GROUP BY store_id, month). ClickHouse досуммировал дневные значения до месячных сам — это называется rollup, и его умеют все четыре базы из «умного» класса, но только для мер, которые можно складывать. К этому вернёмся.

Есть и второй выигрыш, заметный даже без проекции. Фильтр «по названию после JOIN» заставляет базу прочитать всю таблицу за период — названия в ней нет, отбрасывать строки можно только после склейки. Фильтр «по ключу до JOIN» попадает в первичный индекс. Для широкого фильтра (200 магазинов из 500) это даёт 164 миллиона строк вместо 375. Для сводной, отфильтрованной до пяти магазинов, разница уже в сто раз:

Фильтр по ключу до JOIN: 4,3 млн строк и 34 мс против 375 млн строк и 1,7 с
Фильтр по ключу до JOIN: 4,3 млн строк и 34 мс против 375 млн строк и 1,7 с

375 миллионов строк против 4 миллионов, 1,7 секунды против 34 миллисекунд — и это без всякой проекции. Справочник, который был нужен только для фильтра, теперь вообще не присоединяется к фактам.

И наконец, JOIN. В первой форме справочник присоединяется к 375 миллионам строк, во второй — к двум сотням строк результата. Все четыре ступени на одном графике:

Четыре ступени: проекция помогает только правильной форме запроса
Четыре ступени: проекция помогает только правильной форме запроса

Проекция не помогла первой форме вообще (3,2 с → 3,0 с), а второй — в 18 раз (684 мс → 37 мс).

А если просто денормализовать?

Любой, кто работал с ClickHouse, скажет: не делайте JOIN, положите название региона прямо в таблицу продаж — ClickHouse так и рекомендует. Совет правильный для того, что он оптимизирует, но у аналитического слоя есть причины держать справочники отдельно. Атрибуты меняются: магазин переводят в другой регион, товар — в другую категорию, и переписывать ради этого миллиард строк фактов никто не станет. Атрибутов десятки, и все в факт не положишь. Модель общая для нескольких инструментов. Права доступа и иерархии живут в справочниках, а не в чеках.

Но главное — тезис статьи от денормализации не зависит. Положим region_name прямо в sales. Запрос станет однотабличным, и всё равно проекция по (store_id, date) его не обслужит: база не знает, что регион однозначно определяется магазином, и для неё GROUP BY region_name — это другой агрегат. Проекцию придётся строить ровно по тем колонкам, по которым группируют, и держать по одной на каждое сочетание. Знание «регион зависит от магазина, а месяц — от дня» — это снова семантика, которой у базы нет. К этому мы ещё вернёмся.

И наконец, у ClickHouse есть собственный способ сделать то же, что делает наш подзапрос, — словари. Свернуть факты по ключу магазина и получить название региона через dictGet поверх результата — это буквально «сначала свернуть, потом склеить», только без слова JOIN. Так что описанная форма запроса не спорит с рекомендациями ClickHouse, она им следует.

То же самое в других базах

Мы измеряли на ClickHouse, но правило шире. Посмотрим на четыре базы, которые умеют подставлять предагрегат сами.

Snowflake. Materialized view может быть построена только по одной таблице — JOIN в определении запрещён прямо в документации. Значит, чтобы оптимизатор её подставил, в запросе должна быть изолированная агрегация по этой таблице. Та же форма.

BigQuery. Materialized view с автоподстановкой (Google называет это smart tuning) должна использовать «тот же набор базовых таблиц, что и запрос». Если MV построена по sales, а запрос склеивает sales со stores и группирует по региону — набор таблиц другой. Та же форма.

ClickHouse — мы уже видели: проекция строго однотабличная.

StarRocks — единственное исключение. Его асинхронные materialized views переписывают запросы с JOIN: оптимизатор умеет сопоставить «склеили и свернули» с «свернули и склеили» и сам делает нужную перестановку. На StarRocks обе формы запроса работают одинаково хорошо. Но и вторая форма ему не мешает — а значит, её можно использовать как единую для всех.

Вывод: в трёх из четырёх «умных» баз предагрегат однотабличный, и одна и та же форма запроса открывает его во всех четырёх. Это не особенность ClickHouse — это свойство того, как оптимизаторы сопоставляют запрос с предагрегатом.

А что с остальными четырьмя? В Databricks, Trino и Greenplum materialized views есть, но обращаться к ним надо по имени: никакая форма запроса к sales не заставит базу заглянуть в агрегат. В DuckDB агрегат — это просто таблица, которую вы пересобираете скриптом. Здесь подставлять должен тот, кто генерирует SQL, — BI-инструмент. И вот тут начинается самое интересное.

Чего база не знает

Представьте, что база — даже самая умная — подставляет предагрегат идеально. Все ли вопросы сводной она сможет из него ответить?

Нет. Потому что часть знаний о данных в базе просто отсутствует:

  • Иерархии. Что дни складываются в месяцы, месяцы в кварталы, а магазины в регионы — это знает аналитик и модель данных. База видит колонки.

  • Неаддитивные меры. Сумму продаж можно досуммировать из дневного агрегата в месячный. Средний чек — нельзя: среднее из средних — ошибка. Из агрегата его можно получить, только если там лежат отдельно сумма и количество. Остатки на складе нельзя складывать по дням вообще — нужен остаток на последний день.

  • Уникальные значения. «Сколько разных покупателей» из агрегата по дням не сложить в месяц — один покупатель приходил тридцать раз. Все четыре «умные» базы решают это либо приближённо (HyperLogLog), либо точно, но специальными структурами (bitmap в StarRocks). BI-слою нужно знать, что именно лежит в агрегате.

  • Права доступа. Пользователь видит только свой регион. Фильтр прав должен применяться и к сырым данным, и к агрегату — иначе агрегат посчитан по всем, а сводная показывает лишнее.

  • Свежесть. Агрегат обновился в шесть утра, продажи идут весь день. Можно ли отвечать из агрегата в три часа дня? Зависит от отчёта. Базы здесь ведут себя по-разному: проекция ClickHouse свежа всегда (она физически часть таблицы), BigQuery дочитывает разницу из основной таблицы, StarRocks спрашивает у вас, сколько отставания вы терпите, а Trino по умолчанию молча отдаёт старое, пока кто-то не запустит REFRESH.

Всё это — знание о смысле данных, а не об их хранении. Место для него давно придумано и называется семантическим слоем: описание того, что такое магазин и регион, какие меры складываются, какие уровни есть у времени. И индустрия последовательно переносит задачу предагрегатов именно туда. У Looker это называется aggregate awareness и существует много лет; в Power BI — «агрегации»; AtScale построен вокруг этого целиком. Самый свежий пример — Databricks: в 2026 году ответом на вопрос «как заставить запросы использовать предагрегаты» стала не новая фича базы, а семантический слой — metric views, для которых движок сам подбирает материализацию (пока в статусе preview).

Логика везде одна. База хранит и обновляет агрегаты — это она умеет лучше всех. А решает, какой агрегат подходит под этот вопрос, движок запросов, у которого есть семантическая модель: он знает про иерархии, аддитивность, права и свежесть. Ему же проще всего сформировать запрос в форме «сначала свернуть, потом склеить» — той, при которой база подставит свой предагрегат сама, если умеет.

Кто будет писать запросы завтра

До сих пор запросы к базе писал человек — или BI-инструмент по действиям человека. Это меняется: аналитикой начинают заниматься агенты. Модель получает вопрос «почему просели продажи в Поволжье», и дальше сама решает, что спросить у данных.

Два следствия. Первое: агент, которому дали доступ к таблице фактов, напишет ровно ту форму запроса, с которой мы начали, — JOIN справочника, фильтр по названию, GROUP BY по названию. Это самая естественная форма для человека, и для модели тоже: она училась на таком SQL. Второе: агент делает не один запрос, а десятки подряд — уточняет, сужает, перепроверяет, сравнивает с прошлым годом. Если каждый идёт в миллиард строк, это уже не «аналитик ждёт двадцать секунд» — это база, которую положили одним вопросом.

Поэтому агент не должен писать SQL к таблице фактов. Он должен спрашивать семантический слой — «продажи по регионам за 2025 год» — а движок под ним обязан превратить это в оптимальный запрос: с ключами, со свёрткой до склейки, с агрегатом, если он есть. Протокол для такого разговора уже сложился — MCP, и семантические слои его осваивают.

Вот как выглядит тот же вопрос, что задавала сводная Excel, когда его задаёт агент — реальный вызов инструмента query_cube через MCP к кубу на нашем стенде:

{
  "cube": "blog_sales",
  "dimensions": ["Region", "Month"],
  "measures": ["Sales Amount"],
  "filters": [
    {"level": "Year",   "type": "in", "values": ["2025"]},
    {"level": "Region", "type": "in", "values": ["Central", "South"]}
  ]
}

И ответ — 24 строки, те же числа, что в сводной:

{"rows": [
  {"Region": "Central", "Month": "2025-01", "Sales Amount": 8792166529.94},
  {"Region": "Central", "Month": "2025-02", "Sales Amount": 7928933171.09},
  ...
  {"Region": "South",   "Month": "2025-12", "Sales Amount": 8784483049.25}
], "row_count": 24}

Ни одной строки SQL. Агент перед этим вызвал describe_cube, узнал, что у куба есть Region, Month и Sales Amount, и задал вопрос в этих терминах. Какой SQL при этом уходит в базу и насколько он оптимален — целиком ответственность движка под MCP. Аналитик, агент и сводная Excel этого не видят и не должны видеть. В этом и цель.

Что проверить у себя

  1. Узнайте класс своей базы (таблица в приложении): подставляет ли она предагрегаты сама, и по какой форме запроса.

  2. Посмотрите план запроса, который генерирует ваш BI-инструмент для типичной сводной. В ClickHouse — EXPLAIN indexes = 1; если в плане нет имени проекции — она не используется.

  3. Если агрегация в запросе стоит после JOIN — предагрегат не сработает почти нигде. Нужна форма «сначала свернуть, потом склеить».

  4. Меры вида «среднее», «уникальные», «остаток» планируйте отдельно: агрегат должен хранить компоненты (сумма и количество, HLL-скетч, остаток на дату), а BI-слой — знать об этом.

  5. Определите политику свежести для каждого агрегата и проверьте, что она совпадает с ожиданиями пользователей отчёта.

  6. Если ваша база из класса «подставляй сам» — ищите aggregate awareness в BI-инструменте. Без него агрегаты будут лежать без дела.

  7. Если к данным подключаете агентов — давайте им семантический слой, а не таблицу фактов.


Приложение А. Восемь баз: кто подставляет предагрегат сам

MV — materialized view, материализованное представление; rollup — ответ на запрос более грубой гранулярности из агрегата более тонкой (месяц из дней).

База

Механизм

Подставляет сама

JOIN в предагрегате

Rollup

Точный count distinct

Свежесть

Ограничения

ClickHouse

projections

да

нет, одна таблица

да

состояния uniqExact

всегда (часть таблицы)

не работает с FINAL и lightweight delete

StarRocks

sync / async MV

да

да, 7 типов JOIN

да, с таблицей соответствий функций

да, через bitmap

политика: query_rewrite_consistency, mv_rewrite_staleness_second

JDBC-каталоги без rewrite

BigQuery

materialized views (smart tuning)

да, только инкрементальные MV

INNER JOIN в определении, но «тот же набор таблиц»

да

нет, только HLL

всегда: дельта дочитывается из таблицы; опц. max_staleness

100 MV на таблицу; не для LEFT JOIN/UNION ALL

Snowflake

materialized views

да, не гарантировано

нет, одна таблица

да

нет, только APPROX

всегда, фоновый сервис за кредиты

только Enterprise Edition и выше

Databricks

MV в Unity Catalog; metric views

нет (metric views — да, но запрос должен идти к metric view)

да

metric views: для аддитивных мер

нет

по расписанию / TRIGGER ON UPDATE

нужен serverless; материализация metric views — Preview

Trino

MV в Iceberg-коннекторе

нет (issue #20850 открыт)

да

GRACE PERIOD, по умолчанию бесконечный

refresh только вручную/внешним планировщиком

Greenplum

MV (наследие PostgreSQL)

нет (AQUMV — только в форке Cloudberry)

да

REFRESH вручную; в 7.7 — инкрементально, но без агрегатов

DuckDB

нет

нет

MV в roadmap как «Future Work / Looking for Funding»

Источники — официальная документация по состоянию на сентябрь 2026: ClickHouse projections, ALTER PROJECTION; StarRocks query rewrite, CREATE MATERIALIZED VIEW; BigQuery smart tuning, limitations; Snowflake materialized views, editions; Databricks materialized views, metric views materialization; Trino CREATE MATERIALIZED VIEW, Iceberg connector, issue #20850; Greenplum materialized views, Cloudberry AQUMV; DuckDB roadmap, discussion #3638.

Честные пробелы: формальные правила сопоставления GROUP BY для агрегатных проекций ClickHouse в официальной документации не описаны (rollup «день → месяц» подтверждён нашим экспериментом и сторонними разборами); у BigQuery сценарий «MV покрывает часть JOIN-запроса» как поддерживаемый не описан.

Приложение Б. Как измеряли

ClickHouse 25.3 (Managed Service, Yandex Cloud), один узел, 12 vCPU, 48 ГБ памяти. Таблица sales: 1 000 000 000 строк за 2024-01-01…2026-08-31, PARTITION BY toYYYYMM(date), ORDER BY (store_id, date, product_id), после OPTIMIZE FINAL — 32 парта, 7,56 ГиБ; справочники stores (500 строк) и products (5000 строк). Данные синтетические, без сезонности. Проекция p_store_day — 487 тыс. строк, 4,9 МиБ, материализация 16 с. Каждый запрос выполнялся четыре раза, первый прогон отброшен; время — медиана query_duration_ms из system.query_log, прочитанные строки — read_rows оттуда же; кэш запросов выключен. Скрипты генерации данных и замера вышлю по запросу.

Запрос

Без проекции

С проекцией

JOIN первым, 200 магазинов

3,25 с · 375 млн строк

3,03 с · 375 млн строк

Свернуть первым, 200 магазинов

0,68 с · 164 млн строк

37 мс · 184 тыс. строк

JOIN первым, 5 магазинов

1,69 с · 375 млн строк

1,71 с · 375 млн строк

Свернуть первым, 5 магазинов

34 мс · 4,3 млн строк

15 мс · 184 тыс. строк

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы используете ли предагрегаты
0%база подставляет сама0
100%обращаемся явно1
0%есть, но не уверены, что работают0
0%нет0
0%не знаю, что это0
Проголосовал 1 пользователь. Воздержавшихся нет.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какая база у вас под аналитикой
100%ClickHouse1
0%Snowflake0
100%BigQuery1
0%Databricks0
0%StarRocks0
0%Trino0
0%Greenplum0
0%DuckDB0
0%Другая0
Проголосовал 1 пользователь. Воздержавшихся нет.