Learning Platform
Урок 09.05 · 24 мин
Продвинутый
JoinsParallel Hash JoinParallel Nested LoopPartition-wise joinmax_parallel_workers

В последних версиях Postgres параллелизм пробрался во все этапы выполнения: scan, join, aggregate. Для больших аналитических запросов это даёт 2-8x ускорение почти бесплатно — нужно только правильно настроить GUC и понимать, какие операции параллелятся, а какие нет. Этот урок — последний в модуле JOIN, и он посвящён именно тому, как несколько процессоров делают одну работу.

Архитектура параллельного выполнения

Когда планер решает идти параллельно, в плане появляется узел Gather (или Gather Merge):

Gather
  Workers Planned: 4
  Workers Launched: 4
  ->  Parallel Hash Join
        ...

Это означает: postmaster запускает несколько worker’ов (backend-процессов), каждый выполняет часть плана, а Gather собирает их результаты обратно в поток главного процесса. Каждый worker — отдельный процесс с собственным memory context, но имеет доступ к shared memory для координации.

Параллельное выполнение JOIN

Главный backend становится leader. Запускает N worker-процессов. Каждый обрабатывает часть данных. Gather собирает результаты в финальный поток.

Leader backendпланирует параллельный план и запускает worker'ов
Worker 1scan + filter + hash chunk
Worker 2scan + filter + hash chunk
Worker 3scan + filter + hash chunk
Worker 4scan + filter + hash chunk
Shared memoryобщая Hash table, координация, очередь результатов
Gatherсобирает tuples от всех workers в leader

Parallel Hash Join (PG 11+)

До PG 11 параллельный Hash Join работал так: каждый worker строил свою копию хэш-таблицы (по всему build-side). Это давало параллельный probe, но build-side читался N раз — bottleneck.

С PG 11 introduce Parallel Hash Join: build-side читается только один раз всеми worker’ами совместно, и хэш-таблица хранится в shared memory (DSM — dynamic shared memory). Все worker’ы видят одну и ту же хэш-таблицу.

Parallel Hash Join (PG 11+)

Build phase: worker'ы параллельно сканируют разные части build-side и совместно пишут в общую shared hash table. Probe phase: каждый worker берёт свою часть probe-side и делает lookup в общей таблице.

Phase 1: Parallel buildN workers совместно строят общую shared hash table в DSM
Worker 1: build chunk Ainsert into shared HT
Worker 2: build chunk Binsert into shared HT
Worker 3: build chunk Cinsert into shared HT
Phase 2: Parallel probeкаждый worker берёт свою часть probe-side, делает lookup в shared HT
Worker 1: probe range 1emit matches
Worker 2: probe range 2emit matches
Worker 3: probe range 3emit matches

Заметь: Parallel Hash Join использует не work_mem на каждый worker, а work_mem × hash_mem_multiplier (PG 13+) суммарно через DSM. Это критично для расчёта памяти: 4 worker’а с work_mem=64MB не съедят 256MB, а просто разделят общую shared-таблицу.

Parallel Nested Loop

С PG 9.6 параллелятся и Nested Loop’ы — но только outer-side. Каждый worker берёт свою часть outer-таблицы и для каждой строки делает Index Scan на inner. Inner-сторона не параллелится.

Это работает хорошо, когда outer достаточно большая и фильтр на ней не очень селективен (иначе worker’ам нечего раздавать). Если outer — десять строк, параллельный NL смысла не имеет: оверхед запуска worker’ов больше, чем выигрыш.

Параллельный JOIN на большой таблице. Дата-сет генерируется ~10-15 секунд.

PostgreSQL

Ожидаем в плане Gather сверху, Parallel Hash Join или Parallel Seq Scan ниже. На pglite параллелизм обычно недоступен (single-process), но синтаксис плана покажет, что Postgres рассматривал этот вариант.

Партиции и partition-wise join

Самая сильная оптимизация —

partition-wise join
. Когда обе таблицы секционированы по одному ключу с одинаковой схемой партиций (например, по customer_id на 16 партиций), Postgres может превратить один JOIN огромных таблиц в M независимых JOIN’ов по парам партиций.

Partition-wise join

orders и payments секционированы по customer_id одинаково. Postgres JOIN'ит партицию orders_0 только с payments_0, и т.д. — M раз. Каждая пара — независимый JOIN, легко параллелится.

Без partition-wise: один большой JOINHash Join всех orders с всеми payments (нужен 2-3 GiB work_mem)
С partition-wise: 16 маленьких JOIN'овorders_0 JOIN payments_0, orders_1 JOIN payments_1, ...
Pair 0orders_0 + payments_0 = маленький Hash Join в work_mem
Pair 1orders_1 + payments_1
......
Pair 15orders_15 + payments_15
плюскаждая пара параллелится в свой worker; work_mem делится на 16 раз меньшие хэши

Условия для partition-wise join:

  1. Обе таблицы должны быть секционированы.
  2. Одинаковая схема партиций (одинаковый partition key, одинаковые границы partition).
  3. JOIN-условие включает partition key.
  4. enable_partitionwise_join = on (по умолчанию off, потому что увеличивает время планирования).

