Справочник DBA: базы данных изнутри читается по порядку

Статья 5.7 · раздел 5 «Планировщик и статистика» · 38 из 109

Параллельные запросы PostgreSQL: Gather, воркеры и частичные агрегаты

опубликовано
читать
≈ 8 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18; таблица на 30 млн строк (1038 МБ), 20 ядер хоста с другими контейнерами рядом, NVMe, jit = off; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

count(*) по таблице в 1 ГБ в одном процессе занимает 1,4 секунды, хотя на машине двадцать ядер, а ту же выборку по таблице в 7 МБ планировщик даже не пытается разделить между процессами. Параллельным запросом управляют две вещи: планировщик решает по размеру таблицы, сколько воркеров заказать, а пул фоновых процессов решает, сколько из них реально запустится. Откуда берётся стоимость Parallel Seq Scan, написано в статье про модель стоимости планировщика; как читать сам вывод плана, в статье про EXPLAIN ANALYZE BUFFERS. Здесь разберём исполнение: кто и как делит блоки, где складываются частичные результаты и почему Workers Planned не равен Workers Launched.

DBA / 01

Один count(*): лидер, два воркера и слияние в Gather

Параллельный count(*) по таблице big: три участника и слияние частичных счётчиков Таблица big, 132 800 страниц и 30 000 000 строк, раздаётся порциями страниц трём участникам: лидеру, воркеру 0 и воркеру 1. Лидер посчитал 9 967 134 строки, воркер 0 посчитал 9 951 232, воркер 1 посчитал 10 081 634. Каждый участник держит свой Partial Aggregate. Узел Gather получает три частичных count, Finalize Aggregate складывает их в 30 000 000. Workers Planned 2, Workers Launched 2. big: 132 800 страниц · 30 000 000 строк порции страниц получает тот, кто свободен Лидер 9 967 134 строк Partial Aggregate Воркер 0 9 951 232 строк Partial Aggregate Воркер 1 10 081 634 строк Partial Aggregate Gather 3 частичных count(*) Planned 2 · Launched 2 Finalize Aggregate count = 30 000 000
Лидер тоже сканирует таблицу: из 30 млн строк на каждого из трёх участников пришлось около трети. Частичные счётчики встречаются только в Gather и в Finalize Aggregate.

Стенд

Таблица в 30 млн строк с одним столбцом, около гигабайта: меньше диск на машине, а механизм тот же. Вторая таблица на 200 тысяч строк нужна для порога.

docker run -d --name dba-parallel-query -e POSTGRES_PASSWORD=pg postgres:18
docker exec -it dba-parallel-query psql -U postgres
SELECT version();
CREATE TABLE big AS SELECT g AS id FROM generate_series(1, 30000000) g;
CREATE TABLE small AS SELECT g AS id FROM generate_series(1, 200000) g;
VACUUM ANALYZE big; VACUUM ANALYZE small;
SELECT relname, pg_size_pretty(pg_relation_size(oid)), relpages, reltuples::bigint
FROM pg_class WHERE relname IN ('big', 'small');
SHOW max_parallel_workers_per_gather;  SHOW max_parallel_workers;
SHOW max_worker_processes;             SHOW min_parallel_table_scan_size;
SHOW parallel_setup_cost;

Вывод:

PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
 big   | 1038 MB | 132800 | 30001796
 small | 7168 kB |    896 |   200000
 2
 8
 8
 8MB
 1000

Значения по умолчанию: два воркера на Gather, пул max_parallel_workers на 8 процессов, общий предел фоновых процессов max_worker_processes тоже 8, порог 8 МБ, цена запуска 1000. Дальше везде SET jit = off: иначе в плане появляется блок JIT с миллисекундами компиляции и сравнение мешается. Одинаковые запросы выполнялись по семь раз; в таблицах ниже медиана.

Опыт 1: один запрос, три процесса

