Dev & EngARTIGO

PostgreSQL: max_parallel_workers não faz o que o nome sugere

Christophe Pettus destrincha dois GUCs de paralelismo que todo DBA configura errado ao menos uma vez: um governa por nó do plano de execução, o outro disputa um pool compartilhado sem avisar ninguém.

Christophe Pettus, no blog Planet PostgreSQLPostgreSQL11 conteúdosPostgreSQL via SSL com GolangData · abr 20195 itens legais sobre data types do PostgreSQLData · mar 20195 serviços gratuitos na cloud para bancos de dados PostgresData · fev 2025Ver tudo em Data , publicou mais um capítulo da série "All Your GUCs in a Row" e escolheu um par de parâmetros que costuma confundir até quem já mexe com paralelismo há anos: max_parallel_workers_per_gather e max_parallel_workers. O argumento central do texto é direto: nenhum dos dois faz exatamente o que o nome promete, e a diferença entre eles explica boa parte dos planos de execução que "deveriam" ser paralelos e não são.

O que cada GUC controla, de fato

max_parallel_workers_per_gather é o teto de workers que o planner pode pedir para um único nó Gather ou Gather Merge no plano. Ele chegou no PostgreSQL 9.6 junto com o parallel query, com valor padrão 0 (ou seja, desligado). A versão 10 ligou o paralelismo por padrão ao subir esse valor para 2, e criou max_parallel_workers na mesma release para impedir que consultas paralelas tomassem todo o pool de background workers do servidor.

max_parallel_workers, por sua vez, é documentado como limite para o cluster inteiro, mas na prática é aplicado de um jeito bem mais frágil: comparando uma contagem global (em memória compartilhada) contra um valor que cada sessão escolhe para si mesma. Essa assimetria é a raiz de quase todo comportamento estranho que Pettus descreve no artigo.

Ambos aceitam valores de 0 a 1024 e são de contexto user, o que significa que qualquer role pode alterá-los com um SET simples, sem privilégio especial.

Como o planner decide quantos workers pedir

O planner nunca lê max_parallel_workers_per_gather diretamente para decidir a estimativa; ele calcula um número de workers a partir do tamanho esperado de leitura da tabela, usando min_parallel_table_scan_size (8MB por padrão) como base: um worker a partir de 8MB, e mais um a cada vez que esse valor triplica. Isso dá uma progressão conhecida: 24MB pede dois workers, 72MB pede três, 216MB quatro, 648MB cinco, 1.9GB seis, 5.7GB sete, e o oitavo só aparece a partir de 17GB. Uma tabela de 10GB, portanto, nunca vai pedir mais que sete workers, não importa o quanto se aumente o limite por gather. É por isso que subir max_parallel_workers_per_gather para 16 num banco cujas maiores tabelas somam poucos gigabytes muda bem menos do que a intuição sugere: o parâmetro é um teto, não um incentivo.

Existem dois jeitos de contornar essa regra de tamanho. O primeiro é o storage parameter parallel_workers (ALTER TABLE t SET (parallel_workers = 16)), que substitui o cálculo automático para aquela tabela específica. O segundo é o enable_parallel_append: quando ativo, um nó Parallel Append pede pelo menos log2 do número de partições mais um, então 64 partições pequenas já pedem sete workers independentemente do tamanho individual de cada uma. Em ambos os casos, o teto de max_parallel_workers_per_gather continua valendo.

Um detalhe que passa despercebido: o nome diz "per gather" e é literal. Um plano com dois nós Gather pode consumir o dobro do limite configurado — Pettus relata ter observado um merge join entre dois subplanos Gather Merge rodando quatro workers simultâneos com o parâmetro fixado em 2. Além disso, como o parallel_leader_participation está ativo por padrão, o processo líder também executa parte do trabalho paralelo, então "2" na prática significa três processos, cada um com sua própria fatia de work_mem.

O limite cluster-wide que não é um limite de verdade

A parte mais contraintuitiva do artigo é como max_parallel_workers é de fato aplicado. No código-fonte do PostgreSQL 18, ele é consultado em exatamente uma linha: dentro de RegisterDynamicBackgroundWorker(), que recusa um novo worker paralelo quando o número já em execução atingiu o limite. O planner nunca olha para esse valor ao montar o plano. Consequência prática: setar max_parallel_workers = 0 não desliga o parallel query. O plano continua sendo gerado como se tivesse ajuda, só que ninguém aparece para executá-lo:

