Тюнинг: EXPLAIN ANALYZE, статистика, resource groups
Платформа RetailScope работает: данные в Iceberg, справочники федерированы, витрины строятся. Но «работает» и «работает быстро под нагрузкой» — разные состояния. Этот урок — про тюнинг: мы соберём статистику таблиц, прочитаем EXPLAIN ANALYZE ключевого запроса, убедимся, что работает dynamic filtering, и настроим resource groups, чтобы дашборды и тяжёлая аналитика не мешали друг другу.
Тюнинг — это не угадывание. Это цикл: измерить, найти узкое место, применить рычаг, измерить снова. Все инструменты для него дал модуль 15 — здесь мы применяем их к реальному проекту.
Шаг 1: статистика таблиц
Cost-based optimizer Trino выбирает порядок join и тип распределения по статистике таблиц. После CTAS и серии INSERT статистика raw_sales может быть неполной — а без неё CBO работает вслепую и принимает плохие решения.
Сначала смотрим, что есть:
SHOW STATS FOR iceberg.retail.raw_sales;
-- column_name | data_size | distinct_values_count | nulls_fraction | row_count
-- product_id | ... | 200000 | 0.0 |
-- store_id | ... | 50 | 0.0 |
-- sale_ts | ... | 2400 | 0.0 |
-- (all) | | | | 59986052
Если NDV (distinct_values_count) или row count пусты, собираем статистику через ANALYZE:
ANALYZE iceberg.retail.raw_sales;
ANALYZE iceberg.retail.mart_sales_enriched;
ANALYZE вычисляет статистику и сохраняет в метаданные Iceberg. Iceberg обновляет базовую статистику и при записи через Trino, но ANALYZE нужен, когда данные меняли в обход Trino или когда нужна полная расширенная статистика. На что влияет статистика: row count и NDV дают оценку кардинальности; по ней CBO решает порядок join (сначала соединять то, что даст меньше строк) и тип распределения (BROADCAST для маленькой стороны, PARTITIONED для большой).
ANALYZE по большой таблице сам по себе стоит ресурсов. В RetailScope его ставят на расписание после регулярных загрузок данных — статистика должна отражать актуальное состояние таблицы. Устаревшая статистика иногда хуже отсутствующей: CBO уверенно строит план по цифрам, которые больше не верны.
Шаг 2: EXPLAIN ANALYZE ключевого запроса
Берём типичный аналитический запрос RetailScope и снимаем EXPLAIN ANALYZE — реальный план с измеренными числами.
EXPLAIN ANALYZE
SELECT st.region, p.category,
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;
Сокращённый вывод горячего фрагмента:
Fragment 2 [SOURCE]
CPU: 28.40m, Scheduled: 41.10m, Input: 14920183 rows (412MB)
InnerJoin[criteria = ("product_id" = "product_id_0"), distribution = REPLICATED]
│ CPU: 9.81m (34.54%), Output: 14920183 rows
│ dynamicFilterAssignments = {product_id_0 -> #df_512}
├─ ScanFilterProject[table = iceberg:retail.raw_sales,
│ constraint = (sale_ts >= TIMESTAMP '2026-01-01 ...'),
│ dynamicFilters = {"product_id" = #df_512}]
│ CPU: 12.06m (42.46%), Output: 14920183 rows
│ Input: 14920183 rows
└─ RemoteSource[sourceFragmentIds = [4]]
Output: 200000 rows (3.4MB)
Читаем по алгоритму из модуля 15. Горячий фрагмент — Fragment 2 (максимум CPU). Горячий оператор — ScanFilterProject скана raw_sales (42.46% CPU) и InnerJoin (34.54%). Это ожидаемо — скан фактов и join тяжелее всего.
Важные позитивные сигналы. Первый — constraint = (sale_ts >= ...) в скане: фильтр по дате протолкнулся, partition pruning работает — скан вернул около 15 млн строк, а не все 60 млн. Второй — distribution = REPLICATED у join со справочником: dim_product мал (200 тыс. строк, 3.4 MB), Trino разослал его на все воркеры вместо дорогого repartition. Третий — dynamicFilters = {"product_id" = #df_512} в скане: dynamic filtering включился.
Шаг 3: dynamic filtering в действии
dynamicFilters в выводе выше — не деталь, а ключевой механизм. Разберём, что он даёт RetailScope.
Dynamic filtering собирает значения join-ключа с build-стороны (отфильтрованного справочника) в рантайм-предикат и применяет его к скану fact-таблицы. Без него скан raw_sales прочитал бы все строки за период, а лишние отсеялись бы только на join. С ним рантайм-фильтр по product_id проталкивается прямо в Parquet-reader — и Trino пропускает row groups, где нужных product_id нет, ещё на этапе чтения.
Особенно сильно dynamic filtering работает в RetailScope, когда аналитик фильтрует справочник: запрос WHERE p.category = 'Electronics' оставляет малую часть товаров — dynamic filter получается узким, и скан raw_sales пропускает огромную долю row groups. Чем избирательнее фильтр по dimension, тем больше выигрыш на скане фактов. Если в плане у скана фактов dynamicFilters нет, а join со справочником есть — это сигнал разобраться: тип join, тип ключа (для DOUBLE/REAL min/max-фильтр не строится).
Join distribution: BROADCAST против PARTITIONED
В выводе выше у join со справочником стояло distribution = REPLICATED. Это решение CBO стоит разобрать — оно одно из самых влиятельных для производительности RetailScope.
Когда Trino соединяет две таблицы, ему надо свести строки с одинаковым join-ключом на один воркер. Есть два способа. BROADCAST (в выводе — REPLICATED): build-сторона join — обычно меньшая таблица — копируется целиком на каждый воркер как хэш-таблица, после чего probe-сторона сканируется локально, без перемещения по сети. Быстро, но build-сторона должна влезть в память каждого воркера. PARTITIONED: обе таблицы перераспределяются по сети по хэшу join-ключа, так что соответствующие строки сходятся на одном воркере. Медленнее из-за шаффла обеих сторон, зато позволяет соединять две большие таблицы, используя суммарную память кластера.
В федеративных запросах RetailScope почти всегда работает BROADCAST, и это правильно: справочники из PostgreSQL малы, скопировать их на воркеры дёшево, а большая raw_sales остаётся на месте. Если бы CBO выбрал PARTITIONED здесь, он зря репартиционировал бы по сети миллиарды строк фактов. Именно поэтому статистика так важна: CBO выбирает BROADCAST, когда оценивает build-сторону достаточно маленькой, а оценку он берёт из статистики. Нет актуальной статистики — CBO может ошибиться и выбрать дорогой PARTITIONED там, где хватило бы BROADCAST.
| Тип распределения | Как работает | Когда оптимален |
|---|---|---|
| BROADCAST (REPLICATED) | Build-сторона копируется на все воркеры | Одна сторона мала и влезает в память воркера |
| PARTITIONED | Обе стороны репартиционируются по хэшу ключа | Обе стороны велики |
По умолчанию join_distribution_type стоит в AUTOMATIC — CBO решает сам по статистике, и в RetailScope это правильный режим. Принудительно задавать тип через session property стоит только как точечный обход, когда вы уверены, что CBO ошибается, и понимаете почему.
Шаг 4: resource groups — изоляция нагрузки
Платформа обслуживает два класса нагрузки с разными требованиями. Дашборды — частые, лёгкие, требуют низкой задержки. Тяжёлая аналитика и пересборка витрин — редкие, ресурсоёмкие, терпят ожидание. Без изоляции один тяжёлый ad-hoc-запрос займёт кластер, и все дашборды встанут.
Решение — resource groups: иерархия групп с лимитами и приоритетами; каждый запрос попадает в группу, и группа ограничивает его долю кластера.
Файл etc/resource-groups.json:
{
"rootGroups": [
{
"name": "dashboards",
"softMemoryLimit": "40%",
"hardConcurrencyLimit": 20,
"maxQueued": 100,
"schedulingPolicy": "fair"
},
{
"name": "analytics",
"softMemoryLimit": "60%",
"hardConcurrencyLimit": 4,
"maxQueued": 20
}
],
"selectors": [
{ "source": ".*dashboard.*", "group": "dashboards" },
{ "group": "analytics" }
]
}
Подключается через etc/resource-groups.properties:
resource-groups.configuration-manager=file
resource-groups.config-file=etc/resource-groups.json
Логика. Группа dashboards — высокий лимит конкурентности (20 одновременных запросов): дашбордов много, но каждый лёгкий. Группа analytics — низкий лимит (4): тяжёлые запросы не должны заполонить кластер; пятый встанет в очередь. softMemoryLimit делит память кластера между классами. selectors маршрутизируют: запрос с dashboard в источнике идёт в dashboards, остальные — в analytics.
Эффект: тяжёлый ad-hoc-запрос больше не блокирует дашборды — он ограничен своей группой analytics, а dashboards со своей долей памяти и слотов продолжает работать. В Web UI у каждого запроса видна его resource group, а запрос в состоянии QUEUED — это запрос, упёршийся в лимит своей группы.
Resource groups управляют тем, как кластер делится между запросами, но не делают сами запросы быстрее. Если дашбордный запрос медленный из-за плохого плана, отсутствия статистики или мелких файлов — его лечат статистикой, EXPLAIN ANALYZE и компакцией, а не resource group. Resource groups решают другую задачу — изоляцию: чтобы один класс нагрузки не отъедал ресурсы у другого. Это разные рычаги для разных проблем.
Цикл тюнинга RetailScope
Сведём шаги в единый цикл — порядок, в котором тюнят платформу.
| Шаг | Действие | Чем проверяем |
|---|---|---|
| 1 | Собрать и поддерживать статистику таблиц | SHOW STATS, план до/после ANALYZE |
| 2 | Снять EXPLAIN ANALYZE, найти горячий фрагмент и оператор | Проценты CPU в выводе |
| 3 | Убедиться, что работают pruning и dynamic filtering | constraint и dynamicFilters в скане |
| 4 | Проверить физический слой: партиции, размеры файлов | $files, число splits |
| 5 | Настроить resource groups для изоляции классов нагрузки | Группа и state запроса в Web UI |
Тюнинг — итеративен. После каждого изменения снимают EXPLAIN ANALYZE заново и сравнивают CPU горячего фрагмента с предыдущим замером. Цифры, а не ощущения, подтверждают, что рычаг сработал.
Попробуй сам
Проведите цикл тюнинга на платформе RetailScope.
- Выполните
SHOW STATS FOR iceberg.retail.raw_sales. Заполнены ли row count и NDV? Если нет — выполнитеANALYZEи посмотрите статистику снова. - Снимите
EXPLAIN ANALYZEфедеративного запроса выручки по региону и категории. Найдите горячий фрагмент и горячий оператор по проценту CPU. - В выводе найдите
constraint(partition pruning по дате) иdynamicFiltersна сканеraw_sales. Оба механизма работают? - Добавьте в запрос узкий фильтр по справочнику, например
WHERE p.category = 'Electronics', и снова снимитеEXPLAIN ANALYZE. Сравните число строк на сканеraw_sales— как изменился эффект dynamic filtering? - Создайте
etc/resource-groups.jsonиetc/resource-groups.propertiesиз урока, перезапустите кластер. Запустите тяжёлый запрос и параллельно несколько лёгких; в Web UI посмотрите, в какие группы они попали и какие встали вQUEUED.
Цель — пройти полный цикл «измерить, найти, применить, измерить» на реальном проекте и увидеть, как resource groups изолируют нагрузку.