Learning Platform
Урок 10.04 · 24 мин
Продвинутый
Parallel queryGatherWorkersParallel Seq ScanParallel Hash Join

С Postgres 9.6 executor умеет выполнять часть плана параллельно — в нескольких процессах-воркерах (background workers). Это особенно сильно ускоряет analytical-запросы по большим таблицам: один worker сканирует половину таблицы, другой — вторую, и leader-процесс агрегирует результаты. Чтение plan’а parallel-запроса требует понимания нескольких новых узлов и одной важной интерпретации loops.

Важно: pglite (на котором работают песочницы в курсе) parallel-планы не показывает — там один процесс. Поэтому в этом уроке мы будем разбирать реальные планы, скопированные из production Postgres, и обсуждать их. Песочницы здесь — для проверки общей структуры.

Когда Postgres решает идти в параллель

Параллельный план рассматривается планировщиком, если:

  1. Включён
    max_parallel_workers_per_gather
    ≥ 1 (по умолчанию 2).
  2. Стоимость без параллели выше parallel_setup_cost + parallel_tuple_cost (по умолчанию 1000 + 0.1). На маленьких запросах overhead не оправдан.
  3. Размер таблицы ≥ min_parallel_table_scan_size (по умолчанию 8 MB) или для индексов min_parallel_index_scan_size (512 KB).
  4. Запрос может быть распараллелен. Не каждый план parallelizable: некоторые функции (помеченные VOLATILE или UNSAFE) ломают parallel safety.

Если все условия выполнены, планировщик встраивает в дерево узел Gather или Gather Merge и пускает дочерние ветки в parallel-режим.

Структура parallel-плана

Parallel plan: leader + workers

Gather собирает строки от N workers + leader. Каждый worker исполняет одно и то же поддерево; данные разделяются динамически по блокам.

Корень: Gather Merge / Gatherleader process; собирает строки от workers
Workers Planned: 2 Workers Launched: 2запросили 2, реально стартовали 2
Worker 0Parallel Seq Scan blocks 0..N/3
Worker 1Parallel Seq Scan blocks N/3..2N/3
Leaderтоже работает: blocks 2N/3..N
loops = workers + 1 (если leader работал)3 итерации в плане

Типичный текстовый вывод:

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:

  1. Маленькая таблица. Если seq scan стоит 50 мс, overhead на запуск workers (~50 мс на воркер) съест весь выигрыш.
  2. Запрос ограничен I/O bandwidth. Если узкое место — disk read, увеличение CPU-параллелизма не поможет; диск всё равно отдаёт 200 MB/s. На SSD/NVMe эффект иногда есть, на HDD — почти всегда нет.
  3. High concurrency. 100 одновременных запросов уже загружают CPU; запускать каждому из них ещё по 2 worker’а — путь к перегрузке. Параметр max_parallel_workers глобален: если он 8, а у вас 100 запросов, большая часть получит Workers Launched=0.
  4. 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

Что видим:

  1. Корень — Finalize Aggregate. Это leader-узел, не parallel. Принимает частичные суммы от Gather и финализирует.
  2. Gather: Planned 2, Launched 2 — runtime смог запустить запрошенное. rows=3 — собрал по одной строке от каждого worker и leader (3 источника, каждый Partial Aggregate отдал 1 строку).
  3. Partial Aggregate с loops=3 — выполнен в каждом из 3 «потоков» (2 workers + leader). rows=1 — каждый отдал одну частичную сумму.
  4. Parallel Seq Scan: rows=33333, loops=3 — каждый из 3 потоков обработал в среднем 33333 строки. Суммарно 33333 × 3 = 99999 ≈ 100000 строк (это таблица orders на 100K с 1/6 status=‘paid’ ≈ 16666… но Filter accepted ~83333 строк? нет, тут была бы /6 — пример отбалансирован для иллюстрации).
  5. 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 и достаточном размере таблицы.

PostgreSQL

На реальном 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 эффекта не будет, но синтаксис покажем.

PostgreSQL

На 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 не было свободных слотов. Причины:

  1. max_parallel_workers исчерпан другими процессами.
  2. max_worker_processes достигнут.
  3. Системные ограничения OS (fork failures).

Когда launched < planned, оставшуюся работу делает leader. План формально parallel, но эффективность ниже ожидаемой. Это нужно мониторить — статистика в pg_stat_database показывает parallel_workers_to_launch vs parallel_workers_launched.

Когда parallel-план хуже serial: реальный кейс

Бывает, что Postgres строит parallel-план, но реальное время хуже, чем serial. Причины — обычно одно из:

  1. Передача через Gather дороже работы. Если каждый worker производит 1M строк, а финальный Aggregate отдаёт 1 строку, имеет смысл агрегировать на worker’ах (Partial Aggregate) — overhead Gather пренебрежимо мал. Но если запрос отдаёт обратно 500K строк через Gather (например, без агрегации), стоимость parallel_tuple_cost × rows = 0.1 × 500000 = 50000 может перекрывать выигрыш.
  2. Тяжёлые volatile-функции в SELECT list. Volatile функции принудительно делают план serial по этому участку.
  3. 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 и сравниваем.

PostgreSQL
Проверка знанийKnowledge check
План: Parallel Seq Scan (actual time=0.005..1200 rows=200000 loops=3). Сколько всего строк прошло через узел, и какое реальное wall-clock время выполнения этой части?
ОтветAnswer
Total rows = 200000 × 3 = 600000. Это работает как обычно: rows — среднее на итерацию, умножаем на loops для суммы. А вот с time — обратная логика: actual time=1200 мс — это время одного worker. Все 3 (2 workers + leader) работали ПАРАЛЛЕЛЬНО, поэтому wall-clock — ~1200 мс, а НЕ 3600 мс. Если бы это было serial-выполнение, время было бы суммой; в parallel — максимумом (или близко к нему). В Execution Time в конце плана увидишь именно ~1200 мс, не 3600. Это самая частая ошибка при чтении parallel-плана: умножать time × loops.

Чек-лист

  • 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) → GatherAggregate (leader).
  • Тюнинг: max_parallel_workers_per_gather, min_parallel_table_scan_size, parallel_setup_cost.
  • На pglite parallel-планов нет — этот урок про структуру real Postgres.
EXPLAIN PIPELINE: читаем DAG процессоров

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

Результат: 0 из 0
Аналитический
Вопрос 1 из 4. В parallel-плане видишь Parallel Seq Scan с actual time=1500 ms и loops=3. Какое реальное wall-clock время этого узла?

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

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

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

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