SET jit = off;
EXPLAIN (ANALYZE, VERBOSE, BUFFERS OFF) SELECT count(*) FROM big;

Вывод:

Finalize Aggregate  (cost=290059.57..290059.58 rows=1 width=8) (actual time=493.366..495.714 rows=1.00 loops=1)
  Output: count(*)
  ->  Gather  (cost=290059.35..290059.56 rows=2 width=8) (actual time=493.297..495.708 rows=3.00 loops=1)
        Output: (PARTIAL count(*))
        Workers Planned: 2
        Workers Launched: 2
        ->  Partial Aggregate  (cost=289059.35..289059.36 rows=1 width=8) (actual time=491.500..491.501 rows=1.00 loops=3)
              Output: PARTIAL count(*)
              Worker 0:  actual time=490.763..490.764 rows=1.00 loops=1
              Worker 1:  actual time=490.763..490.763 rows=1.00 loops=1
              ->  Parallel Seq Scan on public.big  (cost=0.00..257807.48 rows=12500748 width=0) (actual time=0.146..266.287 rows=10000000.00 loops=3)
                    Output: id
                    Worker 0:  actual time=0.043..265.858 rows=9951232.00 loops=1
                    Worker 1:  actual time=0.050..265.582 rows=10081634.00 loops=1
Planning Time: 0.195 ms
Execution Time: 495.773 ms

План читается снизу вверх, как обычно, но три строки в нём новые.

  • loops=3 у Partial Aggregate и у скана: процессов три, хотя воркеров заказано два. Третий — лидер, он сканирует сам. Строк у воркеров 9 951 232 и 10 081 634, значит, у лидера 30 000 000 − 9 951 232 − 10 081 634 = 9 967 134. Цифры у воркеров свои, потому что работа не нарезана заранее: блоки выдаются порциями, и кому достался более лёгкий кусок, тот берёт следующий.
  • Gather вернул rows=3.00: три готовых счётчика, а не 30 млн строк. PARTIAL count(*) в Output — признак того, что агрегат разделён. Данные между процессами не гоняются; через очередь кортежей идут только эти три числа, а складывает их Finalize Aggregate.
  • rows=12500748 в оценке скана: reltuples 30 001 796, делённые на 2,4. Планировщик считает, что лидер вносит 1 − 0,3 × 2 = 0,4 «воркера», потому что часть времени он читает очередь от остальных. С пятью воркерами вклад лидера в оценке нулевой.

Размер порции задаёт код СУБД. В tableam.c она зависит от числа блоков таблицы:

	pbscanwork->phsw_chunk_size = pg_nextpower2_32(Max(pbscan->phs_nblocks /
													   PARALLEL_SEQSCAN_NCHUNKS, 1));

PARALLEL_SEQSCAN_NCHUNKS равно 2048, PARALLEL_SEQSCAN_MAX_CHUNK_SIZE — 8192 блока. Для 132 800 страниц получается 132 800 / 2048 = 64 (целая часть), ближайшая степень двойки — 64 страницы, то есть порция в 512 КБ. Эта цифра выведена из кода и в выводе опыта её нет.

Опыт 2: сколько воркеров заказывает планировщик

Число воркеров считает compute_parallel_worker по размеру таблицы в страницах:

			heap_parallel_threshold = Max(min_parallel_table_scan_size, 1);
			while (heap_pages >= (BlockNumber) (heap_parallel_threshold * 3))
			{
				heap_parallel_workers++;
				heap_parallel_threshold *= 3;
				if (heap_parallel_threshold > INT_MAX / 3)
					break;		/* avoid overflow */
			}

Один воркер даётся при размере от min_parallel_table_scan_size (8 МБ = 1024 страницы), и ещё по одному на каждое утроение. Для big: 132 800 больше 3072, 9216, 27 648 и 82 944, но меньше 248 832, то есть 1 + 4 = 5 воркеров. Параметр max_parallel_workers_per_gather только режет это число сверху. Проверка: пять воркеров запрошены при лимите 8, и они же планируются. Параметр таблицы parallel_workers обходит формулу, это единственный способ получить больше пяти:

