Splits: генерация и lazy split generation
В таксономии split — это кусок данных, над которым работает задача. Простое определение, но за ним стоит механика, которая прямо влияет на параллелизм запроса: что именно становится split, кто их создаёт, и почему движок не создаёт все splits сразу. Этот урок разбирает split как единицу работы и понятие lazy split generation.
Splits — это то, через что распределённость превращается из идеи в конкретное «вот эти данные читает этот воркер».
Split как единица параллельного чтения
Большая таблица не читается одним потоком — иначе распределённость бессмысленна. Таблицу надо разбить на части, и каждую часть отдать отдельной задаче. Split — это и есть такая часть: секция набора данных, единица работы для task.
Связь со стадиями прямая. У source-стадии (читающей таблицу) задачи получают splits и обрабатывают их. Число splits определяет потенциальный параллелизм чтения: 1000 splits можно разложить на 1000 параллельных порций работы; 1 split — это один поток, никакого параллелизма.
Что физически становится split
Trino не решает сам, что такое split, — это решает коннектор. И решение зависит от природы источника, потому что у разных источников разная физическая структура данных.
| Источник | Что коннектор делает split-ом |
|---|---|
| Hive / Iceberg / Delta | Файл целиком, либо часть файла (диапазон байт), либо отдельная row-group внутри файла |
| Kafka | Раздел (partition) топика или диапазон offset-ов внутри раздела |
| PostgreSQL и другие JDBC | Обычно один split на всю таблицу — JDBC-чтение по умолчанию не партиционируется |
| TPC-H / TPC-DS | Логический сегмент сгенерированного диапазона данных |
Здесь видна важная закономерность. Для object storage коннектор может нарезать очень много splits — файлов и row-group-ов в таблице тысячи, и каждый становится отдельной порцией. Это даёт высокий параллелизм чтения. Для JDBC-источника коннектор по умолчанию делает один split на таблицу — реляционная база отдаёт результат одним потоком через одно соединение. Поэтому, как мы отмечали в модуле про коннекторы, fact-таблицы на больших объёмах держат на object storage: там много splits и читать их можно массово-параллельно.
Parquet: row groups как физическая основа splits Kafka: партиции топика как естественная граница splitsРазмер split — это компромисс. Слишком крупные splits — мало порций, низкий параллелизм, неравномерная загрузка (одна задача доедает огромный split, остальные простаивают). Слишком мелкие splits — порций очень много, и накладные расходы на управление каждой порцией начинают съедать выгоду. Коннекторы стремятся к разумному размеру split — достаточно мелкому для параллелизма, достаточно крупному, чтобы накладные расходы были малы.
Split — это не обязательно физический файл. Split это логическое описание порции данных: “файл X, байты с 0 по 128 МБ” или “топик T, раздел 3, offsets 1000-5000”. Коннектор создаёт такие описания, а задача потом по описанию читает реальные данные через PageSource. Один большой файл может породить несколько splits — по одному на row-group, — чтобы один файл читался параллельно несколькими задачами.
Откуда splits берутся: SplitManager и SplitSource
Splits создаёт сервис коннектора SplitManager — мы видели его в модуле про анатомию SPI. Когда движку нужно прочитать таблицу, он обращается к SplitManager этого коннектора.
Но SplitManager не возвращает сразу готовый список всех splits. Он возвращает SplitSource — источник splits. SplitSource — это объект, у которого движок запрашивает splits, забирая их батчами (порциями) по мере необходимости. Движок говорит SplitSource «дай следующую партию splits», получает её, раздаёт задачам, потом просит следующую.
Почему именно батчами через SplitSource, а не одним готовым списком — это и есть суть следующего раздела.
Lazy split generation: зачем ленивость
Lazy split generation — ленивая генерация сплитов — означает, что splits создаются постепенно, по мере необходимости, а не все разом в начале запроса. Это сознательное проектное решение, и у него три веских причины.
Причина 1: память. Представьте таблицу-озеро из 500 000 файлов. Если коннектор создаст 500 000 объектов-splits разом и сложит в память — это заметный объём ещё до того, как прочитана хоть одна строка. Ленивая генерация держит в памяти только текущие батчи splits, а не весь миллион.
Причина 2: часть splits может не понадобиться. Запрос с LIMIT 100 может остановиться, прочитав несколько первых splits, — остальные просто не нужны. Ещё ярче — dynamic filtering: движок на лету понимает, что часть партиций таблицы заведомо не подходит под join-условие, и просит SplitSource эти splits вообще не генерировать. Если бы все splits создавались заранее, эта экономия была бы невозможна — нельзя «не создавать» то, что уже создано.
Причина 3: ранний старт. Движку не нужно ждать, пока коннектор перечислит все файлы таблицы, чтобы начать работу. Как только готов первый батч splits — задачи могут начинать читать, пока коннектор параллельно генерирует следующие батчи. Запрос стартует быстрее, перечисление splits и их обработка идут внахлёст.
Связь с производительностью: ленивая генерация splits — это то, что делает dynamic filtering по-настоящему полезным. Динамический фильтр урезает множество splits ещё на этапе их генерации — задачи даже не получают splits для отсеянных партиций. Если бы splits материализовались заранее, фильтр мог бы только пропускать уже созданные splits, но не экономить на их создании и не сокращать работу планировщика. Подробнее dynamic filtering разбирается в модуле про cost-based optimizer.
Splits, задачи и параллелизм — цельная картина
Сведём split в общую механику исполнения. Distributed planning дал дерево стадий. У source-стадии движок запрашивает у коннектора splits — получает SplitSource. Из SplitSource движок батчами забирает splits и раздаёт их задачам этой стадии на разных воркерах. Задача получает свой набор splits и обрабатывает их силами своих драйверов.
Число splits задаёт верхнюю границу параллелизма чтения: больше splits — больше порций, которые можно раздать параллельно работающим задачам и драйверам. Но это именно потенциал: реализуется он, если воркеров и драйверов достаточно, чтобы splits разобрать. Если splits 1000, а драйверов в сумме 50, splits будут обрабатываться волнами по 50 за раз.
Поэтому split — это связующее звено таксономии: он соединяет «данные в источнике» с «задачами и драйверами, которые их читают». Коннектор решает, что такое split; SplitManager через SplitSource отдаёт их лениво; движок раздаёт их задачам; драйверы их переваривают.
Попробуй сам
Поведение splits видно в EXPLAIN ANALYZE и в Web UI:
- Выполните
EXPLAIN ANALYZE SELECT count(*) FROM tpch.sf10.lineitem. В деталях source-стадии найдите число обработанных splits. Сопоставьте его с числом строк. - Запустите тот же запрос на
tpch.sf1иtpch.sf100. Сравните число splits — оно должно расти с объёмом данных. - Откройте Web UI, страницу запроса, и посмотрите, как splits источника распределились по задачам — равномерно ли.
- Подумайте над JDBC-источником: если бы вы делали
count(*)для большой таблицы PostgreSQL, сколько splits ожидать по умолчанию и почему такой запрос не распараллелится по чтению. - Сформулируйте письменно три причины, по которым генерация splits ленивая, и какую из них особенно усиливает dynamic filtering.