Lazy Materialization (25.4): чтение колонок после фильтра
С версии 25.4 в ClickHouse появилась новая оптимизация плана запроса — lazy materialization. Идея: для запросов вида SELECT * FROM t ORDER BY x LIMIT N тяжёлые колонки (длинные строки, JSON, массивы) читаются с диска только для тех N строк, которые попадут в финальный результат, а не для всей таблицы.
В блог-посте ClickHouse приводится пример с Amazon reviews (150 миллионов строк): тот же запрос ускорился с 219 секунд до 0.139 секунды — ускорение в 1576 раз, при этом пиковое потребление памяти упало с 1.11 GiB до 3.80 MiB, а I/O — с 71.38 GB до 1.81 GB.
Проблема: чтение тяжёлых колонок впустую
Рассмотрим типичный аналитический запрос Top-N:
SELECT helpful_votes, product_title, review_headline, review_body
FROM amazon.amazon_reviews
ORDER BY helpful_votes DESC
LIMIT 3;
Без оптимизации pipeline выглядит так:
- Прочитать с диска все 4 столбца для всех 150 миллионов строк.
- Отсортировать по helpful_votes.
- Вернуть первые 3 строки.
Узкое место — шаг 1. Колонки product_title, review_headline и review_body — это переменной длины строки (десятки гигабайт совокупно), а в результат попадает всего 3 строки. Получается: ClickHouse читает 70+ GB строковых данных, которые сразу отбрасываются.
Ключевое наблюдение: для ORDER BY достаточно прочитать только helpful_votes. Остальные тяжёлые колонки нужны исключительно для финальной выдачи трёх строк.
Как работает lazy materialization
Lazy materialization меняет порядок операций в pipeline:
- Прочитать только helpful_votes (плюс PREWHERE-колонки, если есть).
- Отсортировать строки по helpful_votes.
- Применить LIMIT 3 — остаются 3 строки с конкретными row positions.
- Только теперь прочитать product_title, review_headline, review_body — но только для этих 3 строк.
Когда срабатывает оптимизация
Lazy materialization применяется для запросов конкретной формы:
| Паттерн | Срабатывает? |
|---|---|
| SELECT col1, col2 FROM t ORDER BY x LIMIT N | Да |
| SELECT * FROM t ORDER BY x LIMIT N | Да |
| SELECT * FROM t WHERE cond ORDER BY x LIMIT N | Да (комбинируется с PREWHERE) |
| SELECT * FROM t LIMIT N (без ORDER BY) | Зависит от version и threshold |
| SELECT col1, sum(col2) FROM t GROUP BY col1 | Нет — агрегация требует все строки |
| SELECT * FROM t1 JOIN t2 ON … | Нет — JOIN не покрывается |
| SELECT * FROM t WHERE rare_cond LIMIT 10 | Зависит — если LIMIT без ORDER BY, оптимизация может не сработать стабильно |
Главные условия:
- Есть ORDER BY или LIMIT N с детерминированным порядком.
- В SELECT нет агрегатных функций, GROUP BY, window functions.
- Запрос не идёт через Distributed-таблицу (см. ограничения ниже).
- N в LIMIT не превышает порог
query_plan_max_limit_for_lazy_materialization.
Lazy materialization особенно мощно работает на широких таблицах с тяжёлыми колонками: длинные String, Array(String), JSON, Map. Для таблиц из узких числовых колонок выигрыш скромнее — читать UInt32 по всем строкам не так дорого.
Lazy materialization vs. PREWHERE
Это две разные оптимизации, которые дополняют друг друга, а не заменяют:
| Аспект | PREWHERE | Lazy materialization |
|---|---|---|
| Когда читаются неосновные колонки | Сразу после применения фильтра WHERE | После сортировки и LIMIT |
| Триггер | WHERE с селективным фильтром | ORDER BY + LIMIT (или просто LIMIT) |
| Что отсекает | Строки, не прошедшие фильтр | Строки, не попавшие в Top N |
| Работает без фильтра | Нет | Да — speedup даже для запросов без WHERE |
| Уровень оптимизации | Storage (MergeTree) | Query plan (физический план) |
Порядок при их совместном применении:
- PREWHERE-колонки читаются первыми, фильтр применяется.
- Сортировочная колонка читается для строк, прошедших PREWHERE.
- ORDER BY + LIMIT N оставляет N row positions.
- Тяжёлые SELECT-колонки дочитываются для этих N строк.
PREWHERE отвечает за “не читать строки, которые отбросит WHERE”. Lazy materialization отвечает за “не читать тяжёлые колонки для строк, которые отбросит ORDER BY + LIMIT”. Вместе они дают многоуровневое сокращение I/O.
Настройки
Оптимизация управляется через query plan settings:
-- Включить lazy materialization (по умолчанию = 1 в 25.4+)
SET query_plan_optimize_lazy_materialization = 1;
-- Порог LIMIT, до которого применяется оптимизация
-- В 25.4 default = 10, в 25.12+ default увеличен до 10000
SET query_plan_max_limit_for_lazy_materialization = 10000;
Логика порога: для очень больших LIMIT (например, 100000) дополнительный random-access I/O при дочитке тяжёлых колонок может перевесить экономию. Поэтому при LIMIT выше порога ClickHouse возвращается к eager-чтению.
-- Полностью выключить (для отладки или fallback)
SET query_plan_optimize_lazy_materialization = 0;
Подтверждение через EXPLAIN
Самый надёжный способ убедиться, что оптимизация применена — посмотреть план запроса с actions = 1:
EXPLAIN actions = 1
SELECT helpful_votes, product_title, review_headline, review_body
FROM amazon.amazon_reviews
ORDER BY helpful_votes DESC
LIMIT 3
SETTINGS query_plan_optimize_lazy_materialization = 1;
В выводе появится секция вида:
Lazily read columns: product_title, review_headline, review_body
Это значит: на уровне ReadFromMergeTree из диска поднимается только helpful_votes, а перечисленные колонки помечены как lazy и будут материализованы позже — после LIMIT.
Если оптимизация не применилась, такой строки не будет, и в плане появится обычный ReadFromMergeTree со всеми колонками сразу.
Метрики через query_log
После выполнения запроса можно сравнить I/O и память:
SELECT
query_duration_ms,
formatReadableSize(memory_usage) AS memory,
formatReadableSize(read_bytes) AS read_bytes,
read_rows,
Settings['query_plan_optimize_lazy_materialization'] AS lazy_setting
FROM system.query_log
WHERE query LIKE '%amazon_reviews%'
AND type = 'QueryFinish'
ORDER BY event_time DESC
LIMIT 5;
Запустите запрос дважды — с query_plan_optimize_lazy_materialization = 0 и = 1. Разница в read_bytes и memory_usage покажет реальный эффект на ваших данных.
Ограничения
Lazy materialization не универсальна. Известные на момент 25.4 — 25.12 кейсы, когда оптимизация не работает или работает с проблемами:
| Случай | Поведение |
|---|---|
| Distributed-таблица или cluster() | Lazy materialization не попадает в план распределённого запроса — работает только при прямом обращении к локальной таблице |
| Tables with projections | Известны баги с block structure mismatch (issue #80201, исправляется в патчах) |
| GROUP BY, агрегации, window functions | Оптимизация не применяется — агрегаты требуют все строки |
| JOIN | Не покрывается — JOIN сам читает все нужные колонки |
| Очень большой LIMIT (выше порога) | Оптимизация отключается, потому что random-access I/O перевешивает экономию |
| Mutations (ALTER UPDATE/DELETE) | Используют старый pipeline, оптимизация не применяется |
Если вы запускаете lazy materialization на таблицах с проекциями или сложными ALIAS-колонками и видите ошибки вида NOT_FOUND_COLUMN_IN_BLOCK или AMBIGUOUS_COLUMN_NAME — это известные проблемы (issues #93186, #79926), которые активно правятся. Временное решение: отключить оптимизацию для проблемного запроса через SETTINGS query_plan_optimize_lazy_materialization = 0.
Сравнение: до и после
Полный пример с двумя запусками:
-- 1. Без lazy materialization (как было до 25.4)
SELECT helpful_votes, product_title, review_headline, review_body
FROM amazon.amazon_reviews
ORDER BY helpful_votes DESC
LIMIT 3
SETTINGS query_plan_optimize_lazy_materialization = 0;
-- Результат блог-поста: ~219 сек, 71 GB прочитано, 1.11 GiB RAM
-- 2. С lazy materialization (default в 25.4+)
SELECT helpful_votes, product_title, review_headline, review_body
FROM amazon.amazon_reviews
ORDER BY helpful_votes DESC
LIMIT 3
SETTINGS query_plan_optimize_lazy_materialization = 1;
-- Результат блог-поста: ~0.139 сек, 1.81 GB прочитано, 3.80 MiB RAM
Разница 1576x — не теоретический максимум, а измеренный benchmark на публичном датасете. На ваших данных выигрыш зависит от ширины таблицы, размера тяжёлых колонок и LIMIT.
Когда оптимизация не помогает
Lazy materialization не даст заметного выигрыша если:
- Все колонки SELECT — узкие числа (UInt32, Float64) и таблица не очень широкая.
- LIMIT превышает
query_plan_max_limit_for_lazy_materialization. - В запросе есть GROUP BY, JOIN или window functions — оптимизация просто не применится.
- Запрос идёт через Distributed без обращения к локальной таблице.
- Таблица очень маленькая — там и без оптимизации всё быстро.
Перед обновлением до 25.4+ полезно прогнать типичные production-запросы на staging с включённой и выключенной оптимизацией. На таблицах с проекциями могут всплыть баги — лучше обнаружить их до прода.
Ключевые выводы
- Lazy materialization (25.4+) откладывает чтение тяжёлых колонок до момента после ORDER BY + LIMIT, читая их только для финальных N строк.
- Реальный benchmark ClickHouse на Amazon reviews — 1576x speedup, 40x меньше I/O, 300x меньше памяти на запросе SELECT * … ORDER BY … LIMIT 3.
- Не заменяет PREWHERE — это разные оптимизации: PREWHERE отсекает строки до чтения остальных колонок, lazy materialization откладывает чтение тяжёлых колонок до после LIMIT. Они работают вместе.
- Настройки:
query_plan_optimize_lazy_materialization = 1(default),query_plan_max_limit_for_lazy_materialization(default 10 в 25.4, 10000 в 25.12+). - Проверка:
EXPLAIN actions = 1показывает строкуLazily read columns: ...для отложенных колонок. - Ограничения: не работает для GROUP BY, JOIN, window functions, Distributed, mutations; известны баги на проекциях.