Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 07.08 · 22 мин
Средний
Lazy Materializationquery_plan_optimize_lazy_materializationTop-N QueriesI/O OptimizationEXPLAIN actionsPREWHERE

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 выглядит так:

  1. Прочитать с диска все 4 столбца для всех 150 миллионов строк.
  2. Отсортировать по helpful_votes.
  3. Вернуть первые 3 строки.

Узкое место — шаг 1. Колонки product_title, review_headline и review_body — это переменной длины строки (десятки гигабайт совокупно), а в результат попадает всего 3 строки. Получается: ClickHouse читает 70+ GB строковых данных, которые сразу отбрасываются.

INFO

Ключевое наблюдение: для ORDER BY достаточно прочитать только helpful_votes. Остальные тяжёлые колонки нужны исключительно для финальной выдачи трёх строк.


Как работает lazy materialization

Lazy materialization меняет порядок операций в pipeline:

  1. Прочитать только helpful_votes (плюс PREWHERE-колонки, если есть).
  2. Отсортировать строки по helpful_votes.
  3. Применить LIMIT 3 — остаются 3 строки с конкретными row positions.
  4. Только теперь прочитать product_title, review_headline, review_body — но только для этих 3 строк.
Eager vs. Lazy materialization: путь чтения колонок
Eager: читаем все 4 столбца для 150M строкEager (старое поведение): ClickHouse читает все 4 столбца для всех 150M строк. Тяжёлые строковые колонки product_title, review_headline, review_body занимают десятки гигабайт -- I/O полный.
Сортировка по helpful_votesСортировка по helpful_votes. Все 150M строк уже в памяти со всеми колонками -- буфер сортировки огромный, нужны гигабайты RAM.
LIMIT 3 -- 3 строки из 150MLIMIT 3 -- из 150M строк остаются 3. 99.999998% прочитанных строковых данных отбрасывается.
Без оптимизации219 сек, 71 GB I/O, 1.11 GiB RAMРеальные числа из блога ClickHouse 25.4 на Amazon reviews. Память пухнет от строковых колонок, время уходит на чтение и сортировку огромного буфера.
Lazy: читаем только helpful_votesLazy: читаем только helpful_votes (UInt32, 4 байта на строку) для всех 150M строк. Остальные 3 тяжёлые колонки пока не трогаем.
Сортировка + сохранение row positionsСортировка лёгкая: сортируем 150M значений UInt32. Параллельно сохраняем row positions -- какие физические позиции у каждой строки.
LIMIT 3 -- известны 3 позицииLIMIT 3: остаются 3 row positions. Теперь точно известно, какие именно строки нужно дочитать.
Чтение 3 тяжёлых колонок для 3 строкДочитываем product_title, review_headline, review_body ТОЛЬКО для 3 row positions. Точечный I/O -- читаем 3 значения из каждой колонки вместо 150M.
С lazy materialization0.139 сек, 1.81 GB I/O, 3.80 MiB RAM1576x быстрее, 40x меньше I/O, 300x меньше памяти. Реальные цифры из блог-поста ClickHouse 25.4.

Когда срабатывает оптимизация

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.
NOTE

Lazy materialization особенно мощно работает на широких таблицах с тяжёлыми колонками: длинные String, Array(String), JSON, Map. Для таблиц из узких числовых колонок выигрыш скромнее — читать UInt32 по всем строкам не так дорого.


Lazy materialization vs. PREWHERE

Это две разные оптимизации, которые дополняют друг друга, а не заменяют:

АспектPREWHERELazy materialization
Когда читаются неосновные колонкиСразу после применения фильтра WHEREПосле сортировки и LIMIT
ТриггерWHERE с селективным фильтромORDER BY + LIMIT (или просто LIMIT)
Что отсекаетСтроки, не прошедшие фильтрСтроки, не попавшие в Top N
Работает без фильтраНетДа — speedup даже для запросов без WHERE
Уровень оптимизацииStorage (MergeTree)Query plan (физический план)

Порядок при их совместном применении:

  1. PREWHERE-колонки читаются первыми, фильтр применяется.
  2. Сортировочная колонка читается для строк, прошедших PREWHERE.
  3. ORDER BY + LIMIT N оставляет N row positions.
  4. Тяжёлые SELECT-колонки дочитываются для этих N строк.
TIP

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, оптимизация не применяется
WARNING

Если вы запускаете 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 без обращения к локальной таблице.
  • Таблица очень маленькая — там и без оптимизации всё быстро.
INFO

Перед обновлением до 25.4+ полезно прогнать типичные production-запросы на staging с включённой и выключенной оптимизацией. На таблицах с проекциями могут всплыть баги — лучше обнаружить их до прода.


Ключевые выводы

  1. Lazy materialization (25.4+) откладывает чтение тяжёлых колонок до момента после ORDER BY + LIMIT, читая их только для финальных N строк.
  2. Реальный benchmark ClickHouse на Amazon reviews — 1576x speedup, 40x меньше I/O, 300x меньше памяти на запросе SELECT * … ORDER BY … LIMIT 3.
  3. Не заменяет PREWHERE — это разные оптимизации: PREWHERE отсекает строки до чтения остальных колонок, lazy materialization откладывает чтение тяжёлых колонок до после LIMIT. Они работают вместе.
  4. Настройки: query_plan_optimize_lazy_materialization = 1 (default), query_plan_max_limit_for_lazy_materialization (default 10 в 25.4, 10000 в 25.12+).
  5. Проверка: EXPLAIN actions = 1 показывает строку Lazily read columns: ... для отложенных колонок.
  6. Ограничения: не работает для GROUP BY, JOIN, window functions, Distributed, mutations; известны баги на проекциях.
Parquet: column chunks, page offsets и late materialization Catalyst Optimizer: Physical Plan и projection pruning

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 8