Реальная федерация: join между PostgreSQL и Parquet на S3, антипаттерны
Этот урок собирает весь модуль воедино на одном сквозном сценарии — самом частом федеративном запросе в реальной дата-инженерии: join таблицы из реляционной базы с большой таблицей-фактом в lakehouse. Мы пройдём такой запрос от плана до результата, увидим, как Trino распределяет работу между источниками, и — главное — разберём антипаттерны: ошибки, из-за которых федеративный запрос из быстрого превращается в катастрофически медленный. Понимание этих ошибок отличает инженера, который умеет писать федеративные запросы, от того, кто просто знает их синтаксис.
Сценарий: dimension в PostgreSQL, fact в Iceberg на S3
Типичная задача. Есть большая fact-таблица событий events — сотни миллионов строк, лежит в Iceberg на S3 (аналитический слой lakehouse). И есть небольшая dimension-таблица customers — справочник клиентов, десятки тысяч строк, живёт в боевом PostgreSQL приложения. Нужен отчёт: выручка по странам клиентов за период. Данные в двух разных системах — это и есть классическая федерация.
-- Федеративный join: fact в Iceberg на S3, dimension в PostgreSQL
SELECT c.country,
count(*) AS events,
sum(e.amount) AS revenue
FROM iceberg.analytics.events e
JOIN postgresql.public.customers c
ON e.customer_id = c.customer_id
WHERE e.event_date >= DATE '2026-05-01'
AND c.segment = 'enterprise'
GROUP BY c.country
ORDER BY revenue DESC;
-- country | events | revenue
-- ---------+---------+-----------
-- DE | 1840200 | 4920100.00
-- US | 1502880 | 4115500.00
-- FR | 610440 | 1284900.00
Без федерации этот отчёт потребовал бы ETL-пайплайна, копирующего customers из PostgreSQL в lakehouse, расписания, мониторинга. С Trino — это один запрос. Но чтобы он был быстрым, надо понимать, что под ним происходит.
Как Trino распределяет работу между источниками
Trino исполняет федеративный join не как «скачать обе таблицы и сджойнить». Каждый источник обрабатывается своим коннектором, и к каждому применяется свой pushdown — ровно то, что мы разбирали в уроках 3 и 4.
К стороне PostgreSQL применяется JDBC-pushdown. Фильтр c.segment = 'enterprise' проталкивается в PostgreSQL — база вернёт не весь справочник, а только enterprise-клиентов. Projection pushdown оставит только нужные колонки — customer_id, country. PostgreSQL отдаст маленький отфильтрованный результат.
К стороне Iceberg применяется механика lakehouse из модулей 9-10. Фильтр e.event_date >= DATE '2026-05-01' запускает partition pruning: Trino по метаданным Iceberg отбрасывает партиции и файлы вне диапазона дат, читая только нужные Parquet-файлы из S3.
Сам join Trino выполняет у себя, распределённо: он соединяет отфильтрованные данные двух источников. Здесь работает важная оптимизация — dynamic filtering (она разбиралась в модуле про CBO). Сторона customers после фильтра по segment мала. Trino собирает с неё множество значений customer_id и формирует рантайм-фильтр, который применяется к чтению events: фактически Trino читает из Iceberg не все события за период, а только события тех клиентов, что вообще попали в результат. Dynamic filtering — то, что делает join «маленькое к большому» по-настоящему быстрым.
Когда всё это срабатывает, запрос быстрый: каждый источник отдал минимум данных, по сети проехало немного, join работает над компактными наборами. Когда что-то из этого ломается — начинаются антипаттерны.
Антипаттерн 1: запрос, который мешает pushdown
Самый частый антипаттерн — написать запрос так, что pushdown не срабатывает. Из урока 3: pushdown — возможность, а не гарантия, и нестандартное выражение в фильтре может его сорвать.
Опасный пример — обернуть фильтруемую колонку в функцию. Сравните:
-- ПЛОХО: функция над колонкой может помешать pushdown фильтра
WHERE lower(c.segment) = 'enterprise'
-- ХОРОШО: чистое сравнение колонки с константой — проталкивается охотно
WHERE c.segment = 'enterprise'
Если функция или её поведение не транслируются в источник точно, Trino не протолкнёт фильтр — он вытянет весь справочник customers и применит lower(...) у себя. Маленькая безобидная lower() превратила pushdown-фильтр в полное чтение таблицы. То же относится к фильтру по дате в fact-таблице: обернёте event_date в выражение — рискуете потерять partition pruning и прочитать всю таблицу-факт. Лечение — EXPLAIN (урок 3): посмотрите план, убедитесь, что фильтры ушли в источники, а не висят отдельными узлами в Trino.
Антипаттерн 2: тащить сырые данные, чтобы агрегировать в Trino
Второй антипаттерн — не дать источнику сделать работу, которую он сделал бы эффективно. Если из реляционной базы нужен агрегат, считать его должна база (aggregation pushdown), а не Trino над сырыми строками.
-- ПЛОХО: тянем все платежи в Trino, чтобы агрегировать здесь
SELECT customer_id, sum(amount)
FROM (SELECT customer_id, amount FROM postgresql.public.payments) t
GROUP BY customer_id;
-- ХОРОШО: позволяем PostgreSQL агрегировать у себя и отдать готовое
SELECT customer_id, sum(amount)
FROM postgresql.public.payments
GROUP BY customer_id;
Логически запросы эквивалентны, но конструкции, мешающие оптимизатору увидеть агрегацию насквозь, могут лишить его aggregation pushdown — и тогда Trino вытянет все строки payments, чтобы посчитать сумму у себя. Принцип: не «защищай» оптимизатор подзапросами, пиши агрегацию прямо — дай Trino шанс протолкнуть её в источник.
Антипаттерн 3: cross join разнородных источников
Третий антипаттерн — самый разрушительный. Join без условия соединения (или с условием, которое Trino не может протолкнуть и использовать) превращается в cross join — декартово произведение.
При федерации это особенно опасно. Декартово произведение таблицы из PostgreSQL на 100 тысяч строк и таблицы-факта из Iceberg на 100 миллионов строк — это 10 триллионов строк. Их физически негде разместить и нечем обработать; запрос либо исчерпает память кластера, либо будет работать часами. Всегда проверяйте, что у join есть корректное ON-условие по ключу, и что оно из тех, что Trino умеет использовать для dynamic filtering (равенство по ключу).
Cross join разнородных федеративных источников — самая дорогая ошибка федерации. Случайно потерянное или некорректное ON-условие превращает join в декартово произведение: маленькая dimension на большую fact дают триллионы строк. Перед запуском федеративного join всегда проверяйте ON-условие. EXPLAIN покажет cross join в плане до того, как запрос положит кластер.
Антипаттерн 4: игнорировать нагрузку на источник
Четвёртый антипаттерн — не технический, а эксплуатационный, но в проде он бьёт больнее всего. Из уроков 1 и 3: pushdown переносит работу на источник, а тяжёлый запрос Trino способен ощутимо нагрузить боевую базу.
Антипаттерн — направить аналитический Trino-каталог прямо на боевую транзакционную базу под пользовательской нагрузкой. Тяжёлый федеративный отчёт, выполняемый по расписанию, добавит боевой базе нагрузки и может ухудшить работу самого приложения. Это особенно коварно, потому что технически запрос корректен и быстр — проблема в том, где он создаёт нагрузку. Правильно — направлять Trino-каталог на read-реплику источника. Аналитика идёт по реплике, боевой трафик приложения — по основной базе, они не мешают друг другу.
Чеклист здорового федеративного запроса
| Проверка | Чем смотреть | Антипаттерн, который ловит |
|---|---|---|
| Фильтры ушли в источники | EXPLAIN: фильтры внутри обращения к источнику, не отдельными узлами | Запрос, мешающий pushdown |
| Агрегация протолкнута, где можно | EXPLAIN: агрегация на стороне источника | Тащить сырьё ради агрегации в Trino |
| У join есть корректное ON-условие | EXPLAIN: нет cross join в плане | Cross join разнородных источников |
| Реально прочитанный объём мал | EXPLAIN ANALYZE: строки и байты по источникам | Скрытое полное чтение таблицы |
| Каталог смотрит на реплику | Конфигурация каталога | Нагрузка на боевую базу |
Сквозной вывод модуля. Федерация Trino даёт огромную силу — единый SQL поверх разнородного ландшафта данных без копирования. Но сила эта обусловлена pushdown и dynamic filtering, а они не срабатывают сами собой при любом запросе. Инженер обязан читать EXPLAIN, понимать, что протолкнулось в источник, а что нет, и избегать четырёх антипаттернов: запросов, ломающих pushdown; вытягивания сырья ради агрегации; cross join; и нагрузки на боевые источники. Федеративный запрос — это не «просто SQL с разными каталогами», это запрос, за поведением которого нужно следить.
Попробуй сам
В песочнице соберите полный сценарий федерации: каталог postgresql с dimension-таблицей customers и каталог iceberg с fact-таблицей events (свяжите их по customer_id, в events сделайте достаточно строк, чтобы разница была заметна). Упражнение первое: напишите федеративный join с фильтрами по обеим сторонам и агрегацией, выполните EXPLAIN и проверьте по чеклисту — фильтры ушли в источники, агрегация протолкнута, cross join нет. Упражнение второе, воспроизведение антипаттернов: возьмите рабочий запрос и (а) оберните фильтруемую колонку PostgreSQL в lower(), (б) в отдельной попытке временно уберите ON-условие join. Снимите EXPLAIN для каждого случая и зафиксируйте, как изменился план — где появилось полное чтение, где cross join. Упражнение третье: для рабочего и для «испорченного» запроса снимите EXPLAIN ANALYZE и сравните фактический объём данных, прочитанный из источников. Письменно разберите все четыре антипаттерна модуля: в чём каждый, как его увидеть в плане и как исправить.