Это нужно знать! По умолчанию Postgres не использует partition-wise join. Включить обязательно для аналитических запросов с партиционированными таблицами.

Что параллелится, что — нет

Параллельные узлы:

  • Parallel Seq Scan (PG 9.6)
  • Parallel Index Scan / Parallel Index Only Scan (PG 10)
  • Parallel Hash Join (PG 11)
  • Parallel Nested Loop — только outer (PG 9.6)
  • Parallel Append для UNION ALL (PG 11)
  • Parallel Aggregate (PG 9.6)

Не параллелятся:

  • Merge Join — потоковая природа алгоритма плохо ложится на партиционирование (отдельные исследования есть, но в Postgres ещё нет).
  • DML (INSERT, UPDATE, DELETE) — главный backend всё пишет сам (за исключением утилит вроде CREATE INDEX CONCURRENTLY с параллельным build).
  • Узлы, использующие user-defined PL/pgSQL функции без PARALLEL SAFE маркера.

Параметры, которыми управляют параллелизм

  • max_parallel_workers (default 8) — глобальный лимит worker’ов на весь сервер.
  • max_parallel_workers_per_gather (default 2) — лимит на один Gather-узел. Если 4 запроса работают параллельно, каждый может взять 2 worker’а — итого 8.
  • min_parallel_table_scan_size (default 8 MiB) — минимум таблицы для рассмотрения параллельного scan.
  • parallel_setup_cost (default 1000) — стоимость старта worker’ов. На маленьких таблицах не окупается.
  • parallel_tuple_cost (default 0.1) — стоимость передачи tuple через Gather.
  • enable_partitionwise_join (default off) — включить partition-wise join.
  • enable_partitionwise_aggregate (default off) — включить partition-wise агрегаты.

Реальная настройка для аналитической нагрузки:

SET max_parallel_workers_per_gather = 8;
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;
SET work_mem = '256MB';

Когда параллелизм не помогает или вредит

  • Маленькие таблицы: запуск worker’ов = ~10ms overhead. Если запрос быстрее 50ms — лучше без параллелизма.
  • Низкий I/O bandwidth: если узкое место — disk read, добавление CPU-workers не помогает (диск всё равно один).
  • PL/pgSQL функции в SELECT: если функция не PARALLEL SAFE, весь запрос упадёт в single-process.
  • LIMIT с маленьким N: worker’ы могут обработать больше строк, чем нужно (overhead). Постгрес обычно это учитывает в cost.
  • Высокая нагрузка на сервер: если у тебя 100 одновременных тяжёлых запросов, каждый требует 8 workers — серверу плохо. Лучше параллелизм поменьше, но больше queries.

Сравним sequential vs parallel: сначала запретим параллелизм, потом разрешим. Дата-сет ~10-15 секунд.

PostgreSQL

На большом dataset разница между sequential и parallel может быть 2-4x. На pglite параллелизм недоступен (single-process), но cost-оценки в EXPLAIN всё равно покажут, что Postgres его рассматривал.

Проверка знанийKnowledge check
У тебя orders секционирована по hash от customer_id на 32 партиции, customers тоже секционирована по hash от id на 32 партиции. Ты делаешь SELECT с JOIN'ом и GROUP BY country. Какие два параметра обязательно включить, и насколько примерно ускорится запрос на 8-ядерной машине с включёнными параллельными worker'ами?
ОтветAnswer
Включить: enable_partitionwise_join = on (превратит один большой JOIN в 32 независимых, каждый на паре партиций) и enable_partitionwise_aggregate = on (сделает агрегацию по country независимо в каждой партиции, потом смержит). На 8 ядрах: партиции делятся между 8 worker'ами по 4 каждой; каждая пара партиций обрабатывается за ~1/32 времени единого JOIN'а; общее ускорение ~4-8x (ограничено CPU и I/O bandwidth, не теоретическим 32x). Дополнительный бонус: каждая хэш-таблица в partition-wise join в 32 раза меньше, значит, с большой вероятностью помещается в work_mem без batched join.

Чек-лист

  • Параллельное выполнение запускает N worker’ов, координируется через Gather или Gather Merge.
  • Parallel Hash Join (PG 11+) использует shared memory для общей хэш-таблицы — build читается один раз.
  • Parallel Nested Loop (PG 9.6) параллелит outer, inner проходит каждый worker сам.
  • Merge Join не параллелится в текущих версиях Postgres.
  • partition-wise join (PG 11) превращает один большой JOIN в M независимых на парах партиций. Требует одинаковой схемы партиционирования и enable_partitionwise_join = on (по умолчанию off).
  • Параметры: max_parallel_workers_per_gather, max_parallel_workers, parallel_setup_cost, min_parallel_table_scan_size.
  • На маленьких запросах параллелизм добавляет overhead. Окупается с ~50ms+ запросов и таблиц от ~8 MiB.
  • Высоконагруженные серверы лучше с меньшим параллелизмом на запрос, но большей пропускной способностью.
  • DML и PL/pgSQL без PARALLEL SAFE не параллелятся.
Поток vs процесс — что общего, что разного Векторизованное выполнение

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Чем Parallel Hash Join (PG 11+) отличается от его предшественника в более ранних версиях?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5