SET max_parallel_workers = 0;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, BUFFERS OFF)
SELECT count(*) FROM t WHERE k = 7;
 Finalize Aggregate (actual rows=1.00 loops=1)
 -> Gather (actual rows=1.00 loops=1)
 Workers Planned: 2
 Workers Launched: 0

O líder executa tudo sozinho, usando um plano custado com base na expectativa de ajuda que nunca chega. Para desligar paralelismo de verdade, o GUC certo é max_parallel_workers_per_gather = 0. E o pior: um worker recusado não gera erro nem grava nada no log. A única evidência é a diferença entre Workers Planned e Workers Launched no EXPLAIN (ANALYZE), o que na prática significa descobrir o problema uma query de cada vez, depois que alguém reclama de lentidão.

O caso do "estagiário hostil"

Como a contagem que importa é global mas o limite comparado é por sessão, Pettus monta um cenário revelador: com max_parallel_workers = 2 no postgresql.conf, uma role sem privilégio nenhum além de SELECT numa tabela consegue fazer isto:

SET max_parallel_workers = 1024;
SET max_parallel_workers_per_gather = 64;
SET min_parallel_table_scan_size = 0;

O resultado, com max_worker_processes no padrão de 8 (um slot já ocupado pelo launcher de replicação lógica), foi um plano pedindo dez workers e conseguindo sete — e, enquanto essa query rodava, outra sessão com plano paralelo não lançou nenhum worker, e uma CREATE SUBSCRIPTION criada no mesmo servidor ficou sem worker de apply, sem erro visível além de um WARNING: out of background worker slots repetido no log a cada cinco segundos. A réplica só voltou a funcionar quando a query foi cancelada. Não é preciso um estagiário malicioso: basta alguém colar um SET max_parallel_workers = 64 copiado de um blog dentro de um job de relatório noturno.

PostgreSQL 18 dá visibilidade ao problema

Até a versão 17, a única forma de monitorar isso era contar linhas com backend_type = 'parallel worker' em pg_stat_activity (agrupando por leader_pid para saber de quem são) e comparar com o limite configurado. O PostgreSQL 18 resolve isso de forma nativa: pg_stat_database e pg_stat_statements ganharam as colunas parallel_workers_to_launch e parallel_workers_launched. Uma diferença crescente entre as duas é sinal direto de que o pool está subdimensionado, e passa a ser algo que dá para monitorar de forma agregada, não query por query.

Como tunar na ordem certa

A recomendação de Pettus é configurar esses parâmetros na ordem inversa da alfabética. Primeiro max_worker_processes, porque exige restart e é o único limite realmente rígido dos três, compartilhado com replicação lógica (max_logical_replication_workers) e com o que as extensões registrarem. A conta sugerida: max_parallel_workers mais max_logical_replication_workers mais o que as extensões usarem, mais uma folga de quatro a oito. Para max_parallel_workers, o número sugerido é de duas a três vezes o número de cores, caindo para cerca de 1,5x em máquinas com 32 ou mais cores, generoso de propósito porque o custo de um pool esgotado é um plano paralelo executado por um único processo.

max_parallel_workers_per_gather deve ficar em 2 globalmente em qualquer instância que atenda tráfego OLTP, subindo apenas onde há scans grandes de fato, via ALTER ROLE ... SET max_parallel_workers_per_gather = 6. Passar de 8 raramente compensa, a menos que o volume escaneado esteja na casa de dezenas de gigabytes; nesse caso, a via correta é o storage parameter parallel_workers na tabela específica. Por fim, vale caçar em pg_db_role_setting, no código da aplicação e em cron jobs qualquer SET max_parallel_workers esquecido: é esse valor, e não o do postgresql.conf, que efetivamente vira o teto do cluster.

Para quem constrói sobre PostgreSQL e depende de queries analíticas pesadas convivendo com OLTP no mesmo cluster, o recado prático é claro: antes de subir cegamente max_parallel_workers_per_gather numa query lenta, vale rodar o EXPLAIN (ANALYZE) e comparar Workers Planned com Workers Launched. Se a diferença for zero, o problema não está no gather, está no pool compartilhado, e a correção passa por max_worker_processes, não pelo parâmetro que parece mais óbvio.

Fonte: Planet PostgreSQL

Este artigo foi escrito por Roberto Diniz, colunista de banco de dados. Conteúdo produzido por agente de IA da redação iMasters, sob revisão editorial humana. Saiba como produzimos no expediente.

Roberto DinizColunista

Especialista virtual de banco de dados e engenharia de dados. DBA veterano, TI tradicional: modelagem, performance de query, integridade e governança. Formal e criterioso — desconfia de modinha e preza consistência, backup e o plano de execução.

Ver perfil