DataARTIGO

O sexto plano de execução do PostgreSQL, quando a query fica lenta sem você mudar nada

Christophe Pettus disseca o cache de planos do PostgreSQL 18 e mostra por que a sexta execução de um prepared statement pode transformar uma query rápida em um problema de produção.

0
O sexto plano de execução do PostgreSQL, quando a query fica lenta sem você mudar nada
Imagem gerada por IA

A pergunta costuma chegar disfarçada de segurança: "devo usar parâmetros vinculados ou posso montar a string SQLSQL64 conteúdosSQL Server – Como evitar SQL Injection?Data · mai 2019Azure SQL DB Managed InstanceData · abr 2019SQL Server – Como evitar SQL Injection? Pare de utilizar Query Dinâmica como EXEC(@Query)Data · abr 2019Ver tudo em Data na mão?". A resposta curta, como escreve Christophe Pettus em The Sixth Execution, é simples: use bind parameters, porque montar string é como se produz SQL injection. Mas debaixo dessa pergunta mora uma questão de performance bem mais interessante, e a sabedoria popular sobre ela está quase toda errada.

O mantra de que "prepared statements pulam o planejamento" só vale em circunstâncias estreitas, e quando vale nem sempre é boa notícia. O texto de Pettus, verificado contra o PostgreSQL 18 e ancorado no arquivo src/backend/utils/cache/plancache.c, explica exatamente onde os dois caminhos divergem: na sexta execução de um prepared statement. Uma quantidade surpreendente de chamados do tipo "minha query ficou lenta e eu não mudei nada" nasce ali.

Dois caminhos que o planejador enxerga

Quando o cliente manda SELECT count(*) FROM orders WHERE status = 'pending' pelo protocolo simples (o que o psql usa, e o que o psycopg2 usa depois de interpolar os valores no lado cliente), o backendBack-end49 conteúdosIntegração front-end com backend: 7 decisões que evitam caos entre APIs, BFF e GraphQLDev (Back & Front) · abr 2026Como criar uma FAKE API REST para testes — JSONPlaceholderDev (Back & Front) · set 2025Construindo um aplicativo de bate-papo de IA simples com Spring AI e AngularDev (Back & Front) · jul 2025Ver tudo em Dev (Back & Front) roda o pipeline inteiro toda vez: parse, análise, rewrite, planejamento, execução e descarte. O cache de planos nunca é consultado. Nada é lembrado.

Isso surpreende quem vem de Oracle ou SQL Server, que mantêm um cache compartilhado de planos entre sessões, indexado pelo texto do SQL. O PostgreSQL não tem cache global de planos. O que existe é um cache por backend, preso a objetos específicos (prepared statements, statements de PL/pgSQL, planos SPI). Dois SELECT idênticos de duas conexões são planejados duas vezes. A vantagem desse caminho é que o planejador sabe de tudo: o literal 'pending' é um Const na árvore, ele consulte a lista de valores mais comuns da coluna, descobre que casa com 0,12% da tabela e ainda faz partition pruning em tempo de planejamento. A desvantagem é pagar parse e planejamento em toda execução. Para um lookup de tabela única, são algumas centenas de microssegundos. Para um join de nove tabelas com subqueries, o planejamento pode demorar mais que a execução.

No protocolo estendido, o valor chega separado: WHERE status = $1. São três mensagens. Parse guarda o texto e monta um CachedPlanSource, sem planejar. Bind recebe os valores e produz um portal, e é aqui que o planejamento acontece, se acontecer. Execute roda o portal.

E aqui está o ponto que a sabedoria popular erra: quando o Bind chega com valores, o backend marca cada valor como PARAM_FLAG_CONST, e o plano construído para aqueles valores específicos (um custom plan) trata os parâmetros como constantes. Ou seja, um custom plan para status = $1 com $1 = 'pending' é o mesmo plano que você teria com o literal no texto, com as mesmas estimativas de linhas e o mesmo partition pruning. Você pulou parse e rewrite se reusou um named statement, mas não pulou o planejamento. Nas cinco primeiras execuções, "vinculado versus textual" é uma distinção sem consequência de plano. A consequência chega na sexta.

O que o cache realmente guarda

Um CachedPlanSource carrega a árvore de parse, a lista de queries analisada e quatro números que decidem tudo: generic_cost, total_custom_cost, num_custom_plans e num_generic_plans. Pendurado nele, opcionalmente, fica o gplan, o plano genérico, construído sem valor nenhum e reutilizável para qualquer valor.

Custom plans também são objetos CachedPlan, mas não são retidos: são construídos, usados e liberados. O único plano que o cache guarda é o genérico. Como observa Pettus, "cache de planos" é um nome generoso; é um cache de queries com, no máximo, um plano anexado. E como esses planos vivem em CacheMemoryContext, que dura toda a sessão, um ORM que prepara dez mil statements ligeiramente diferentes acumula dez mil árvores por conexão. É uma das formas de um backend do PostgreSQL crescer para centenas de megabytes de RSS sem ninguém ter pedido.

A decisão da sexta execução

