С Postgres 9.6 executor умеет выполнять часть плана параллельно — в нескольких процессах-воркерах (background workers). Это особенно сильно ускоряет analytical-запросы по большим таблицам: один worker сканирует половину таблицы, другой — вторую, и leader-процесс агрегирует результаты. Чтение plan’а parallel-запроса требует понимания нескольких новых узлов и одной важной интерпретации loops.
Важно: pglite (на котором работают песочницы в курсе) parallel-планы не показывает — там один процесс. Поэтому в этом уроке мы будем разбирать реальные планы, скопированные из production Postgres, и обсуждать их. Песочницы здесь — для проверки общей структуры.
Когда Postgres решает идти в параллель
Параллельный план рассматривается планировщиком, если:
- Включён ≥ 1 (по умолчанию 2).max_parallel_workers_per_gather
- Стоимость без параллели выше
parallel_setup_cost + parallel_tuple_cost(по умолчанию 1000 + 0.1). На маленьких запросах overhead не оправдан. - Размер таблицы ≥
min_parallel_table_scan_size(по умолчанию 8 MB) или для индексовmin_parallel_index_scan_size(512 KB). - Запрос может быть распараллелен. Не каждый план parallelizable: некоторые функции (помеченные
VOLATILEилиUNSAFE) ломают parallel safety.
Если все условия выполнены, планировщик встраивает в дерево узел Gather или Gather Merge и пускает дочерние ветки в parallel-режим.
Структура parallel-плана
Gather собирает строки от N workers + leader. Каждый worker исполняет одно и то же поддерево; данные разделяются динамически по блокам.
Типичный текстовый вывод:
Gather (cost=1000.00..15234.00 rows=100000 width=8)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on orders
(cost=0.00..14234.00 rows=41667 width=8)
Filter: (status = 'paid'::text)
Что важно для чтения:
Workers Planned: 2— планировщик попросил 2 worker’а.Workers Launched: 2— runtime смог стартовать оба. Если быmax_parallel_workersуже был исчерпан другими запросами, Launched могло быть меньше — это нормально.Parallel Seq Scan— узел parallel-aware. Это значит: каждый worker не сканирует всю таблицу, а Postgres динамически выдаёт каждому свободному worker’у блоки страниц. Когда worker заканчивает свой блок — берёт следующий из общего пула.
Parallel-aware vs Parallel-safe vs serial-only
Это разные понятия, путаница частая.
Parallel-safe — функция/оператор может корректно работать внутри parallel-плана (не имеет глобального состояния, не делает DML, etc.). Это статус функции, помечается при CREATE FUNCTION ... PARALLEL SAFE.
Parallel-aware узел — узел плана, специально написанный для разделения работы между workers. К ним относятся:
Parallel Seq Scan— каждый worker берёт свой блок страниц.Parallel Index Scan/Parallel Index Only Scan/Parallel Bitmap Heap Scan.Parallel Hash Join(с PG 11) — build-side hash table собирается коллективно всеми workers.Parallel Append— на partitioned tables: каждый worker берёт свою партицию.
Serial-only узлы — узлы, которые в parallel-плане могут стоять только над Gather (то есть leader-only). Это Limit, Sort (если не Parallel-aware), Aggregate финальный.
В план обычно вкладывается так:
Aggregate (financial: серийный) <- leader
-> Gather <- собирает от workers
-> Partial Aggregate <- parallel-aware
-> Parallel Seq Scan <- parallel-aware
То есть worker’ы делают Partial Aggregate (частичная сумма по своему куску), отдают leader’у через Gather, leader делает Aggregate финальный (сводит частичные суммы). Это двухфазная агрегация — фундаментальный паттерн parallel-execution.
loops в parallel-плане: главная ловушка
Под Gather каждый узел исполняется независимо в каждом worker и в leader. Поэтому loops = Workers Launched + 1 (если leader тоже работал), не 1.
Видишь:
-> Parallel Seq Scan on orders
(actual time=0.012..145.234 rows=33000 loops=3)
Buffers: shared hit=14000
Что это значит? loops=3 (2 workers + leader). actual rows=33000 — это среднее на одну итерацию, то есть в среднем каждый worker увидел 33000 строк. Суммарно за все 3 запуска: 33000 × 3 = 99000 строк. Это и есть итоговое число строк на parallel seq scan.
Аналогично с временем: actual time=...145 мс — среднее, но это время каждого worker в parallel. Реальное wall-clock время выполнения меньше этой цифры, потому что они работали параллельно, не последовательно. Это контр-интуитивно: видишь loops × time = 435 мс, а Execution Time в самом конце плана может быть 150 мс.
Правильная интерпретация:
actual rows × loops= total rows produced (как обычно).actual time— wall-clock на одного worker.- Реальное время = в идеале
actual timeкорня (не суммируется через loops!).
Это исключение надо запомнить: parallel-плана actual time не умножается на loops для оценки общего времени.
Gather vs Gather Merge
Два варианта собирающего узла:
- Gather — собирает строки в любом порядке. Быстрее, но не сохраняет сортировку.
- Gather Merge — каждый worker возвращает отсортированный stream, leader делает merge. Используется, когда выше Gather стоит
LIMIT NсORDER BYи каждый worker может вернуть свои топ-N.
Пример с Gather Merge:
Limit (cost=1234..1240 rows=10)
-> Gather Merge
Workers Planned: 2
-> Sort
-> Parallel Seq Scan on orders
Каждый worker делает локальный Sort своего куска. Gather Merge сливает отсортированные потоки в один глобально-отсортированный. Limit отрезает первые 10. Это эффективно — не нужно сортировать все 100K строк, каждый worker сортирует только своё.
Parallel Hash Join
Самый интересный parallel-узел. С PG 11+ workers строят общую hash table — shared между всеми. Раньше каждый worker строил свою.
Hash Join
-> Parallel Seq Scan on big_table
-> Parallel Hash
-> Parallel Seq Scan on small_table
Узел Parallel Hash под join — это знак, что build-side собирается коллективно. Это эффективно по памяти: вместо N копий hash table — одна shared. Но усложняет управление work_mem: лимит делится на workers.
Когда parallel не помогает
Parallel — не серебряная пуля. Случаи, когда parallel-план медленнее или одинаков с serial:
- Маленькая таблица. Если seq scan стоит 50 мс, overhead на запуск workers (~50 мс на воркер) съест весь выигрыш.
- Запрос ограничен I/O bandwidth. Если узкое место — disk read, увеличение CPU-параллелизма не поможет; диск всё равно отдаёт 200 MB/s. На SSD/NVMe эффект иногда есть, на HDD — почти всегда нет.
- High concurrency. 100 одновременных запросов уже загружают CPU; запускать каждому из них ещё по 2 worker’а — путь к перегрузке. Параметр
max_parallel_workersглобален: если он 8, а у вас 100 запросов, большая часть получит Workers Launched=0. - Volatile/Unsafe функции в запросе. Postgres не сможет запустить parallel — план будет serial с лучшим оценочным cost.
Реальный пример с разбором
Возьмём типичный parallel-план и разберём его строку за строкой.
Finalize Aggregate (cost=2401.50..2401.51 rows=1 width=8)
(actual time=185.234..185.235 rows=1 loops=1)
-> Gather (cost=2401.40..2401.41 rows=2 width=8)
(actual time=185.100..185.220 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate
(cost=1401.30..1401.31 rows=1 width=8)
(actual time=180.500..180.501 rows=1 loops=3)
-> Parallel Seq Scan on orders
(cost=0..1359.97 rows=16531 width=0)
(actual time=0.050..170.123 rows=33333 loops=3)
Filter: (status = 'paid')
Execution Time: 186.234 ms
Что видим:
- Корень — Finalize Aggregate. Это leader-узел, не parallel. Принимает частичные суммы от Gather и финализирует.
- Gather: Planned 2, Launched 2 — runtime смог запустить запрошенное.
rows=3— собрал по одной строке от каждого worker и leader (3 источника, каждый Partial Aggregate отдал 1 строку). - Partial Aggregate с
loops=3— выполнен в каждом из 3 «потоков» (2 workers + leader).rows=1— каждый отдал одну частичную сумму. - Parallel Seq Scan:
rows=33333, loops=3— каждый из 3 потоков обработал в среднем 33333 строки. Суммарно33333 × 3 = 99999≈ 100000 строк (это таблица orders на 100K с 1/6 status=‘paid’ ≈ 16666… но Filter accepted ~83333 строк? нет, тут была бы /6 — пример отбалансирован для иллюстрации). - Wall-clock:
actual time=...170 мсна одного worker × 3 loops. Но реальное Execution Time = 186 мс, не 510. Потому что workers работали параллельно.
Это эталонный parallel-план: workers равномерно нагружены, Partial Aggregate сводит до 1 строки на worker, передача через Gather минимальная.
Тюнинг
Ключевые параметры:
max_parallel_workers_per_gather— сколько workers под одним Gather. Default 2. Для аналитических queries иногда повышают до 4-8.max_parallel_workers— глобальный лимит. Default 8. Должен быть ≤max_worker_processes.parallel_setup_cost— overhead на запуск workers (для оценки). Default 1000. Если у вас на железе fork() дешёвый, можно уменьшить.parallel_tuple_cost— стоимость передачи одной строки между процессами. Default 0.1.min_parallel_table_scan_size— минимальный размер таблицы для parallel scan. Default 8 MB.min_parallel_index_scan_size— для индексов. Default 512 KB.
В песочнице — структура без распараллеливания
pglite parallel не показывает, но мы можем посмотреть, как Postgres решает: запросить parallel или нет, через настройку cost’ов.
Симуляция: на реальном PG план для COUNT по 100K строкам обычно становится Parallel при выключенном Index Scan и достаточном размере таблицы.
На реальном Postgres 17 такой план выглядит как:
Finalize Aggregate (cost=2401.50..2401.51 rows=1 width=8)
-> Gather (cost=2401.40..2401.41 rows=2 width=8)
Workers Planned: 2
-> Partial Aggregate (cost=1401.30..1401.31 rows=1 width=8)
-> Parallel Seq Scan on orders
(cost=0.00..1359.97 rows=16531 width=0)
Filter: (status = 'paid')
Структура: serial Aggregate (финальная сумма) → Gather → Partial Aggregate (частичная сумма каждого worker’а) → Parallel Seq Scan. Это классический паттерн двухфазной агрегации.
Через параметр force_parallel_mode (DEPRECATED, debug-only) или установку min_parallel_table_scan_size в 0 можно подтолкнуть планировщик. В pglite эффекта не будет, но синтаксис покажем.
На real PG последний запрос с этими настройками действительно перейдёт в parallel plan с 4 workers. pglite, вероятно, останется в serial.
Per-worker детали с VERBOSE
С EXPLAIN (ANALYZE, VERBOSE) план parallel-узла раскрывает per-worker метрики. Каждый Worker N получает свою строку с его собственным actual time, actual rows, Buffers.
-> Parallel Seq Scan on orders
(actual time=0.005..145.000 rows=33000 loops=3)
Buffers: shared hit=14000
Worker 0: actual time=0.010..150.000 rows=34500 loops=1
Buffers: shared hit=4800
Worker 1: actual time=0.008..148.000 rows=32500 loops=1
Buffers: shared hit=4700
(leader: actual time=0.005..140.000 rows=32000 loops=1
Buffers: shared hit=4500)
Видно: workers распределили работу почти равномерно (32000-34500 строк каждому). Если бы один worker занял 100000 строк, а другие по 5000 — это skew между workers, индикация плохого partitioning работы. Обычно из-за этого: тяжёлый Filter в одной части таблицы, или несбалансированный partition pruning. Решение — переписать predicate или увеличить параллелизм, чтобы skew был размыт.
Диагностика: Launched < Planned
Если в плане Workers Planned=4, Workers Launched=1 — у Postgres не было свободных слотов. Причины:
max_parallel_workersисчерпан другими процессами.max_worker_processesдостигнут.- Системные ограничения OS (fork failures).
Когда launched < planned, оставшуюся работу делает leader. План формально parallel, но эффективность ниже ожидаемой. Это нужно мониторить — статистика в pg_stat_database показывает parallel_workers_to_launch vs parallel_workers_launched.
Когда parallel-план хуже serial: реальный кейс
Бывает, что Postgres строит parallel-план, но реальное время хуже, чем serial. Причины — обычно одно из:
- Передача через Gather дороже работы. Если каждый worker производит 1M строк, а финальный Aggregate отдаёт 1 строку, имеет смысл агрегировать на worker’ах (Partial Aggregate) — overhead Gather пренебрежимо мал. Но если запрос отдаёт обратно 500K строк через Gather (например, без агрегации), стоимость
parallel_tuple_cost × rows = 0.1 × 500000 = 50000может перекрывать выигрыш. - Тяжёлые volatile-функции в SELECT list. Volatile функции принудительно делают план serial по этому участку.
- work_mem скопирован в каждом worker. Если запрос строит Hash table 200 MB и работают 4 worker’а, суммарное потребление памяти — 800 MB (без shared hash). С
Parallel Hashв PG 11+ это улучшено, но не везде.
Тестовый workflow: сравнить с SET max_parallel_workers_per_gather = 0; (forced serial). Если serial быстрее или сопоставимо — parallel не помогает.
Сравнение serial vs parallel-привезённый план: ставим max_parallel_workers_per_gather = 0 и сравниваем.
Чек-лист
Gather/Gather Merge— узел, собирающий строки от parallel workers + leader.Workers Planned— запрошено планировщиком;Workers Launched— реально стартовало.loops = Workers Launched + 1(если leader работал; обычно да).- rows × loops = total rows (как обычно).
- actual time НЕ умножается на loops — это wall-clock одного worker. Реальное время плана ≈ actual time корня.
- Parallel-aware узлы:
Parallel Seq Scan,Parallel Index Scan,Parallel Hash Join,Parallel Append. - Двухфазная агрегация:
Partial Aggregate(worker) →Gather→Aggregate(leader). - Тюнинг:
max_parallel_workers_per_gather,min_parallel_table_scan_size,parallel_setup_cost. - На pglite parallel-планов нет — этот урок про структуру real Postgres.