PostgreSQL 18: treze medições pequenas que mudam a performance de verdade
- Leitura
- 7 min
- Tabelas
- 4
- Fontes
- 1
Um benchmark artesanal no PostgreSQL 18.6 mede índices, WAL e work_mem com números reais: alguns ganhos chegam a 270 vezes, outros não valem o esforço.
O autor do blog Explain, Measured publicou em 8 de novembro de 2026 uma lista de treze medições feitas em uma única sessão de testes no PostgreSQL↳PostgreSQL11 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 → 18.6. Nada de benchmark de marketing: são comparações pontuais, com EXPLAIN (ANALYZE, BUFFERS, TIMING OFF), mediana de cinco execuções após um aquecimento, rodando numa única máquina (MacBook Air M5, 10 núcleos, 24 GB de RAM, configuração padrão com work_mem em 4MB e shared_buffers em 128MB).
Cada uma é pequena. Mesmo assim, algumas me surpreenderam.
blog Explain, Measured, que não identifica o autor do postEach one is small. A few of them surprised me anyway.
O autor avisa que os números não servem para comparar com posts anteriores dele, feitos em outra máquina. Servem, sim, para entender a direção e a ordem de grandeza de cada decisão de design. É esse recorte que interessa a quem decide índice, isolamento de transação e tamanho de work_mem em produção.
Índices: o que cada tipo realmente faz com a página
A tabela de teste tem 2 milhões de pedidos, com 5% no status open. Um índice parcial WHERE status = 'open' ocupou 0,7 MB contra 14,2 MB do índice completo em customer_id, e precisou de 2 buffers contra 43 para buscar os pedidos abertos de um cliente. A economia só aparece quando a cláusula WHERE da consulta bate exatamente com a condição do índice parcial.
O índice BRIN sobre created_at mostrou o outro lado da moeda: como as linhas foram inseridas em ordem crescente de data, um resumo por faixa de blocos deu conta do recado com 24 kB contra 44,9 MB de um B-tree, com tempo de consulta praticamente igual (0,63 ms contra 0,55 ms) para contar um dia inteiro. Esse resultado depende inteiramente da ordem física dos dados; o autor não testou o mesmo índice sobre uma tabela embaralhada, onde o BRIN não teria nada de útil para resumir.
A cláusula INCLUDE revelou um trade-off que passa batido na documentação:
| Índice | Tamanho | Buffers | Tempo |
|---|---|---|---|
(customer_id) | 14,2 MB | 43 | 0,073 ms |
(customer_id) INCLUDE (amount) | 45,0 MB | 4 | 0,038 ms |
O índice fica mais rápido porque dispensa a ida à tabela para buscar amount, mas quase triplica de tamanho. A causa não é o amount em si: desde o PostgreSQL 13, o B-tree deduplica chaves repetidas, guardando uma lista de ponteiros para a mesma chave. Colunas INCLUDE quebram essa deduplicação, porque o valor incluído varia linha a linha mesmo quando a chave se repete. Com a deduplicação desligada manualmente, um índice em status (só dois valores possíveis) salta de 13,9 MB para 45,0 MB, e customer_id (50 mil valores) de 14,2 MB para 45,0 MB; numa chave primária, onde todo valor já é único, o tamanho nem muda (44,9 MB).
Outro ponto que costuma surpreender: mesmo um Index Only Scan no plano não garante que a tabela não será tocada. Numa tabela recém-carregada com autovacuum desligado, escanear 200.001 linhas gerou 200.001 "heap fetches" e 1.917 buffers em 7,1 ms, porque o visibility map não sabia quais páginas estavam totalmente visíveis. Depois de um VACUUM, os heap fetches caíram a zero, os buffers para 553 e o tempo para 4,4 ms, com o plano mostrando exatamente o mesmo "Index Only Scan" nas duas rodadas. O rótulo do plano, sozinho, não denuncia o problema.
A medição que mais deveria chamar atenção de quem escreve migração é a de chave estrangeira sem índice. O PostgreSQL cria automaticamente um índice para a chave primária, mas não para a coluna de chave estrangeira. Com 10 mil tabelas-pai, 1 milhão de linhas-filhas e ON DELETE CASCADE, apagar 200 pais (arrastando 20 mil filhos) levou 3,6 segundos sem índice em child.parent_id e 0,013 segundo com o índice no lugar: uma diferença de cerca de 277 vezes.
Escrita: COPY, UNLOGGED e o custo do commit síncrono
Carregar 200 mil linhas pelo socket local do psql, uma transação por rodada, deixou claro o custo de inserir linha a linha:
| Método | Tempo |
|---|---|
\copy | 0,039 s |
INSERT, 1.000 linhas por comando | 0,133 s |
INSERT, uma linha por comando | 2,1 s |
O INSERT linha a linha foi cerca de 54 vezes mais lento que o \copy. Para scripts de seed, carga inicial ou migração de dados, isso é argumento direto para trocar laços de INSERT por COPY ou, no mínimo, por lotes de centenas de linhas.
Tabelas UNLOGGED mostraram outro ganho mensurável: inserir 2 milhões de linhas levou 1,78 s e gerou 192,6 MB de WAL numa tabela normal, contra 1,00 s e 0 MB de WAL numa UNLOGGED. O preço é conhecido, mas vale repetir: uma tabela UNLOGGED é esvaziada depois de um crash e não é replicada, então só serve para dados reconstruíveis, como tabelas de staging em ETL ou caches que podem ser recalculados.
Com synchronous_commit = off, 10 mil INSERTs de uma linha, cada um em sua própria transação, renderam 64.478 transações por segundo contra 28.101 com o parâmetro on, mais que o dobro. O autor é honesto sobre o limite do teste: nesse Mac o fsync é barato (wal_sync_method resolvido como open_datasync), então em disco de servidor que realmente grava em disco a diferença pode ser outra. Desligar o synchronous_commit não corrompe dados, mas pode perder os últimos commits num crash, o que descarta a opção para tabelas de transações financeiras e libera para métricas, logs e filas de baixo risco.
A medição de inchaço por UPDATE resume por que tabelas de alta escrita pedem atenção a VACUUM:
| Etapa | Tamanho da tabela |
|---|---|
| antes | 202,5 MB |
depois do UPDATE em todas as linhas | 404,7 MB |
depois do VACUUM | 404,7 MB |
depois de um segundo UPDATE completo | 404,7 MB |
Cada UPDATE escreve uma nova versão da linha; o VACUUM marca o espaço antigo como reutilizável, mas não devolve nada ao sistema operacional, por isso o tamanho não recua. O ganho aparece só na rodada seguinte: o segundo UPDATE completo reaproveitou o espaço liberado em vez de pedir mais disco. Quem precisa encolher a tabela de volta precisa de VACUUM FULL ou de uma ferramenta como o pg_repack, com o custo de bloqueio que isso implica.
Ordenação e paralelismo: onde o work_mem para de ajudar
Um ORDER BY note LIMIT 10 sobre as 2 milhões de linhas, sem paralelismo, levou 119 ms usando um heapsort de topo-N de apenas 26 kB. Trocar o LIMIT 10 por uma ordenação completa das mesmas linhas levou 571 ms: o plano com LIMIT lê todas as linhas também, mas mantém só as dez melhores em memória, em vez de materializar a ordenação inteira.
A mesma ordenação completa, testada em três valores de work_mem, mostra onde o ganho para:
work_mem | Tempo | Método de ordenação |
|---|---|---|
| 4 MB (padrão) | 433 ms | merge externo, 74 MB em disco |
| 64 MB | 297 ms | quicksort, 50 MB em memória |
| 256 MB | 295 ms | quicksort, 50 MB em memória |
Assim que a ordenação cabe inteira em memória, dar mais work_mem não acelera nada: o salto de 64 MB para 256 MB praticamente não mudou o tempo. Como o work_mem se aplica a cada ordenação ou hash de cada consulta, e não ao servidor como um todo, um valor global alto demais multiplica o consumo de RAM em cargas concorrentes sem trazer o mesmo benefício.
Por fim, um filtro LIKE '%ab%' com GROUP BY sobre as 2 milhões de linhas mediu o efeito do max_parallel_workers_per_gather: 151 ms sem paralelismo, 54 ms com os 2 workers padrão (quase 3 vezes mais rápido) e 44 ms com 4 workers. O salto de 0 para 2 workers entregou a maior parte do ganho; de 2 para 4, o retorno já é bem menor, o que é relevante para quem pensa em subir esse parâmetro sem medir antes em sua própria máquina.
O que o teste não cobre
Todas as medições vieram de uma única máquina, sem concorrência de outras conexões, sem réplica e sem disco de servidor com fsync caro de verdade. O autor deixou isso explícito ao comentar o resultado do synchronous_commit e ao não testar o BRIN sobre dados embaralhados. Para quem quiser reproduzir ou estender os testes, o post publica os scripts run.py, brin_size.py e o arquivo results.json, com a saída bruta de cada rodada individual.
Nenhum desses treze números, isoladamente, justifica uma mudança de configuração em produção sem medir o caso real da aplicação. Mas, juntos, formam um roteiro de verificação concreto: índice parcial quando o filtro é seletivo, BRIN quando a tabela já cresce em ordem, cuidado com INCLUDE em colunas de alta cardinalidade, índice manual em toda chave estrangeira usada em ON DELETE CASCADE, \copy ou lotes para carga em massa, UNLOGGED só para dado descartável, e work_mem dimensionado pelo tamanho real da ordenação, não por adivinhação.
Fonte: Planet PostgreSQL
Highlights
- Índice parcial com 0,7 MB vs 14,2 MB do completo; BRIN com 24 kB vs 44,9 MB do B-tree
- Deletar 200 pais com 20 mil filhos: 3,6 segundos sem índice, 0,013 segundo com índice
- COPY carrega 200 mil linhas em 0,039 s; INSERT linha a linha leva 2,1 s no mesmo volume
- Tabelas UNLOGGED poupam 192,6 MB de WAL em 2 milhões de inserts, com risco de perda em crash
- Dois workers paralelos caem de 151 ms para 54 ms em GROUP BY; quatro workers só ganham mais 10 ms
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.
O scram_iterations do PostgreSQL esconde uma armadilha de segurança em migrações
O parâmetro chegou ao PostgreSQL 16 para tornar configurável a contagem de iterações do SCRAM-SHA-256, mas a correção da falha que ele abriu só deslocou o problema, sem fechá-lo.