SET jit = off;  SET max_parallel_workers_per_gather = 8;
ALTER TABLE big SET (parallel_workers = 2);  -- затем 0, 4, 8 и RESET (parallel_workers)
EXPLAIN (ANALYZE, BUFFERS OFF) SELECT count(*) FROM big;
Заказано воркеровЗапущеноПроцессов с лидеромВремя, мс (медиана)Разброс, мсУскорение
0 (parallel_workers = 0)0113771370–13971,0
223473465–4832,9
445293289–3024,7
5 (по формуле)56248243–2515,6
8 (parallel_workers = 8)89191184–1987,2

Семь прогонов шли вперемешку: круг по всем пяти настройкам, потом следующий круг. Так посторонняя нагрузка на машине ложится на все строки одинаково. Абсолютные числа зависят от соседей по хосту, отношения устойчивее. Ускорение в 7,2 раза на девяти процессах: каждый следующий воркер даёт меньше: переход с 5 процессов на 9 снизил время с 293 до 191 мс, а не вдвое.

Опыт 3: Planned не равно Launched

Workers Planned — это план, Workers Launched — факт. Пул ограничивает max_parallel_workers; его значение меняется перезагрузкой конфигурации, без рестарта.

ALTER SYSTEM SET max_parallel_workers = 3;
SELECT pg_reload_conf();
SET jit = off;  SET max_parallel_workers_per_gather = 8;
EXPLAIN (ANALYZE, BUFFERS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM big;

Вывод:

Finalize Aggregate  (cost=208805.01..208805.02 rows=1 width=8) (actual rows=1.00 loops=1)
  ->  Gather  (cost=208804.49..208805.00 rows=5 width=8) (actual rows=4.00 loops=1)
        Workers Planned: 5
        Workers Launched: 3
        ->  Partial Aggregate  (cost=207804.49..207804.50 rows=1 width=8) (actual rows=1.00 loops=4)
              ->  Parallel Seq Scan on big  (cost=0.00..192803.59 rows=6000359 width=0) (actual rows=7500000.00 loops=4)

Заказано пять, запущено три: время запроса 385 мс (медиана семи, разброс 384–409). Оценка осталась прежней, «на пятерых»: rows=6000359 это 30 001 796 / 5. Исполнитель план не пересчитывает: четыре участника (лидер и три воркера) прошли по 7 500 000 строк вместо 6 000 000, и запрос не упал. Ограничение в коде стоит в RegisterDynamicBackgroundWorker:

	if (parallel && (BackgroundWorkerData->parallel_register_count -
					 BackgroundWorkerData->parallel_terminate_count) >=
		max_parallel_workers)
	{

Если воркер не зарегистрирован, LaunchParallelWorkers не считает его в nworkers_launched и пробует остальные. Так же устроен второй предел: общее число фоновых процессов max_worker_processes, по умолчанию 8. Пул в 8 воркеров и заказ в 8 (для этого на таблице стоит parallel_workers = 8) дают запуск лишь 7:

 Workers Planned: 8  Workers Launched: 7

Восьмой слот занят постоянным фоновым процессом logical replication launcher, он виден в pg_stat_activity и регистрируется в ApplyLauncherRegister. После ALTER SYSTEM SET max_worker_processes = 9 и перезапуска контейнера запускаются все восемь:

 Workers Planned: 8  Workers Launched: 8

max_worker_processes общий для параллельных воркеров, логической репликации и расширений. Реальный потолок параллельных воркеров — max_worker_processes минус постоянные фоновые процессы, а не значение max_parallel_workers.

Опыт 4: почему на таблице в 7 МБ параллелизма нет

SELECT pg_relation_size('small') / 8192 AS pages;
EXPLAIN SELECT count(*) FROM small;
SET min_parallel_table_scan_size = 0;
EXPLAIN SELECT count(*) FROM small;

Вывод:

 896
Aggregate  (cost=3396.00..3396.01 rows=1 width=8)
  ->  Seq Scan on small  (cost=0.00..2896.00 rows=200000 width=0)

Finalize Aggregate  (cost=2937.88..2937.89 rows=1 width=8)
  ->  Gather  (cost=2937.67..2937.88 rows=2 width=8)
        Workers Planned: 2
        ->  Partial Aggregate  (cost=1937.67..1937.68 rows=1 width=8)
              ->  Parallel Seq Scan on small  (cost=0.00..1729.33 rows=83333 width=0)

896 страниц меньше 1024, и compute_parallel_worker возвращает 0 ещё до сравнения стоимостей. Порог в 8 МБ эвристика, а не результат расчёта: без него планировщик сам выбирает параллельный план, он дешевле (2937,89 против 3396,01). Если порог снять, на одиночном запросе так и выходит быстрее: медиана девяти прогонов EXPLAIN ANALYZE 7,3 мс против 9,9 мс. Выигрыш пропадает, когда запросов много. pgbench с 16 клиентами по 10 секунд на запросе select count(*) from small;:

docker exec -i dba-parallel-query sh -c 'echo "select count(*) from small;" > /tmp/q.sql'
docker exec -e PGOPTIONS="-c jit=off -c min_parallel_table_scan_size=0" dba-parallel-query \
  pgbench -U postgres -n -c 16 -j 4 -T 10 -f /tmp/q.sql postgres

С порогом 8 МБ (по умолчанию) три прогона дали 2481, 2496 и 2498 запросов в секунду, без порога 1748, 1842 и 1988. Медиана 2496 против 1842: параллельные планы отняли 26 %. Разница — накладные расходы на запуск воркеров и сборку результата, к тому же 16 клиентов заказывают 16 × 2 = 32 воркера при пуле в 8. Порог защищает от именно такой нагрузки.

Что из этого следует

  • Параллелизм ускоряет редкие тяжёлые запросы: отчёты, count(*), агрегаты по большим таблицам. На OLTP с тысячами коротких запросов он вреден, и min_parallel_table_scan_size лучше не снижать. Опыт 4: при 16 клиентах пропускная способность упала на 26 %.
  • Ускорение не линейно: 7,2 раза на 9 процессах в опыте 2. Больше пяти воркеров на запрос ставь только для конкретной таблицы через parallel_workers, а не общим max_parallel_workers_per_gather.
  • Пул max_parallel_workers общий на всю инстанцию. Если несколько тяжёлых запросов идут одновременно, каждый получит Launched меньше Planned (опыт 3: 3 из 5), и время станет непредсказуемым. Следи за этим в EXPLAIN (ANALYZE) и при необходимости поднимай max_worker_processes, а не только пул.
  • max_worker_processes меняется только рестартом, max_parallel_workers — pg_reload_conf(). Запас под логическую репликацию и расширения закладывай заранее.

Чек-лист

  1. В плане с Gather сравни Workers Planned и Workers Launched: расхождение значит, что пул или max_worker_processes кончились.
  2. Для таблицы, которая должна сканироваться параллельно, посчитай ожидаемое число воркеров: 1 + число утроений порога min_parallel_table_scan_size, не больше max_parallel_workers_per_gather.
  3. Не снижай min_parallel_table_scan_size для всей базы: проверь pgbench на своём наборе коротких запросов, как в опыте 4.
  4. Для одного отчёта больше пяти воркеров задавай ALTER TABLE ... SET (parallel_workers = N) и держи max_worker_processes не меньше max_parallel_workers плюс фоновые процессы.
  5. Убери стенд вместе с томом данных: docker rm -f -v dba-parallel-query.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru