Сквозной тюнинг: партиционирование, форматы и размеры файлов
Предыдущие уроки модуля учили диагностировать запросы и кластер. Но самый дешёвый и самый недооценённый рычаг производительности lakehouse лежит ниже уровня запроса — это физический слой хранения: как данные разложены по файлам в object storage. Запрос, который сканирует терабайт там, где хватило бы десяти гигабайт, нельзя «дотюнить» ни статистикой, ни сменой join-стратегии. Его лечит только правильная раскладка данных.
Три рычага этого слоя: схема партиционирования (как данные разбиты на куски), файловый формат (как байты внутри файла организованы) и размер файлов (проблема мелких файлов). Этот урок разбирает все три и показывает, как Trino-инструменты помогают их настроить.
Партиционирование: меньше читать
Партиционирование — это разбиение таблицы на физические части по значению одной или нескольких колонок. Таблица событий, партиционированная по дню, физически разложена по каталогам-партициям: данные за каждый день — отдельная группа файлов. Когда запрос фильтрует по дню, Trino применяет partition pruning — пропускает целые партиции, не открывая ни одного их файла.
Эффект прямой: запрос с WHERE event_date = DATE '2026-05-11' по таблице за три года читает 1 партицию из примерно 1100. Это не оптимизация выполнения — это сокращение работы на порядки ещё до того, как выполнение началось.
В Iceberg партиционирование задаётся при создании таблицы и поддерживает transforms — функции, превращающие значение колонки в значение партиции:
CREATE TABLE iceberg.analytics.events (
event_id BIGINT,
user_id BIGINT,
event_type VARCHAR,
event_time TIMESTAMP(6),
payload VARCHAR
)
WITH (
partitioning = ARRAY['day(event_time)', 'event_type']
);
Здесь day(event_time) — transform: партиция определяется днём из timestamp, отдельная колонка-дата не нужна. Это hidden partitioning Iceberg: запросы фильтруют по event_time, а Trino сам выводит партиции. Доступные transforms: year, month, day, hour, bucket(col, n), truncate(col, n), identity.
Ключевое решение — гранулярность. Здесь баланс, и обе крайности плохи.
Слишком крупные партиции (по году) почти не дают pruning — partition pruning не сужает скан. Слишком мелкие (по часу плюс ещё две колонки) порождают десятки тысяч партиций с крошечными файлами: метаданных становится больше, чем данных, а планирование запроса само по себе замедляется. Практическое правило: партиция должна соответствовать типичному фильтру запросов и при этом содержать файлы разумного размера. Для большинства событийных таблиц это партиционирование по дню; колонку с очень высокой кардинальностью (user_id) в партиционирование не берут — берут bucket(user_id, N), который раскладывает значения по фиксированному числу бакетов.
Partition pruning срабатывает, только когда фильтр приложен к партиционирующему выражению напрямую. WHERE event_time >= TIMESTAMP '2026-05-01 00:00:00' сужает скан; WHERE cast(event_time AS date) = DATE '2026-05-11' или WHERE date_format(event_time, '%Y') = '2026' могут помешать pruning, потому что фильтр обёрнут в функцию над колонкой. Проверяйте через EXPLAIN, сколько партиций реально остаётся после фильтра.
Файловый формат: как организованы байты
Партиционирование решает, какие файлы открыть. Формат файла решает, насколько эффективно читается то, что внутри. Для аналитики на Trino стандарт — колоночные форматы: Parquet и ORC. Понимать, почему они быстрее построчных, важно для тюнинга.
Parquet: row groups, страницы и min/max статистикаКолоночный файл хранит значения одной колонки подряд. Это даёт три механизма ускорения. Первый — колоночное чтение: запрос SELECT user_id, event_type физически читает с диска только эти две колонки, а не весь файл; projection pushdown работает на уровне байт. Второй — сжатие: соседние значения одной колонки однородны, поэтому сжимаются в разы лучше разнотипных строк. Третий — пропуск блоков по статистике: и Parquet, и ORC бьют файл на крупные блоки (row groups в Parquet, stripes в ORC) и хранят для каждого блока min/max значений колонок. Если фильтр WHERE amount > 1000 не пересекается с диапазоном min/max блока, блок не читается вообще.
Эти уровни работают вместе: партиции отсекают группы файлов, статистика блоков — куски внутри файла, projection — ненужные колонки, dynamic filtering добавляет рантайм-отсев по join-ключу. Колоночный формат — то, что делает три нижних уровня возможными; на построчном CSV или JSON не работает ни один из них, поэтому такие форматы для горячих аналитических таблиц непригодны. CSV и JSON остаются разумны только для зоны приёма сырых данных, откуда их сразу перекладывают в Parquet или ORC.
| Формат | Тип | Аналитика на Trino |
|---|---|---|
| Parquet | Колоночный | Стандарт для lakehouse, широкая совместимость |
| ORC | Колоночный | Сильная компрессия и статистика, исторически из Hive |
| Avro | Построчный, со схемой | Для приёма и потоков, не для горячих сканов |
| CSV / JSON | Построчный, без статистики | Только зона приёма сырых данных |
Проблема мелких файлов
Третий рычаг — размер файлов, и это самая частая болезнь продакшен-lakehouse. Каждое чтение файла из object storage — это отдельный сетевой запрос с фиксированными накладными расходами: установка соединения, round-trip, открытие, чтение футера с метаданными. На файле в 256 MB эти накладные расходы незаметны. На файле в 200 KB они доминируют — Trino тратит больше времени на «открыть-закрыть», чем на полезное чтение.
Мелкие файлы накапливаются естественно. Каждый частый INSERT, каждый микробатч стрима, каждая запись в партицию добавляет новые файлы — и почти всегда мелкие. Через месяц частых вставок партиция вместо нескольких крупных файлов содержит тысячи крошечных. Симптомы в диагностике, которую вы уже умеете снимать: в EXPLAIN ANALYZE у source-стадии огромное число splits при скромном объёме данных; в Web UI source-стадия долго планируется и копит wall-время; растёт нагрузка на metastore.
Лечение — компакция: переписать много мелких файлов в несколько крупных. В Iceberg это процедура OPTIMIZE:
-- Скомпактить файлы мельче порога во всей таблице
ALTER TABLE iceberg.analytics.events EXECUTE optimize;
-- Скомпактить только свежую партицию (дешевле и точечнее)
ALTER TABLE iceberg.analytics.events EXECUTE optimize
WHERE event_time >= TIMESTAMP '2026-05-01 00:00:00';
OPTIMIZE читает мелкие файлы и пишет вместо них крупные; параметр file_size_threshold (по умолчанию около 100 MB) задаёт, что считать мелким. У Delta Lake есть аналогичная процедура optimize. Цель компакции — файлы в диапазоне примерно от сотни мегабайт до нескольких сотен: достаточно крупные, чтобы амортизировать накладные расходы на открытие, но не настолько, чтобы помешать параллелизму, ведь один файл — это обычно один или несколько splits.
Компакция — не разовое действие. В активно пополняемой таблице мелкие файлы появляются постоянно, поэтому OPTIMIZE и сопутствующие операции обслуживания (expire_snapshots для удаления старых снапшотов, remove_orphan_files) ставят на расписание — обычно компактят свежие партиции по фильтру WHERE, а не всю таблицу целиком. Без регулярного обслуживания lakehouse деградирует постепенно и незаметно, пока запросы не станут ощутимо медленными.
Bucketing и порядок данных внутри файла
Партиционирование, формат и размер файлов — три основных рычага, но есть и четвёртый, тоньше: как данные упорядочены внутри файлов. Он не нужен каждой таблице, но в правильном случае даёт заметный выигрыш.
Колонку с очень высокой кардинальностью — например, user_id, где миллионы различных значений, — нельзя брать в партиционирование: получится взрыв партиций. Но если запросы часто фильтруют или джойнят по этой колонке, помогает bucketing. Bucketing раскладывает строки по фиксированному числу бакетов по хэшу значения: bucket(user_id, 64) в Iceberg-партиционировании означает 64 бакета вместо миллионов партиций. Запрос с фильтром по user_id тогда обращается только к нужным бакетам, а не ко всем файлам. Bucketing особенно ценен для join: если обе таблицы забакечены по join-ключу, Trino может соединять соответствующие бакеты без полного repartition.
Второй приём — порядок строк внутри файла. Вспомните механизм пропуска блоков по min/max статистике: row group пропускается, если диапазон значений колонки не пересекается с фильтром. Этот механизм работает тем лучше, чем плотнее значения сгруппированы. Если данные физически отсортированы по часто фильтруемой колонке, диапазоны min/max соседних row groups узкие и почти не перекрываются — фильтр отсекает много блоков. Если же та же колонка разбросана по файлу случайно, каждый row group покрывает почти весь диапазон значений, и min/max-статистика бесполезна — пропустить нельзя ничего. Поэтому при компакции через OPTIMIZE или при загрузке данных имеет смысл задавать сортировку по ключевой для фильтрации колонке.
| Приём | Когда применять | Что даёт |
|---|---|---|
| Партиционирование | Колонка средней кардинальности, частый фильтр | Partition pruning: пропуск целых партиций |
| Bucketing | Колонка высокой кардинальности, фильтр или join | Обращение к нужным бакетам, эффективный join |
| Сортировка внутри файла | Частый фильтр по диапазону | Узкие min/max, эффективный пропуск row groups |
Эти приёмы не взаимоисключающие: таблицу можно партиционировать по дню, бакетировать по user_id и сортировать внутри по event_time одновременно. Но каждый дополнительный приём — это усложнение, и применять их стоит, только когда понятен профиль запросов к таблице.
Сквозной чек-лист тюнинга хранения
Три рычага складываются в порядок действий: сначала убедиться, что запрос вообще читает только нужное, и лишь потом тюнить выполнение.
| Рычаг | Вопрос | Инструмент проверки |
|---|---|---|
| Партиционирование | Соответствует ли схема партиций реальным фильтрам? | EXPLAIN: сколько партиций после фильтра |
| Формат файла | Колоночный ли формат у горячих таблиц? | DDL таблицы, метаданные |
| Размер файлов | Нет ли тысяч мелких файлов в партиции? | EXPLAIN ANALYZE: число splits; $files |
| Обслуживание | Регулярно ли идёт OPTIMIZE и очистка снапшотов? | История EXECUTE, расписание |
Этот слой первичен. Filesystem caching из прошлого урока, статистика и dynamic filtering из модуля по CBO — всё это ускоряет работу с данными, которые запрос читает. Партиционирование, формат и размер файлов определяют, сколько данных он вообще читает. Сократить объём чтения почти всегда дешевле и эффективнее, чем ускорять чтение лишнего.
Попробуй сам
На Iceberg-каталоге (или используя tpch как источник данных) поэкспериментируйте с физической раскладкой.
- Создайте две версии таблицы из
tpch.sf1.lineitem: одну без партиционирования, вторую сpartitioning = ARRAY['month(shipdate)']. Залейте данные черезCREATE TABLE ... AS SELECT. - Выполните
EXPLAIN ANALYZEдля запроса с фильтром поshipdateна обеих таблицах. Сравните число splits и объём прочитанных данных на source-стадии. - Сделайте в партиционированную таблицу несколько отдельных мелких
INSERT(по несколько тысяч строк). Посмотрите метадату-таблицу$files— сколько файлов и какого размера появилось. - Выполните
ALTER TABLE ... EXECUTE optimizeи снова посмотрите$files: как изменилось число и размер файлов. - Повторите
EXPLAIN ANALYZEтого же запроса после компакции и сравните число splits с шагом 3.
Цель — увидеть своими глазами, как партиционирование сокращает скан, а компакция убирает накладные расходы мелких файлов.