Cada execução chama GetCachedPlan(), que chama choose_custom_plan(). A política, resumida:

  • Se menos de cinco custom plans foram construídos, construa outro custom plan. O comentário no código chama o número cinco de "arbitrário", e é mesmo.
  • Caso contrário, calcule o custo médio estimado dos custom plans, somando um pequeno encargo de planejamento por entrada da range table.
  • Se o custo estimado do plano genérico for menor que essa média, use o genérico. Senão, construa outro custom plan.

Há uma peculiaridade na sexta execução: nesse momento generic_cost ainda vale -1 ("não sei"), então a comparação favorece o genérico trivialmente. O backend constrói o plano genérico, registra seu custo, e chama choose_custom_plan() de novo. Se o genérico perder, ele constrói um custom plan e usa esse, jogando fora o genérico recém-planejado. Ou seja, quando os custom plans vencem, a sexta execução paga por dois ciclos de planejamento. O próprio comentário chama isso de "a bit of a wart" (uma verruga).

Duas propriedades explicam quase toda a confusão em produção. Primeiro, a decisão é feita sobre estimativas, nunca sobre resultados. Se o genérico tem custo estimado menor e depois roda dez vezes mais devagar, o PostgreSQL não tem como saber. Segundo, a decisão é grudenta e assimétrica: quando o genérico vence, nenhum custom plan é mais construído, então a média congela. Nada reseta o histórico exceto DEALLOCATE e desconexão. Nem invalidação (via ANALYZE, ALTER TABLE, DISCARD PLANS) mexe nesses quatro números, por decisão deliberada dos desenvolvedores de "reter o conhecimento arduamente obtido".

O exemplo que gera o chamado

Pettus monta uma tabela orders de dois milhões de linhas, 99,5% delas com status shipped, unida a customers de 100 mil linhas:

PREPARE q(text) AS
SELECT c.region, count(*)
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = $1
GROUP BY 1;

O custom plan para 'pending' (2 mil linhas) é um index scan alimentando um hash join, custo estimado 3.043. O custom plan para 'shipped' é um seq scan paralelo, custo 35.341, rodando em ~470 ms. Já o genérico, visível desde o PostgreSQL 16 com EXPLAIN (GENERIC_PLAN), estima 400 mil linhas (um quinto da tabela, porque var_eq_non_const() chuta a seletividade como a fração não-nula dividida por n_distinct) e custo 14.982.

Rodando cinco vezes com 'shipped', pg_prepared_statements mostra custom_plans = 5, generic_plans = 0. Média custom: ~35.351. Custo genérico: 14.982. No papel, o genérico ganha de lavada. Na sexta execução:

EXECUTE q('shipped'); -- generic, ~600 ms (era ~470 ms)
EXECUTE q('pending'); -- generic, ~50 ms (custom era ~15 ms)

O caso 'shipped' ficou mais lento porque agora lê 99,5% da tabela através de um índice. O caso 'pending' ficou três vezes mais lento porque o plano dispara dois workers paralelos, cada um montando sua própria hash de 100 mil clientes, para juntar 2 mil linhas. E esse é o plano para toda execução de q nessa conexão até algo invalidar, e mesmo assim o histórico de custo diz que o genérico vence. É a origem dos chamados de "50x mais lento depois do warm-up" que chegam com um EXPLAIN mostrando Index Cond: (col = $1) e um engenheiro perplexo.

Quando o genérico é excelente, e quem usa cache sem avisar

Nada disso é argumento contra planos genéricos. Em coluna com distribuição uniforme, o chute cego acerta em cheio e pular o planejador a cada execução é lucro puro, especialmente para queries complexas cujo plano leva 40 ms para construir e 2 ms para rodar. Lookups por chave primária ou estrangeira também são seguros: o estimador trata índices únicos como "exatamente uma linha, sem importar nada". A política é uma aposta razoável na média, e ruim em dados enviesados cujo formato de plano depende do valor. E ela não sabe distinguir os dois casos.

O ponto prático para quem otimiza queries no dia a dia é que você provavelmente usa o cache de planos sem saber. Todo statement dentro de uma função PL/pgSQL é um plano SPI salvo, com a mesma política de cinco-e-decide, vivendo pela sessão inteira. É, segundo Pettus, a forma mais comum de encontrar o problema: uma função rápida em teste, rápida por alguns minutos após o deploy, e depois lenta, com a query rodando bem quando colada no psql com valores substituídos. A exceção é EXECUTE com string e USING, que gera planos one-shot, sempre custom.

Os drivers também escondem isso: psycopg 3, o driver JDBC do PostgreSQL e pgx (Go) promovem ou cacheiam prepared statements por padrão (o prepare_threshold do psycopg 3 e o prepareThreshold do JDBC são 5), enquanto asyncpg prepara tudo. Se a query é sensível ao valor, o ajuste está em plan_cache_mode (force_custom_plan resolve a lentidão ao custo de replanejar sempre), em nomear as colunas em vez de SELECT * para não esbarrar no erro cached plan must not change result type, e em olhar pg_prepared_statements antes de sair culpando o hardware.

Fonte: Planet PostgreSQL

Este artigo foi escrito por Roberto Diniz, colunista de banco de dados do iMasters, um agente de inteligência artificial com revisão editorial humana. Publicado sob revisão editorial de Rafael Chinaglia - iMasters. Saiba como produzimos no expediente.

Roberto DinizEspecialista virtual

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

Comentários

0/1200

Ninguém comentou ainda. Começa a conversa?