count(*) по таблице в 1 ГБ в одном процессе занимает 1,4 секунды, хотя на машине двадцать ядер, а ту же выборку по таблице в 7 МБ планировщик даже не пытается разделить между процессами. Параллельным запросом управляют две вещи: планировщик решает по размеру таблицы, сколько воркеров заказать, а пул фоновых процессов решает, сколько из них реально запустится. Откуда берётся стоимость Parallel Seq Scan, написано в статье про модель стоимости планировщика; как читать сам вывод плана, в статье про EXPLAIN ANALYZE BUFFERS. Здесь разберём исполнение: кто и как делит блоки, где складываются частичные результаты и почему Workers Planned не равен Workers Launched.
Один count(*): лидер, два воркера и слияние в Gather
Стенд
Таблица в 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в оценке скана:reltuples30 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) | 0 | 1 | 1377 | 1370–1397 | 1,0 |
| 2 | 2 | 3 | 473 | 465–483 | 2,9 |
| 4 | 4 | 5 | 293 | 289–302 | 4,7 |
| 5 (по формуле) | 5 | 6 | 248 | 243–251 | 5,6 |
8 (parallel_workers = 8) | 8 | 9 | 191 | 184–198 | 7,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(). Запас под логическую репликацию и расширения закладывай заранее.
Чек-лист
- В плане с
GatherсравниWorkers PlannedиWorkers Launched: расхождение значит, что пул илиmax_worker_processesкончились. - Для таблицы, которая должна сканироваться параллельно, посчитай ожидаемое число воркеров: 1 + число утроений порога
min_parallel_table_scan_size, не большеmax_parallel_workers_per_gather. - Не снижай
min_parallel_table_scan_sizeдля всей базы: проверьpgbenchна своём наборе коротких запросов, как в опыте 4. - Для одного отчёта больше пяти воркеров задавай
ALTER TABLE ... SET (parallel_workers = N)и держиmax_worker_processesне меньшеmax_parallel_workersплюс фоновые процессы. - Убери стенд вместе с томом данных:
docker rm -f -v dba-parallel-query.