В последних версиях 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 для координации.
Главный backend становится leader. Запускает N worker-процессов. Каждый обрабатывает часть данных. Gather собирает результаты в финальный поток.
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’ы видят одну и ту же хэш-таблицу.
Build phase: worker'ы параллельно сканируют разные части build-side и совместно пишут в общую shared hash table. Probe phase: каждый worker берёт свою часть probe-side и делает lookup в общей таблице.
Заметь: 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 секунд.
Ожидаем в плане Gather сверху, Parallel Hash Join или Parallel Seq Scan ниже. На pglite параллелизм обычно недоступен (single-process), но синтаксис плана покажет, что Postgres рассматривал этот вариант.
Партиции и partition-wise join
Самая сильная оптимизация —
customer_id на 16 партиций), Postgres может превратить один JOIN огромных таблиц в M независимых JOIN’ов по парам партиций.
orders и payments секционированы по customer_id одинаково. Postgres JOIN'ит партицию orders_0 только с payments_0, и т.д. — M раз. Каждая пара — независимый JOIN, легко параллелится.
Условия для partition-wise join:
- Обе таблицы должны быть секционированы.
- Одинаковая схема партиций (одинаковый partition key, одинаковые границы partition).
- JOIN-условие включает partition key.
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 секунд.
На большом dataset разница между sequential и parallel может быть 2-4x. На pglite параллелизм недоступен (single-process), но cost-оценки в EXPLAIN всё равно покажут, что Postgres его рассматривал.
Чек-лист
- Параллельное выполнение запускает 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не параллелятся.