Федеративный слой: подключение PostgreSQL и витрины
В уроке постановки мы приняли решение: справочники RetailScope остаются в PostgreSQL и не копируются в lakehouse. Теперь это решение реализуем. В этом уроке мы подключаем PostgreSQL к Trino как каталог, соединяем Iceberg-факты со справочниками одним SQL-запросом и строим полноценные денормализованные витрины. Это и есть федеративный слой — то, ради чего в архитектуру взяли именно Trino.
SQL: EXPLAIN в PostgreSQL — смотрим, что Trino протолкнул в базуГлавное, что покажет урок: один SELECT обращается к двум совершенно разным хранилищам — object storage и реляционной базе — а Trino сам решает, какую часть работы отдать PostgreSQL, а какую посчитать сам.
Подключение PostgreSQL
В docker-compose.yml из урока развёртывания добавляем сервис базы:
postgres:
image: postgres:16
environment:
POSTGRES_USER: retail
POSTGRES_PASSWORD: retail_pw
POSTGRES_DB: refdata
ports:
- "5432:5432"
volumes:
- pg-data:/var/lib/postgresql/data
Чтобы Trino увидел базу, добавляем каталог. Файл etc/catalog/postgresql.properties — у координатора и у каждого воркера:
connector.name=postgresql
connection-url=jdbc:postgresql://postgres:5432/refdata
connection-user=retail
connection-password=retail_pw
Всё. После перезапуска SHOW CATALOGS покажет postgresql рядом с iceberg. Каталог — это всего лишь конфигурация подключения; коннектор postgresql адаптирует базу к модели Trino. Naming-маппинг прямой: схема PostgreSQL становится Trino-схемой, таблица — Trino-таблицей. Запрос SELECT * FROM postgresql.public.dim_product читает таблицу напрямую из PostgreSQL.
Для проекта наполним PostgreSQL справочниками — товары, магазины, категории:
-- Выполняется в PostgreSQL
CREATE TABLE dim_product (
product_id INTEGER PRIMARY KEY,
product_name VARCHAR(120),
category VARCHAR(60),
brand VARCHAR(60)
);
CREATE TABLE dim_store (
store_id INTEGER PRIMARY KEY,
store_name VARCHAR(120),
region VARCHAR(60),
city VARCHAR(60)
);
Федеративный запрос: один SQL, два хранилища
Теперь — суть. Соединяем raw_sales (Iceberg, факты в object storage) со справочниками (PostgreSQL) в одном запросе:
SELECT
st.region,
p.category,
count(*) AS sale_lines,
sum(s.quantity * s.unit_price - s.discount) AS net_revenue
FROM iceberg.retail.raw_sales s
JOIN postgresql.public.dim_product p ON s.product_id = p.product_id
JOIN postgresql.public.dim_store st ON s.store_id = st.store_id
WHERE s.sale_ts >= TIMESTAMP '2026-01-01 00:00:00'
GROUP BY st.region, p.category
ORDER BY net_revenue DESC;
Один запрос, три таблицы из двух разных систем. Аналитику не нужно знать, что факты лежат в MinIO, а справочники в PostgreSQL, — для него это единое SQL-пространство. Это и есть query federation: Trino выступает единой точкой доступа поверх разнородных источников.
Pushdown: что Trino отдаёт PostgreSQL
Федерация была бы бесполезна, если бы Trino тащил справочники целиком и фильтровал сам. Поэтому работает pushdown — Trino отдаёт часть работы источнику, если коннектор это умеет. Коннектор postgresql поддерживает pushdown хорошо, потому что PostgreSQL — полноценная SQL-база.
Что Trino проталкивает в PostgreSQL:
- Predicate pushdown —
WHEREуезжает в базу: PostgreSQL отдаёт уже отфильтрованные строки. - Projection pushdown — в базу уходит только список нужных колонок, лишние не читаются.
- Aggregation pushdown — если запрос агрегирует только данные PostgreSQL,
count/sum/GROUP BYмогут посчитаться в самой базе.
Что Trino всегда делает сам: join данных из разных каталогов. Соединить Iceberg-факты с PostgreSQL-справочником в PostgreSQL невозможно — база не видит Iceberg. Такой join всегда исполняется на воркерах Trino.
Увидеть распределение работы помогает EXPLAIN. В плане федеративного запроса у скана PostgreSQL-таблицы виден проброшенный в источник запрос:
Fragment 3 [SOURCE]
TableScan[table = postgresql:public.dim_store]
region := region:varchar
store_id := store_id:integer
-- запрос, реально отправленный в PostgreSQL:
-- SELECT store_id, region FROM public.dim_store
В скане только store_id и region — projection pushdown сработал, остальные колонки dim_store не читаются. Если бы в запросе был фильтр только по справочнику (например, WHERE st.region = 'North'), он бы тоже ушёл в PostgreSQL отдельным WHERE.
Перед запуском незнакомого федеративного запроса снимите EXPLAIN и посмотрите, что уходит в источник. Если в PostgreSQL уходит SELECT без фильтров и со всеми колонками, а должен был с фильтром, — pushdown не сработал, и Trino вытянет всю таблицу по сети. Для маленького справочника это терпимо, для большой таблицы из federated-источника — серьёзная проблема производительности.
Что коннектор делает под капотом
Полезно понимать, что postgresql — это JDBC-коннектор, и через что он проходит, обслуживая федеративный запрос.
Коннектор реализует Connector SPI Trino: он переводит запросы движка в обращения к источнику и обратно. Для PostgreSQL это означает три рода работы. Первое — метаданные: когда Trino нужно узнать, какие в refdata есть схемы и таблицы и какого типа их колонки, коннектор спрашивает это у системного каталога PostgreSQL. Второе — маппинг типов: типы PostgreSQL не совпадают с типами Trino один в один, и коннектор сопоставляет их — integer PostgreSQL становится integer Trino, varchar остаётся varchar, numeric отображается в decimal, timestamp — в timestamp. Если у источника есть тип без точного аналога в Trino, коннектор подбирает ближайший или помечает колонку как непереносимую. Третье — трансляция запроса: ту часть работы, которую Trino решил протолкнуть (фильтр, проекцию, агрегацию), коннектор оформляет как валидный SQL-запрос на диалекте PostgreSQL и отправляет в базу через JDBC.
Для PostgreSQL сама база видит обычные SQL-запросы и даже не знает, что обращается к ней Trino — она просто отдаёт строки. Именно поэтому коннектор postgresql хорошо поддерживает pushdown: PostgreSQL — полноценная SQL-СУБД, и почти любую протолкнутую операцию она исполнит сама. У коннекторов к менее «умным» источникам pushdown беднее — это всегда зависит от того, что источник умеет.
Антипаттерн федерации
Федерация мощна, но её легко применить неправильно. Главный антипаттерн — join двух больших таблиц из разных federated-источников.
Представьте, что фактов было бы две гигантские таблицы в двух разных PostgreSQL-базах, и мы соединяем их в Trino. Pushdown тут не спасает: join разных источников всегда делается в Trino, а значит обе огромные таблицы целиком поедут по сети на воркеры. Сеть станет узким местом, и запрос будет медленным независимо от мощности кластера.
Почему RetailScope этого избегает: федерация в проекте — это всегда большие факты в Iceberg плюс маленькие справочники из PostgreSQL. Справочник мал — протащить его по сети дёшево. Факты велики, но они в Iceberg, который Trino читает эффективно: partition pruning, колоночное чтение, dynamic filtering. Перекос размеров — то, что делает федерацию RetailScope быстрой.
| Сценарий федерации | Оценка |
|---|---|
| Большие факты в Iceberg + маленький справочник из PostgreSQL | Хорошо: справочник дёшево протащить, факты Trino читает эффективно |
| Маленькая таблица из PostgreSQL + маленькая из MySQL | Нормально: объёмы малы, сеть не узкое место |
| Большая таблица из PostgreSQL + большая из другой базы | Антипаттерн: обе целиком едут по сети, сеть — bottleneck |
| Регулярный отчёт по большой federated-таблице | Плохо: лучше один раз загрузить её в Iceberg |
Денормализованная витрина с федерацией
Теперь достроим витрину mart_sales_daily из прошлого урока — добавим в неё справочные атрибуты из PostgreSQL. Это полноценная денормализованная витрина:
CREATE OR REPLACE TABLE iceberg.retail.mart_sales_enriched
WITH (
format = 'PARQUET',
partitioning = ARRAY['month(sale_date)']
)
AS
SELECT
CAST(s.sale_ts AS DATE) AS sale_date,
st.region,
st.store_name,
p.category,
p.brand,
count(*) AS sale_lines,
sum(s.quantity) AS units_sold,
sum(s.quantity * s.unit_price - s.discount) AS net_revenue
FROM iceberg.retail.raw_sales s
JOIN postgresql.public.dim_product p ON s.product_id = p.product_id
JOIN postgresql.public.dim_store st ON s.store_id = st.store_id
GROUP BY 1, 2, 3, 4, 5;
Что здесь произошло. Витрина денормализована — region, store_name, category, brand «вшиты» прямо в таблицу. Она целиком в Iceberg: справочники из PostgreSQL прочитаны один раз в момент построения и зафиксированы в витрине. Дашборды теперь бьют по mart_sales_enriched — компактной, агрегированной, полностью в lakehouse — и не нагружают PostgreSQL на каждый запрос.
Здесь видна роль федерации в проекте. Федерация работает в момент построения витрины: запрос денормализации соединяет lakehouse и PostgreSQL. Дашборды же читают уже готовую Iceberg-витрину — для них федерации нет, есть быстрая локальная таблица. Витрина пересобирается по расписанию оркестратором, и при пересборке справочные значения обновляются. Это сознательный компромисс: дашборды быстрые, а свежесть справочников в витрине определяется частотой пересборки.
Попробуй сам
Добавьте к платформе RetailScope федеративный слой.
- Добавьте сервис
postgresвdocker-compose.ymlи каталогetc/catalog/postgresql.properties. Перезапустите и проверьтеSHOW CATALOGS— виден лиpostgresql. - В PostgreSQL создайте
dim_productиdim_storeи наполните их (можно сгенерировать строки запросом или взятьtpch.tiny.partиtpch.tiny.supplierкак основу). Из Trino выполнитеSELECT count(*) FROM postgresql.public.dim_product. - Запустите федеративный запрос выручки по региону и категории из урока. Он соединяет Iceberg и PostgreSQL в одном SQL.
- Снимите
EXPLAINэтого запроса. Найдите фрагменты со сканом PostgreSQL-таблиц: какие колонки в них и какой SQL уходит в базу? Сработал ли projection pushdown? - Добавьте в запрос
WHERE st.region = 'North'и снова посмотритеEXPLAIN— ушёл ли этот фильтр в PostgreSQL отдельным условием? - Постройте денормализованную витрину
mart_sales_enriched. Сравните время запроса дашборда к ней с временем исходного федеративного запроса.
Цель — на практике увидеть, как один SQL работает поверх двух хранилищ, и как федерация при построении витрины уступает место быстрому локальному чтению при работе дашбордов.