Otimização de Queries PostgreSQL
Plano de estudo completo sobre como o PostgreSQL planeja e executa consultas — do modelo de custos e das estatísticas do planner à leitura minuciosa de planos de execução, métodos de acesso, algoritmos de join, tipos de índices (B-tree, GIN, GiST, SP-GiST, BRIN, Hash, Bloom), reescrita de queries, manutenção, configuração e observabilidade. Cada módulo traz teoria, exemplos executáveis em um laboratório próprio e exercícios práticos.
Docker com PostgreSQL 18, extensões de diagnóstico e um dataset de ~18M linhas com distribuições enviesadas e colunas correlacionadas.
Parser, rewriter, planner e executor; modelo de custo (seq/random_page_cost, cpu_*), páginas, MVCC, TOAST e correlação física.
ANALYZE, BUFFERS, SETTINGS, SERIALIZE, GENERIC_PLAN; custo × tempo real, loops, tempo exclusivo por nó e sinais de alerta.
Seq, Index, Index Only, Bitmap (lossy/recheck), TID e scans paralelos — e como a seletividade decide entre eles.
Nested Loop, Hash Join (batches), Merge Join e Memoize; ordem de join, collapse limits e GEQO.
quicksort vs external merge, top-N, Incremental Sort, Hash/GroupAggregate, Gather e Gather Merge.
pg_stats, MCV, histogramas, n_distinct, SET STATISTICS e estatísticas estendidas (dependencies, ndistinct, mcv, expressões).
Estrutura, ordem de colunas, INCLUDE, deduplicação, skip scan (PG18), ORDER BY + LIMIT, opclasses e LIKE.
JSONB (jsonb_ops vs jsonb_path_ops), full-text, pg_trgm, ranges e exclusion constraints, KNN, séries temporais.
Índices parciais e de expressão, desenho por workload, hypopg, CONCURRENTLY, custo de escrita, índices inúteis e FKs sem índice.
Sargabilidade, casts, OR → UNION, NOT IN vs NOT EXISTS, keyset pagination, LATERAL, SELECT * e N+1.
Prepared statements e plan_cache_mode, CTE MATERIALIZED/NOT MATERIALIZED, semi/anti-join, SubPlan e funções.
Autovacuum por tabela, xmin horizon, visibility map, HOT updates, fillfactor, pgstattuple e REINDEX CONCURRENTLY.
shared_buffers, work_mem, random_page_cost, effective_io_concurrency, io_method (PG18), paralelismo e JIT.
Particionamento declarativo, pruning em plano e execução, partitionwise join/aggregate e índices em partições.
pg_stat_statements, auto_explain, pg_stat_activity, wait events, locks e pg_stat_io.
Workflow de tuning em 5 passos, checklist de diagnóstico, 5 casos resolvidos e projeto final.
Laboratório, anatomia da execução e EXPLAIN
Subir o ambiente, gerar o dataset de ~18M linhas e dominar a leitura de planos: custo estimado vs. tempo real, loops, buffers e a regra "de dentro para fora".
Agregação, ordenação, paralelismo e estatísticas
Sorts em disco, HashAggregate, Incremental Sort, Gather; depois o coração do planner: pg_stats, MCV, histogramas e estatísticas estendidas.
B-tree a fundo
Estrutura interna, ordem de colunas em índices compostos, covering (INCLUDE), deduplicação, skip scan (PG18), ORDER BY + LIMIT e operator classes.
GIN, GiST, SP-GiST, BRIN, Hash, Bloom e estratégia de indexação
JSONB, full-text, trigramas, ranges, geoespacial, séries temporais; índices parciais e de expressão; custo de escrita e caça a índices inúteis.
Reescrita de queries, planos genéricos, CTEs e subqueries
Sargabilidade, OR → UNION, NOT IN vs NOT EXISTS, keyset pagination, plan_cache_mode, MATERIALIZED, LATERAL.
Manutenção, configuração e particionamento
VACUUM/autovacuum, bloat, HOT updates, fillfactor; work_mem, random_page_cost, io_method; partition pruning e partitionwise join.
Observabilidade, metodologia e estudos de caso
pg_stat_statements, auto_explain, pg_stat_io, locks e wait events. Fechamento com o workflow de tuning e 5 casos resolvidos de ponta a ponta.
Laboratório de estudos
Um ambiente reprodutível com volume suficiente para que as escolhas do planner façam diferença de verdade.
Otimização só se aprende com dados em escala: em tabelas de mil linhas quase tudo vira Seq Scan e os tempos são irrelevantes. O laboratório abaixo cria ~18 milhões de linhas com distribuições propositalmente enviesadas (status raro, colunas correlacionadas, datas crescentes) para exercitar índices parciais, estatísticas estendidas e BRIN nos módulos seguintes.
docker run -d --name pg-lab \
-e POSTGRES_PASSWORD=lab -p 5432:5432 \
--shm-size=1g \
postgres:18 \
-c shared_preload_libraries='pg_stat_statements,auto_explain' \
-c shared_buffers=1GB -c work_mem=16MB \
-c track_io_timing=on -c pg_stat_statements.track=all
# extensões extras (hypopg = índices hipotéticos) — a imagem oficial já tem o repo PGDG
docker exec -it pg-lab bash -c "apt-get update && apt-get install -y postgresql-18-hypopg"
docker exec -it pg-lab psql -U postgresCREATE DATABASE lab;
\c lab
CREATE EXTENSION pg_stat_statements;
CREATE EXTENSION pg_trgm;
CREATE EXTENSION pgstattuple;
CREATE EXTENSION pg_buffercache;
CREATE EXTENSION hypopg;
CREATE EXTENSION bloom;
-- no psql: mostrar tempo de cada comando
\timing onCREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
email text NOT NULL,
city text NOT NULL,
state char(2) NOT NULL,
created_at timestamptz NOT NULL
);
CREATE TABLE products (
id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE,
name text NOT NULL,
category text NOT NULL,
price numeric(10,2) NOT NULL,
tags text[] NOT NULL
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL,
total numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL,
payload jsonb
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id),
product_id int NOT NULL REFERENCES products(id),
qty int NOT NULL,
unit_price numeric(10,2) NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- 1M clientes: city e state são CORRELACIONADAS (city determina state)
INSERT INTO customers (name, email, city, state, created_at)
SELECT 'Cliente ' || g,
'cliente' || g || '@exemplo.com',
(ARRAY['Vitória','Vila Velha','Serra','Cariacica','São Paulo','Campinas','Rio de Janeiro','Niterói'])[1 + g % 8],
(ARRAY['ES','ES','ES','ES','SP','SP','RJ','RJ'])[1 + g % 8],
now() - (random() * 1500)::int * interval '1 day'
FROM generate_series(1, 1000000) g;
-- 5k produtos
INSERT INTO products (sku, name, category, price, tags)
SELECT 'SKU-' || lpad(g::text, 6, '0'),
'Produto ' || md5(g::text),
(ARRAY['eletrônicos','casa','moda','livros','esporte'])[1 + g % 5],
round((random() * 2000 + 5)::numeric, 2),
ARRAY[(ARRAY['promo','novo','premium','outlet','kit'])[1 + g % 5],
(ARRAY['frete-gratis','importado','nacional'])[1 + g % 3]]
FROM generate_series(1, 5000) g;
-- 5M pedidos: created_at CRESCENTE com o id (ótimo para BRIN)
-- status enviesado: 'pending' ~1%, 'canceled' ~4%
INSERT INTO orders (customer_id, status, total, created_at, payload)
SELECT 1 + (random() * 999999)::bigint,
CASE WHEN r < 0.01 THEN 'pending'
WHEN r < 0.05 THEN 'canceled'
WHEN r < 0.20 THEN 'paid'
WHEN r < 0.40 THEN 'shipped'
ELSE 'delivered' END,
round((random() * 3000 + 10)::numeric, 2),
timestamptz '2024-01-01' + g * interval '15 seconds',
jsonb_build_object(
'channel', (ARRAY['web','app','marketplace','loja'])[1 + g % 4],
'coupon', CASE WHEN g % 10 = 0 THEN 'PROMO10' END,
'device', jsonb_build_object('os', (ARRAY['android','ios','windows','macos'])[1 + g % 4]))
FROM (SELECT g, random() AS r FROM generate_series(1, 5000000) g) s;
-- ~12M itens (1 a 4 por pedido)
INSERT INTO order_items (order_id, product_id, qty, unit_price)
SELECT o.id, p.pid, 1 + (random() * 3)::int, round((random() * 500 + 5)::numeric, 2)
FROM orders o
CROSS JOIN LATERAL (
SELECT DISTINCT 1 + (random() * 4999)::int AS pid
FROM generate_series(1, 1 + (o.id % 4)::int)
) p;
VACUUM (ANALYZE) customers, products, orders, order_items;
SELECT relname, n_live_tup, pg_size_pretty(pg_total_relation_size(relid)) AS total
FROM pg_stat_user_tables ORDER BY n_live_tup DESC;generate_series(1, 1 + o.id % 4) referencia a linha externa, o que obriga o LATERAL a ser reavaliado para cada pedido (sem referência externa o planner executaria a subquery uma única vez e todos os pedidos teriam os mesmos produtos); o DISTINCT evita violar a PK (order_id, product_id). Repare que as FKs em order_items.product_id e orders.customer_id não têm índice — isso é intencional e será um dos exercícios.- Sempre comparar planos com
EXPLAIN (ANALYZE, BUFFERS)— no PG18BUFFERSjá vem ligado por padrão comANALYZE. - Rodar a query 2–3 vezes antes de medir: a primeira execução paga cache frio (
shared read), as seguintes medem CPU +shared hit. - Para experimentar sem sujar o banco:
BEGIN; CREATE INDEX ...; EXPLAIN ...; ROLLBACK;— DDL é transacional no PostgreSQL. - Parâmetros
enable_*(ex.:SET enable_seqscan = off) servem para estudar alternativas, nunca como correção em produção.
Anatomia da execução de uma query
Do texto SQL ao resultado: parser, rewriter, planner/optimizer e executor — e o modelo físico de armazenamento que o planner tenta prever.
O otimizador do PostgreSQL é baseado em custo. Para cada relação ele gera caminhos de acesso possíveis (seq scan, cada índice aplicável, bitmap), depois combina relações com os três algoritmos de join em diferentes ordens, usando programação dinâmica (ou o algoritmo genético GEQO quando há ≥ geqo_threshold = 12 itens no FROM). Cada caminho recebe dois números: startup cost (custo até a primeira linha) e total cost (até a última). Com LIMIT ou cursores, o startup cost pesa mais — é assim que um Index Scan "caro" ganha de um Sort "barato".
Custo é uma unidade arbitrária, ancorada em "ler uma página sequencialmente = 1.0". A fórmula simplificada de um Seq Scan é:
cost(Seq Scan) = relpages × seq_page_cost
+ reltuples × cpu_tuple_cost
+ reltuples × cpu_operator_cost × (nº de operadores no filtro)| Parâmetro | Padrão | Significado |
|---|---|---|
| seq_page_cost | 1.0 | Ler uma página de 8 kB sequencialmente. |
| random_page_cost | 4.0 | Ler uma página em posição aleatória. Em SSD/NVMe costuma-se usar 1.1–1.5. |
| cpu_tuple_cost | 0.01 | Processar uma linha. |
| cpu_index_tuple_cost | 0.005 | Processar uma entrada de índice. |
| cpu_operator_cost | 0.0025 | Avaliar um operador/função (multiplicado pelo COST da função). |
| parallel_setup_cost | 1000 | Custo de iniciar workers paralelos. |
| effective_cache_size | 4GB | Estimativa de cache total (shared_buffers + page cache do SO). Não aloca memória; torna index scans mais atraentes. |
SELECT relpages, reltuples FROM pg_class WHERE relname = 'customers';
-- relpages | reltuples
-- 9346 | 1e+06
EXPLAIN SELECT * FROM customers WHERE state = 'ES';
-- Seq Scan on customers (cost=0.00..21846.00 rows=500120 width=62)
-- Filter: (state = 'ES'::bpchar)
-- 9346 × 1.0 + 1.000.000 × 0.01 + 1.000.000 × 0.0025 (1 operador: =)
-- = 9346 + 10000 + 2500 = 21846 ✔
-- rows=500120: fração de 'ES' lida da lista de MCV em pg_stats (≈ 4/8)- Heap e páginas de 8 kB: cada tabela é um conjunto de arquivos de páginas; cada linha (tuple) é endereçada por um
ctid= (página, offset). - MVCC: um
UPDATEcria uma nova versão da linha; a antiga vira dead tuple até o VACUUM. Scans precisam checar visibilidade (xmin/xmax) — por isso Index Only Scan depende do visibility map. - TOAST: valores > ~2 kB são comprimidos e/ou movidos para uma tabela auxiliar.
SELECT *em colunas grandes (jsonb, text) paga de-TOAST — e o PG17+ mostra isso comEXPLAIN (SERIALIZE). - shared_buffers + page cache: uma página "lida" pode vir do cache do PostgreSQL (
shared hit), do cache do SO (aparece comoread, mas rápido) ou do disco. - Correlação física: se a ordem física das linhas acompanha a ordem de um índice (
pg_stats.correlation≈ ±1), index scans por faixa leem poucas páginas; se não, cada linha pode custar uma página aleatória.
SELECT ctid, xmin, xmax, id, status FROM orders LIMIT 3;
SELECT attname, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname IN ('id','created_at','customer_id');
-- id / created_at ≈ 1.0 (inseridos em ordem)
-- customer_id ≈ 0.0 (aleatório)- Calcule manualmente o custo total do Seq Scan de
orderscom o filtrototal > 2000e compare com oEXPLAIN. - Altere
SET random_page_cost = 1.1e observe em qual seletividade um filtro emcustomers.created_at(com índice) passa de Seq Scan para Index/Bitmap Scan. - Faça
UPDATE orders SET total = total WHERE id <= 1000e usepgstattuple('orders')para ver as dead tuples geradas.
EXPLAIN em profundidade
A ferramenta central de todo o curso: opções, anatomia de cada linha, loops, buffers e como achar o nó culpado.
| Opção | O que acrescenta |
|---|---|
| ANALYZE | Executa a query de verdade e mostra tempos e linhas reais. Cuidado com DML: envolva em BEGIN … ROLLBACK. |
| BUFFERS | Páginas shared hit/read/dirtied/written, local e temp. Padrão com ANALYZE a partir do PG18. |
| VERBOSE | Colunas de saída de cada nó, nomes qualificados, workers paralelos individualmente, query identifier. |
| SETTINGS | Lista GUCs de planejamento diferentes do padrão — fundamental ao anexar planos em tickets. |
| WAL | Registros/bytes de WAL gerados (útil em UPDATE/DELETE/INSERT). |
| SERIALIZE (PG17+) | Custo de converter o resultado para envio ao cliente, incluindo de-TOAST. |
| MEMORY (PG17+) | Memória usada pelo planner. |
| GENERIC_PLAN (PG16+) | Planeja uma query com $1, $2 sem valores — como um prepared statement genérico. |
| FORMAT | TEXT, JSON, YAML, XML. JSON para visualizadores (explain.dalibo.com, explain.depesz.com). |
| TIMING OFF | Mantém contagem de linhas sem o overhead de cronometrar cada nó (útil em planos com milhões de loops). |
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.name, sum(o.total)
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE c.city = 'Vitória' AND o.created_at >= '2025-06-01'
GROUP BY c.name;
HashAggregate (cost=178912.40..181412.40 rows=125000 width=45) (actual time=1480.221..1532.004 rows=108113 loops=1)
Group Key: c.name
Batches: 5 Memory Usage: 16433kB Disk Usage: 12872kB
Buffers: shared hit=2915 read=58690, temp read=1604 written=2958
-> Hash Join (cost=27466.02..177648.60 rows=252760 width=21) (actual time=231.9..1391.8 rows=252629 loops=1)
Hash Cond: (o.customer_id = c.id)
-> Seq Scan on orders o (cost=0.00..133520.00 rows=2019845 width=14) (actual time=0.02..905.1 rows=2022080 loops=1)
Filter: (created_at >= '2025-06-01 00:00:00-03'::timestamptz)
Rows Removed by Filter: 2977920
-> Hash (cost=25846.00..25846.00 rows=125000 width=23) (actual time=231.2..231.2 rows=125000 loops=1)
Buckets: 131072 Batches: 1 Memory Usage: 7856kB
-> Seq Scan on customers c (cost=0.00..25846.00 rows=125000 width=23) (actual time=0.01..201.3 rows=125000 loops=1)
Filter: (city = 'Vitória'::text)
Rows Removed by Filter: 875000
Planning Time: 0.412 ms
Execution Time: 1539.550 ms| Campo | Leitura |
|---|---|
| cost=A..B | Custo estimado de startup (A) e total (B), acumulado dos filhos. |
| rows (estimado) | Linhas que o planner espera que o nó emita por loop. |
| width | Tamanho médio estimado da linha em bytes. |
| actual time=A..B | Tempo real em ms até a primeira linha (A) e até a última (B), por loop, incluindo os filhos. |
| rows (real) | Média de linhas por loop. Total = rows × loops. |
| loops | Quantas vezes o nó foi executado (lado interno de Nested Loop, workers paralelos). |
| Rows Removed by Filter | Linhas lidas e descartadas — alto valor = candidato a índice ou a filtro melhor. |
| Buffers | hit = cache do PG, read = pediu ao SO, dirtied/written = escrita; temp = spill em disco. |
- Leia de dentro para fora, de baixo para cima: os nós mais indentados executam primeiro (na verdade, o executor é pull, mas os dados fluem das folhas para a raiz).
- Calcule o tempo exclusivo de cada nó:
actual_total × loops − Σ(tempo dos filhos). No exemplo: HashAggregate ≈ 1532 − 1392 = 140 ms próprios; Hash Join ≈ 1392 − 905 − 231 = 256 ms; Seq Scan de orders ≈ 905 ms — é ele o alvo. - Compare estimado × real: diferenças de 10× ou mais indicam problema de estatística — e estimativas erradas geram escolhas erradas acima daquele nó. No exemplo as estimativas estão boas (±1%); o problema é físico: ler 5M linhas para aproveitar 40% delas, e o agregado transbordando para disco (
Batches: 5). - Procure sinais de alerta:
Disk:/external merge,Batches > 1,Rows Removed by Filtergigante,Heap Fetchesalto,loopsna casa de milhares com tempo por loop não desprezível,temp read/written. - Olhe os buffers antes do tempo: o tempo varia com cache; o número de páginas tocadas é estável e é o que você realmente reduz com índices.
actual time e rows do nó interno são médias por loop. Um Index Scan com actual time=0.010..0.012 rows=1 loops=500000 consome na verdade ~6 s. Sempre multiplique por loops.-- PG16+: planejar sem valores (como o prepared statement genérico vê)
EXPLAIN (GENERIC_PLAN)
SELECT * FROM orders WHERE customer_id = $1 AND created_at > $2;
-- DML com ANALYZE sem efeito colateral
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, WAL)
UPDATE orders SET status = 'canceled' WHERE status = 'pending' AND created_at < now() - interval '30 days';
ROLLBACK;
-- JSON para colar em https://explain.dalibo.com
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT JSON) SELECT ...;- Rode a query de exemplo, calcule o tempo exclusivo de cada nó e identifique o mais caro.
- Rode com
SET work_mem = '64MB'e observe o que muda no HashAggregate (Batches, Disk Usage). - Cole o plano JSON no explain.dalibo.com e no explain.depesz.com; compare as visualizações.
- Execute a mesma query com cache frio (reinicie o container) e quente, comparando
shared readvshit.
Métodos de acesso (scans)
Como o executor lê uma tabela: Seq Scan, Index Scan, Index Only Scan, Bitmap Scan, TID Scan e as variantes paralelas.
| Nó | Como funciona | Vence quando |
|---|---|---|
| Seq Scan | Lê todas as páginas em ordem física e aplica o filtro linha a linha. | Seletividade alta (≳ 5–10% das linhas), tabelas pequenas, sem índice útil. |
| Index Scan | Percorre o índice e, para cada entrada, busca a linha no heap (I/O potencialmente aleatório). | Poucas linhas, ou necessidade de ordem (ORDER BY … LIMIT), ou alta correlação física. |
| Index Only Scan | Responde só com o índice; vai ao heap apenas se a página não estiver marcada como all-visible no visibility map. | Todas as colunas usadas estão no índice e a tabela está bem vacuumizada. |
| Bitmap Index Scan + Bitmap Heap Scan | Coleta TIDs do(s) índice(s) num bitmap em memória, ordena por página e lê o heap em ordem física. Pode combinar índices com BitmapAnd/BitmapOr. | Seletividade intermediária, condições em várias colunas com índices separados, OR. |
| TID Scan / TID Range Scan | Acesso direto por ctid. | Operações de manutenção, deleção em lotes por faixa de páginas. |
| Parallel Seq / Index / Bitmap Heap Scan | Divide blocos entre workers sob um nó Gather. | Tabelas grandes em queries analíticas. |
CREATE INDEX idx_orders_created_at ON orders (created_at);
ANALYZE orders;
-- ~0,01% das linhas → Index Scan
EXPLAIN (ANALYZE) SELECT * FROM orders
WHERE created_at BETWEEN '2025-03-01' AND '2025-03-01 02:00';
-- ~3% → Bitmap Heap Scan (ou Index Scan, pois a correlação é ~1)
EXPLAIN (ANALYZE) SELECT * FROM orders
WHERE created_at BETWEEN '2025-03-01' AND '2025-03-27';
-- ~40% → Seq Scan (Parallel)
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE created_at >= '2025-06-01';O bitmap guarda um bit por linha enquanto couber em work_mem. Se estourar, ele degrada para um bit por página (lossy), e o Bitmap Heap Scan precisa reavaliar a condição em todas as linhas daquelas páginas:
Bitmap Heap Scan on orders (actual time=45.1..980.4 rows=1497600 loops=1)
Recheck Cond: (created_at >= '2025-03-01'::timestamptz AND created_at < '2025-06-01'::timestamptz)
Rows Removed by Index Recheck: 211340
Heap Blocks: exact=10212 lossy=11480
-> Bitmap Index Scan on idx_orders_created_at (actual time=41.9..41.9 rows=1497600 loops=1)Heap Blocks: lossy + Rows Removed by Index Recheck = bitmap não coube em memória. Aumentar work_mem na sessão costuma resolver.
CREATE INDEX idx_orders_cust_total ON orders (customer_id) INCLUDE (total);
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, sum(total) FROM orders WHERE customer_id BETWEEN 1000 AND 2000 GROUP BY 1;
-- Index Only Scan using idx_orders_cust_total ...
-- Heap Fetches: 0 ← visibility map em dia
UPDATE orders SET total = total + 0 WHERE customer_id BETWEEN 1000 AND 2000;
-- rode o EXPLAIN de novo: Heap Fetches ≈ nº de linhas → IOS virou Index Scan disfarçado
VACUUM orders; -- marca páginas como all-visible novamenteautovacuum_vacuum_insert_threshold/_scale_factor, que dispara vacuum também por inserções.- Encontre, variando a faixa de datas, os pontos de virada Index → Bitmap → Seq Scan. Repita com
random_page_cost = 1.1. - Crie índices separados em
statusecreated_ate escreva um filtro que gereBitmapAnd. Depois compare com um índice composto. - Provoque um bitmap lossy com
SET work_mem = '1MB'e elimine-o aumentando o valor.
Algoritmos de join
Nested Loop, Hash Join, Merge Join e Memoize — custo, memória, ordem de join e os limites da busca exaustiva.
| Algoritmo | Funcionamento | Custo aproximado | Ideal para |
|---|---|---|---|
| Nested Loop | Para cada linha externa, busca correspondências no lado interno (idealmente via índice). | O(N × custo da busca interna) | Lado externo pequeno + índice no interno; único que suporta condições não-equi arbitrárias. |
| Hash Join | Constrói hash table com o lado menor (build) e varre o maior (probe). | O(N + M), memória = work_mem × hash_mem_multiplier | Equi-joins com volumes médios/grandes sem ordem útil. |
| Merge Join | Percorre dois fluxos ordenados pela chave em paralelo. | O(N + M) + custo de ordenar | Entradas já ordenadas (índice) ou volumes enormes; suporta FULL JOIN. |
| Memoize (PG14+) | Cache LRU entre um Nested Loop e seu lado interno, indexado pelo parâmetro. | Evita repetir buscas com a mesma chave | Lado externo com muitos valores repetidos. |
-- FK sem índice: order_items.product_id
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.sku, count(*)
FROM products p JOIN order_items oi ON oi.product_id = p.id
WHERE p.category = 'livros' AND p.price > 1900
GROUP BY p.sku;
-- Esperado: Hash Join (build = products filtrados, probe = Seq Scan de 12M itens)
CREATE INDEX idx_oi_product ON order_items (product_id);
ANALYZE order_items;
-- Rode de novo: agora Nested Loop (products filtrados → Index Scan em order_items)
-- Forçando alternativas para comparar (somente estudo!)
SET enable_nestloop = off; EXPLAIN (ANALYZE) ...; RESET enable_nestloop;
SET enable_hashjoin = off; EXPLAIN (ANALYZE) ...; RESET enable_hashjoin;-> Hash (actual time=2210.4..2210.4 rows=5000000 loops=1)
Buckets: 262144 Batches: 32 Memory Usage: 11265kB
Buffers: temp written=21456Batches > 1 significa que a hash table não coube em memória e foi particionada em disco. A memória disponível é work_mem × hash_mem_multiplier (padrão 2.0 desde o PG15). Soluções: aumentar work_mem para a sessão/role, reduzir as colunas projetadas no lado build (o width importa!) ou filtrar antes do join.
- Até
join_collapse_limit(8) relações, o planner reordenaJOINs explícitos livremente; acima disso, respeita a ordem escrita.from_collapse_limit(8) faz o mesmo para subqueries achatadas. - A partir de
geqo_threshold(12) itens, entra o otimizador genético: planos podem variar entre execuções. LEFT JOINrestringe reordenações; o planner consegue remover LEFT JOINs inúteis (join removal) quando a tabela à direita tem chave única e nenhuma coluna dela é usada.- Em ORMs que geram 15+ joins, considere views materializadas, quebrar a query ou ajustar esses limites por sessão.
Nested Loop (actual time=0.05..310.2 rows=250000 loops=1)
-> Seq Scan on order_items oi (actual rows=250000 loops=1)
-> Memoize (actual time=0.001..0.001 rows=1 loops=250000)
Cache Key: oi.product_id
Hits: 245000 Misses: 5000 Evictions: 0 Overflows: 0 Memory Usage: 590kB
-> Index Scan using products_pkey on products p (actual rows=1 loops=5000)rows estimado × real do lado externo — a correção normalmente está nas estatísticas (módulo 06), não no join.- Meça o join
orders ⋈ customerspara um único estado com os três algoritmos forçados. Anote tempo e buffers. - Provoque um Hash Join com
Batches: 16+comwork_mem = '4MB'e resolva de duas formas diferentes. - Escreva uma query com 10 tabelas (use subqueries/CTEs sobre as 4 existentes) e observe o efeito de
join_collapse_limit = 1.
Agregação, ordenação e paralelismo
Sort em memória vs. disco, top-N heapsort, Incremental Sort, HashAggregate vs GroupAggregate, Gather e Gather Merge.
| Sort Method | Significado |
|---|---|
| quicksort | Tudo coube em work_mem. Ideal. |
| top-N heapsort | ORDER BY … LIMIT N: mantém só N linhas em um heap. Muito barato. |
| external merge | Estourou work_mem; ordenou em runs no disco (Disk: …kB, temp written). |
| Incremental Sort (PG13+) | Entrada já ordenada por um prefixo das chaves; ordena apenas os grupos. Excelente com índices parciais de ordenação. |
SET work_mem = '4MB';
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders ORDER BY total DESC;
-- Sort Method: external merge Disk: 412552kB
EXPLAIN (ANALYZE) SELECT * FROM orders ORDER BY total DESC LIMIT 20;
-- Sort Method: top-N heapsort Memory: 30kB
-- Incremental Sort: índice em created_at, ordem por (created_at, total)
EXPLAIN (ANALYZE) SELECT * FROM orders ORDER BY created_at, total LIMIT 100;
-- Incremental Sort
-- Sort Key: created_at, total
-- Presorted Key: created_at
-- -> Index Scan using idx_orders_created_at on orders- HashAggregate: hash table por grupo; sem necessidade de ordem. Desde o PG13 pode transbordar para disco (
Batches,Disk Usage) em vez de estourar memória. - GroupAggregate: exige entrada ordenada pela chave (via índice ou Sort); streaming e com startup baixo — combina bem com
LIMIT. - MixedAggregate: usado em
GROUPING SETS/ROLLUP/CUBE. - Partial/Finalize Aggregate: agregação em duas fases em planos paralelos.
Finalize GroupAggregate (actual time=610.2..612.9 rows=5 loops=1)
Group Key: status
-> Gather Merge (actual time=610.1..612.8 rows=15 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Sort (actual time=598.3..598.3 rows=5 loops=3)
-> Partial HashAggregate (actual time=598.2..598.2 rows=5 loops=3)
Group Key: status
-> Parallel Seq Scan on orders (actual time=0.01..201.5 rows=1666667 loops=3)loops=3= líder + 2 workers;rowsé média por processo.- Parâmetros:
max_parallel_workers_per_gather,max_parallel_workers,max_worker_processes,min_parallel_table_scan_size(8MB),parallel_setup_cost,parallel_tuple_cost. Workers Launched < Plannedindica pool esgotado — comum em horários de pico.- Funções marcadas como
PARALLEL UNSAFE(padrão para funções de usuário!) impedem planos paralelos. DeclarePARALLEL SAFEquando for verdade. - Paralelismo acelera queries analíticas, mas em OLTP com alta concorrência pode piorar o throughput total.
- Encontre o menor
work_memque evita oexternal mergeao ordenarordersportotal. Compare o tempo com o sort em disco. - Compare
count(*) … GROUP BY statuscommax_parallel_workers_per_gather= 0, 2 e 4. - Crie uma função SQL simples sem
PARALLEL SAFE, use-a noWHEREe veja o paralelismo desaparecer.
Estatísticas e estimativas de cardinalidade
A causa raiz da maioria dos planos ruins: o que o ANALYZE coleta, como o planner calcula seletividade e como corrigir estimativas.
| Coluna | Uso pelo planner |
|---|---|
| null_frac | Seletividade de IS NULL / IS NOT NULL. |
| n_distinct | Nº de valores distintos (negativo = fração do total de linhas). Base para GROUP BY e joins. |
| most_common_vals / _freqs | Lista de MCV com frequências — igualdade com valores frequentes. |
| histogram_bounds | Buckets de mesma frequência para os demais valores — filtros de faixa. |
| correlation | Correlação entre ordem física e lógica — custo de index scans por faixa. |
| avg_width | Largura média — memória de hash/sort. |
| most_common_elems | Para arrays/tsvector — operadores @>, &&. |
SELECT attname, null_frac, n_distinct, most_common_vals, most_common_freqs, correlation
FROM pg_stats WHERE tablename = 'orders' AND attname = 'status';
-- most_common_vals = {delivered,shipped,paid,canceled,pending}
-- most_common_freqs = {0.60,0.20,0.15,0.04,0.01}
-- Amostra = 300 × default_statistics_target (100) = 30.000 linhas
-- Aumentar para uma coluna com distribuição complexa:
ALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 1000;
ANALYZE orders (customer_id);Por padrão o planner assume que condições em colunas diferentes são independentes e multiplica as seletividades. Em nosso dataset, city = 'Vitória' implica state = 'ES':
EXPLAIN ANALYZE SELECT * FROM customers WHERE city = 'Vitória' AND state = 'ES';
-- Seq Scan on customers (rows=62500) (actual rows=125000)
-- estimativa: 1/8 × 1/2 = 6,25% real: 12,5% → subestimou 2×
CREATE STATISTICS st_customers_city_state (dependencies, ndistinct, mcv)
ON city, state FROM customers;
ANALYZE customers;
EXPLAIN ANALYZE SELECT * FROM customers WHERE city = 'Vitória' AND state = 'ES';
-- rows=125000 (actual rows=125000) ✔
-- GROUP BY em colunas correlacionadas também melhora (ndistinct):
EXPLAIN SELECT city, state, count(*) FROM customers GROUP BY city, state;
-- sem stats: rows=16 (8 × 2) · com stats: rows=8
SELECT * FROM pg_stats_ext WHERE statistics_name = 'st_customers_city_state';dependencies = dependências funcionais (ajuda igualdades combinadas); ndistinct = distintos de combinações (ajuda GROUP BY/DISTINCT); mcv = lista MCV multicoluna (a mais precisa, cobre também desigualdades e OR). Desde o PG14 também é possível criar estatísticas sobre expressões: CREATE STATISTICS s ON (lower(email)) FROM customers.- Estatística desatualizada: tabela carregada em lote e consultada antes do autoanalyze. Rode
ANALYZEao final de cargas/ETL. - Funções e expressões no filtro:
WHERE date_trunc('day', created_at) = …usa seletividade padrão (0,5% para igualdade). Índice de expressão ou estatística de expressão corrigem. - Valores fora do histograma: datas mais recentes que o último ANALYZE. O planner consulta o índice para descobrir o máximo real (
get_actual_variable_range), mas só se houver índice. - Funções que retornam conjuntos / JSON:
jsonb_array_elementsestima 100 linhas (ROWSda função). UseROWSem funções próprias ouSUPPORTfunctions. - Joins com distribuições enviesadas: join de chave com MCVs muito diferentes em cada lado.
- CTEs materializadas e tabelas temporárias: tabelas temporárias não são analisadas pelo autovacuum — rode
ANALYZE tmpmanualmente.
- Liste as colunas das 4 tabelas com
n_distinct, MCVs e correlação. Explique cada valor à luz do script de carga. - Carregue 1M novos pedidos com datas futuras sem rodar ANALYZE e observe a estimativa para
created_at > now()com e sem o índice emcreated_at. - Compare a estimativa de
WHERE payload->>'channel' = 'app'antes e depois deCREATE STATISTICS … ON ((payload->>'channel')) FROM orders.
B-tree a fundo
O índice padrão e o mais importante: estrutura, índices compostos, covering, deduplicação, skip scan, ordenação e operator classes.
Uma B-tree do PostgreSQL (variante Lehman-Yao) tem uma metapage, páginas internas e páginas folha duplamente encadeadas, com entradas ordenadas pela chave. Cada entrada folha aponta para um ctid no heap. Buscas por igualdade e faixa são O(log N), e a varredura das folhas permite servir ORDER BY sem Sort — em ambas as direções (Index Scan Backward). Operadores suportados: < <= = >= >, BETWEEN, IN/= ANY, IS NULL e LIKE 'prefixo%' (com a opclass certa).
- Igualdade primeiro, faixa por último. Em
WHERE customer_id = ? AND created_at > ?, o índice(customer_id, created_at)desce direto no trecho exato;(created_at, customer_id)varre toda a faixa de datas filtrando cliente. - Colunas após a primeira condição de faixa não delimitam a busca — viram "Index Cond" de filtro dentro do índice.
- Pense no ORDER BY:
(customer_id, created_at DESC)resolve "últimos pedidos do cliente" com um Index Scan +LIMIT, sem Sort. - Um índice
(a, b)atende consultas só ema; um índice só emasobreposto a(a, b)geralmente é redundante.
-- Consulta típica de tela: últimos 20 pedidos de um cliente
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, total, created_at FROM orders
WHERE customer_id = 4242 ORDER BY created_at DESC LIMIT 20;
-- sem índice: Parallel Seq Scan + top-N heapsort (≈ 70k buffers)
CREATE INDEX idx_orders_cust_created ON orders (customer_id, created_at DESC);
-- agora: Limit → Index Scan using idx_orders_cust_created (≈ 8 buffers)
-- Covering: evita ir ao heap (Index Only Scan)
CREATE INDEX idx_orders_cust_created_cov ON orders (customer_id, created_at DESC)
INCLUDE (status, total);Colunas em INCLUDE ficam apenas nas folhas: não participam da ordenação nem da unicidade, deixam as páginas internas menores e permitem, por exemplo, UNIQUE (email) INCLUDE (name). Use-as para habilitar Index Only Scan sem alterar a semântica do índice. Contra: índice maior = mais I/O de escrita e menos HOT updates (módulo 12).
-- Em bancos com collation diferente de "C", LIKE 'abc%' NÃO usa índice B-tree comum
CREATE INDEX idx_customers_email_pattern ON customers (email text_pattern_ops);
EXPLAIN SELECT * FROM customers WHERE email LIKE 'cliente4242%';
-- Alternativa: coluna/índice com COLLATE "C"
CREATE INDEX idx_customers_name_c ON customers (name COLLATE "C");
-- Ordenação de NULLs precisa casar com o ORDER BY para evitar Sort
CREATE INDEX idx_orders_total_desc ON orders (total DESC NULLS LAST);SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS size, idx_scan
FROM pg_stat_user_indexes WHERE relname = 'orders' ORDER BY pg_relation_size(indexrelid) DESC;
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT * FROM bt_metap('idx_orders_created_at'); -- níveis da árvore
SELECT * FROM pgstatindex('idx_orders_created_at'); -- densidade, fragmentação (pgstattuple)- Para
WHERE customer_id = ? AND status = 'paid' AND created_at > ?, teste 3 ordens de colunas e compare buffers. - Compare o tamanho de um índice em
statuscomdeduplicate_items = offeon. - Construa um cenário em que o skip scan do PG18 é escolhido e outro em que não é (coluna líder de alta cardinalidade).
- Transforme "últimos pedidos do cliente" em Index Only Scan com
Heap Fetches: 0.
- Deduplicação: valores repetidos são armazenados como uma posting list; índices em colunas de baixa cardinalidade (
status) ficam várias vezes menores. Ativo por padrão (deduplicate_items). - Skip scan (PG18): um índice
(status, created_at)passa a servirWHERE created_at > …sem condição emstatus, "pulando" entre os poucos valores distintos da primeira coluna. Vale quando a coluna líder tem baixa cardinalidade.
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
EXPLAIN (ANALYZE) SELECT count(*) FROM orders WHERE created_at BETWEEN '2025-01-01' AND '2025-01-02';
-- Index Only Scan using idx_orders_status_created
-- Index Cond: ((created_at >= ...) AND (created_at <= ...))
-- Index Searches: 5 ← ≈ uma descida por valor distinto de status (PG18 mostra este contador)Outros tipos de índice
Hash, GIN, GiST, SP-GiST, BRIN e Bloom — o que cada um indexa, operadores suportados e onde brilham.
| Tipo | Estrutura | Operadores típicos | Casos de uso |
|---|---|---|---|
| B-tree | Árvore balanceada ordenada | < = >, BETWEEN, IN, LIKE prefixo | Padrão para quase tudo. |
| Hash | Hash de 32 bits por valor | = apenas | Igualdade em chaves longas (URLs, tokens); menor que B-tree. WAL-logged desde PG10. |
| GIN | Índice invertido: elemento → posting list | @> ? ?| ?& @@ &&, % (trgm) | JSONB, arrays, full-text, LIKE '%x%' com pg_trgm. |
| GiST | Árvore balanceada genérica (R-tree-like), lossy | && @> <@ <-> (KNN) | Geometria/PostGIS, ranges, exclusion constraints, busca por proximidade. |
| SP-GiST | Árvore particionada não-balanceada (quad-tree, k-d, radix) | pontos, inet, prefixo de texto | Dados com particionamento natural: IPs, pontos, telefones. |
| BRIN | Resumo (min/max) por faixa de blocos | < = > em dados correlacionados | Tabelas enormes append-only ordenadas por tempo/ID. Minúsculo. |
| Bloom (ext) | Filtro de Bloom por linha | = em qualquer combinação de colunas | Tabelas com muitas colunas filtradas em combinações arbitrárias. |
-- jsonb_ops (padrão): suporta @>, ?, ?|, ?&, @?, @@
CREATE INDEX idx_orders_payload ON orders USING gin (payload);
-- jsonb_path_ops: só @>, @?, @@ — porém menor e mais rápido
CREATE INDEX idx_orders_payload_path ON orders USING gin (payload jsonb_path_ops);
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE payload @> '{"coupon": "PROMO10"}';
-- Bitmap Heap Scan → Bitmap Index Scan on idx_orders_payload_path
-- ATENÇÃO: ->> com = NÃO usa índice GIN; use @> ou um índice de expressão B-tree:
CREATE INDEX idx_orders_channel ON orders ((payload->>'channel'));
SELECT count(*) FROM orders WHERE payload->>'channel' = 'app';-- LIKE/ILIKE com curinga no início: pg_trgm
CREATE INDEX idx_customers_name_trgm ON customers USING gin (name gin_trgm_ops);
EXPLAIN ANALYZE SELECT * FROM customers WHERE name ILIKE '%nte 4242%';
-- Full-text search com coluna gerada
ALTER TABLE products ADD COLUMN fts tsvector
GENERATED ALWAYS AS (to_tsvector('portuguese', name || ' ' || category)) STORED;
CREATE INDEX idx_products_fts ON products USING gin (fts);
SELECT sku FROM products WHERE fts @@ websearch_to_tsquery('portuguese', 'livros');
-- Arrays
CREATE INDEX idx_products_tags ON products USING gin (tags);
SELECT count(*) FROM products WHERE tags @> ARRAY['promo','importado'];fastupdate = on (padrão), inserções vão para uma pending list que é mesclada no vacuum ou quando atinge gin_pending_list_limit — isso gera picos de latência pontuais. Em tabelas com escrita intensa, avalie fastupdate = off ou ajustar o limite.CREATE INDEX idx_orders_created_brin ON orders USING brin (created_at) WITH (pages_per_range = 32);
SELECT pg_size_pretty(pg_relation_size('idx_orders_created_at')) AS btree, -- ~107 MB
pg_size_pretty(pg_relation_size('idx_orders_created_brin')) AS brin; -- ~48 kB
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE created_at BETWEEN '2025-03-01' AND '2025-03-02';
-- Bitmap Heap Scan ... Rows Removed by Index Recheck: ~N Heap Blocks: lossy=...
-- BRIN é sempre lossy: devolve faixas de blocos que "podem" conter os valores.
-- PG14+: minmax-multi tolera outliers (ex.: registros atrasados)
CREATE INDEX ... USING brin (created_at timestamptz_minmax_multi_ops);BRIN só funciona bem com correlation próxima de ±1. Um único UPDATE massivo ou CLUSTER por outra coluna destrói essa propriedade.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE reservas (
sala int,
periodo tstzrange,
EXCLUDE USING gist (sala WITH =, periodo WITH &&) -- impede sobreposição
);
SELECT * FROM reservas WHERE periodo && tstzrange(now(), now() + interval '1 hour');
-- KNN: os 5 produtos com preço mais próximo de 100 (ordenação pelo índice)
-- (btree_gist oferece <-> para int/float/date/timestamp; numeric não tem, por isso o cast)
CREATE INDEX idx_products_price_gist ON products USING gist ((price::float8));
SELECT sku, price FROM products ORDER BY price::float8 <-> 100 LIMIT 5;CREATE INDEX idx_customers_email_hash ON customers USING hash (email);
CREATE TABLE acessos (ip inet, ts timestamptz);
CREATE INDEX ON acessos USING spgist (ip inet_ops);
SELECT * FROM acessos WHERE ip << '10.0.0.0/8';
-- Bloom: filtros em qualquer combinação de colunas int/text
CREATE INDEX idx_oi_bloom ON order_items USING bloom (order_id, product_id, qty)
WITH (length = 80, col1 = 2, col2 = 2, col3 = 4);- Compare tamanho e tempo de criação de
jsonb_opsvsjsonb_path_ops, e o tempo da consulta@>em cada um. - Crie B-tree e BRIN em
orders.created_at; compare buffers para 1 hora, 1 dia e 1 mês. Ajustepages_per_range. - Destrua a correlação física (
CLUSTER orders USING idx_orders_cust_created) e refaça o teste BRIN. - Implemente busca "contém" em
customers.namecom pg_trgm e compare com o Seq Scan.
Estratégia de indexação
Índices parciais e de expressão, desenho orientado a workload, custo de escrita, índices hipotéticos e caça a índices inúteis.
Indexam só as linhas que satisfazem um predicado. Perfeitos para filas de trabalho e status raros: o índice fica minúsculo e quente em cache. O WHERE da query precisa implicar o predicado do índice (o planner prova isso com lógica simples — use o mesmo literal, não parâmetros).
-- 1% dos pedidos estão 'pending' e a aplicação consulta a fila o tempo todo
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'pending';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 50;
-- Limit → Index Scan using idx_orders_pending (buffers: ~55)
SELECT pg_size_pretty(pg_relation_size('idx_orders_pending')); -- ~1 MB vs ~107 MB
-- Unicidade condicional: 1 cupom ativo por cliente
CREATE UNIQUE INDEX ux_cupom_ativo ON cupons (customer_id) WHERE ativo;-- Busca case-insensitive
CREATE INDEX idx_customers_lower_email ON customers (lower(email));
SELECT * FROM customers WHERE lower(email) = lower('Cliente42@Exemplo.com');
-- Agrupamento por dia (a expressão da query deve ser IDÊNTICA à do índice;
-- date_trunc em timestamptz não é IMMUTABLE → converta com fuso fixo)
CREATE INDEX idx_orders_day ON orders (((created_at AT TIME ZONE 'America/Sao_Paulo')::date));
SELECT count(*) FROM orders
WHERE (created_at AT TIME ZONE 'America/Sao_Paulo')::date = '2025-03-10';- Parta do
pg_stat_statements(módulo 15): top queries portotal_exec_time, não pela mais lenta isolada. - Para cada query, identifique: colunas de igualdade, colunas de faixa,
ORDER BY, colunas projetadas. - Monte o índice: igualdade → faixa/ordenação → INCLUDE das projetadas (se o IOS compensar).
- Tente consolidar: um índice bem desenhado frequentemente atende 3–4 queries.
- Valide com
hypopgantes de criar, depois crie comCONCURRENTLY. - Monitore
idx_scanpor algumas semanas; remova o que não for usado.
SELECT * FROM hypopg_create_index('CREATE INDEX ON orders (customer_id, status)');
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
-- Index Scan using <13590>btree_orders_customer_id_status ... (só EXPLAIN, sem ANALYZE)
SELECT * FROM hypopg_list_indexes;
SELECT hypopg_reset();
-- Produção: sem bloquear escrita
CREATE INDEX CONCURRENTLY idx_orders_cust_status ON orders (customer_id, status);
-- Se falhar, sobra um índice INVALID: verifique e remova
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;- Cada índice é atualizado em todo
INSERTe emUPDATEs que não sejam HOT — amplificação de escrita e de WAL. - Indexar uma coluna frequentemente atualizada impede HOT updates naquela tabela para essas alterações (módulo 12).
- Índices competem por
shared_bufferscom os dados. - Mais índices = mais caminhos para o planner avaliar (tempo de planejamento).
-- Não usados desde o último reset de estatísticas (exclui PK/UNIQUE)
SELECT s.schemaname, s.relname, s.indexrelname, s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0 AND NOT i.indisunique AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;
-- Duplicados (mesma tabela, mesmas colunas/expressões/predicado)
SELECT indrelid::regclass, array_agg(indexrelid::regclass) AS dups
FROM pg_index
GROUP BY indrelid, indkey, indclass, coalesce(indexprs::text,''), coalesce(indpred::text,'')
HAVING count(*) > 1;
-- FKs sem índice no lado referenciador (DELETE no pai vira Seq Scan no filho!)
SELECT c.conrelid::regclass AS tabela, a.attname AS coluna
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.contype = 'f'
AND NOT EXISTS (SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND i.indkey[0] = c.conkey[1]);pg_stat_user_indexes é por instância. Um índice com idx_scan = 0 no primário pode ser crítico para relatórios que rodam na réplica — confira todas as instâncias antes de remover.- Rode a auditoria de FKs no lab e crie os índices faltantes. Meça um
DELETE FROM customers WHERE id = …antes e depois. - Desenhe um único índice que atenda: "fila de pendentes por data", "pendentes de um cliente" e "contagem de pendentes".
- Use hypopg para avaliar 3 índices candidatos para uma query de sua escolha sem criá-los.
Reescrita de queries e anti-padrões
Quando o problema está no SQL: sargabilidade, tipos, OR, NOT IN, paginação, SELECT *, contagens e DISTINCT.
| Anti-padrão | Por que é lento | Reescrita |
|---|---|---|
| WHERE func(col) = x | Índice em col não é usado; estimativa padrão. | Mova a função para o lado do valor ou crie índice de expressão. |
| created_at::date = d | Idem — cast na coluna. | created_at >= d AND created_at < d + 1 |
| col_text = 123 | Tipos divergentes forçam cast na coluna (ex.: varchar vs numeric). | Parâmetro com o tipo da coluna. |
| a = x OR b = y | Um único índice não cobre; pode virar Seq Scan. | Índices em a e b (BitmapOr) ou UNION ALL com exclusão. |
| NOT IN (subquery) | Semântica com NULL impede anti-join; vira "hashed SubPlan" ou pior, SubPlan por linha. | NOT EXISTS (Hash Anti Join). |
| OFFSET 100000 | Lê e descarta 100k linhas a cada página. | Keyset pagination: WHERE (created_at, id) < (…). |
| LIKE '%termo%' | B-tree não serve. | pg_trgm (GIN) ou full-text. |
| SELECT * | Impede Index Only Scan, aumenta width/memória e de-TOAST. | Liste as colunas necessárias. |
| count(*) para "existe?" | Conta tudo. | EXISTS (… LIMIT 1). |
| DISTINCT para "consertar" join | Gera linhas duplicadas e depois as remove (Sort/Hash). | EXISTS/semi-join. |
| Subquery correlacionada no SELECT | Executa por linha (SubPlan, loops=N). | JOIN + GROUP BY ou LATERAL. |
| N+1 do ORM | Milhares de queries pequenas; latência de rede domina. | Carga em lote (= ANY($1)), joins. |
-- ✗ não usa idx_orders_created_at
SELECT count(*) FROM orders WHERE created_at::date = '2025-03-10';
SELECT count(*) FROM orders WHERE extract(year FROM created_at) = 2025;
-- ✓ faixas semiabertas
SELECT count(*) FROM orders
WHERE created_at >= '2025-03-10' AND created_at < '2025-03-11';-- clientes que nunca compraram
-- ✗ se orders.customer_id pudesse ser NULL o resultado seria vazio; e o plano é pior
SELECT count(*) FROM customers c WHERE c.id NOT IN (SELECT customer_id FROM orders);
-- ✓ Hash Anti Join
SELECT count(*) FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);-- ✗ OR entre colunas de tabelas diferentes impede o uso de índices
SELECT o.* FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE c.email = 'cliente42@exemplo.com' OR o.payload @> '{"coupon":"VIP"}';
-- ✓ cada ramo usa seu índice
SELECT o.* FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE c.email = 'cliente42@exemplo.com'
UNION
SELECT o.* FROM orders o WHERE o.payload @> '{"coupon":"VIP"}';-- ✗ página 5000: lê 100.020 linhas
SELECT id, created_at, total FROM orders ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
-- ✓ passa o último (created_at, id) visto; custo constante em qualquer página
CREATE INDEX idx_orders_created_id ON orders (created_at DESC, id DESC);
SELECT id, created_at, total FROM orders
WHERE (created_at, id) < ('2025-05-01 10:00:00-03', 3200000)
ORDER BY created_at DESC, id DESC
LIMIT 20;-- Os 3 últimos pedidos de cada cliente de Vitória (usa idx_orders_cust_created)
SELECT c.id, o.*
FROM customers c
CROSS JOIN LATERAL (
SELECT id, total, created_at FROM orders
WHERE customer_id = c.id ORDER BY created_at DESC LIMIT 3
) o
WHERE c.city = 'Vitória';
-- Contagem aproximada instantânea (quando exatidão não é necessária)
SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders';- Para cada linha da tabela de anti-padrões, escreva a versão ruim e a boa no lab e registre tempo e buffers.
- Implemente paginação keyset para "pedidos de um cliente" e compare a página 1 com a página 500 via OFFSET.
- Reescreva uma subquery correlacionada no
SELECT(ex.: total gasto por cliente) em JOIN + GROUP BY e em LATERAL.
Planos genéricos, CTEs e subqueries
Prepared statements e o "parameter sniffing" do PostgreSQL, materialização de CTEs, pull-up de subqueries e funções.
Um prepared statement (drivers JDBC, Npgsql, psycopg com server-side binding, PgBouncer em modo transaction com prepared statements) é planejado com os valores reais nas 5 primeiras execuções (custom plans). Da sexta em diante, o PostgreSQL compara o custo médio desses planos com o de um plano genérico (sem valores); se o genérico não for mais caro, passa a usá-lo para sempre naquela sessão. Com dados enviesados isso é desastroso: o plano genérico para status = $1 é bom para 'delivered' (60%) e péssimo para 'pending' (1%), ou vice-versa.
PREPARE q(text) AS SELECT count(*) FROM orders WHERE status = $1 AND created_at > now() - interval '7 days';
EXECUTE q('pending'); -- repita 6×
EXPLAIN EXECUTE q('pending'); -- repare em "$1" no filtro → plano genérico
SELECT name, generic_plans, custom_plans FROM pg_prepared_statements;
-- Controle
SET plan_cache_mode = force_custom_plan; -- sempre replaneja (custo de CPU de planejamento)
SET plan_cache_mode = force_generic_plan; -- útil para testar o pior caso
-- também por role/database: ALTER ROLE app SET plan_cache_mode = force_custom_plan;Até o PG11 toda CTE era uma "barreira de otimização" (materializada). Desde o PG12, CTEs não recursivas, sem efeitos colaterais e referenciadas uma única vez são incorporadas (inline) à query. É possível forçar qualquer um dos comportamentos:
-- Inline: o filtro externo é empurrado para dentro e usa índice
WITH recentes AS NOT MATERIALIZED (
SELECT * FROM orders WHERE created_at > now() - interval '30 days'
)
SELECT * FROM recentes WHERE customer_id = 42;
-- Materialized: calcula uma vez (útil quando referenciada várias vezes ou para
-- "isolar" um pedaço que o planner insiste em estimar mal)
WITH agg AS MATERIALIZED (
SELECT customer_id, sum(total) AS gasto FROM orders GROUP BY customer_id
)
SELECT * FROM agg WHERE gasto > 10000;IN (SELECT …)eEXISTSviram Semi Join;NOT EXISTSvira Anti Join — ambos podem usar hash/merge/nested loop.- Subqueries no
FROMsão "achatadas" (pull-up) quando não têm agregação/LIMIT/window. SubPlanno plano = executado por linha;InitPlan= executado uma única vez.hashed SubPlan= subquery materializada em hash.- Funções PL/pgSQL são caixas-pretas para o planner (custo e linhas fixos). Funções
LANGUAGE sqlsimples,IMMUTABLE/STABLE, podem ser incorporadas (inlined). - Para investigar o plano das queries dentro de funções:
auto_explain.log_nested_statements = on.
- Reproduza a troca para plano genérico com
PREPAREe mostre um valor de parâmetro em que ele é 10× pior que o custom. - Compare a mesma CTE com
MATERIALIZEDeNOT MATERIALIZEDquando há filtro externo seletivo. - Encontre um
SubPlancomloopsalto e elimine-o reescrevendo a query.
Manutenção: VACUUM, bloat e HOT
MVCC tem custo: dead tuples, bloat de tabela e índice, visibility map e o papel do autovacuum na performance de leitura.
- Remove dead tuples e torna o espaço reutilizável dentro das páginas (não devolve ao SO, exceto páginas vazias no fim do arquivo).
- Atualiza o visibility map (Index Only Scan) e o free space map.
- Congela XIDs antigos, prevenindo wraparound.
VACUUM FULL/CLUSTERreescrevem a tabela inteira comACCESS EXCLUSIVElock — em produção, prefirapg_repackoupg_squeeze.- ANALYZE (ou autoanalyze) é o que atualiza as estatísticas do planner.
vacuum dispara quando: dead_tuples > autovacuum_vacuum_threshold (50)
+ autovacuum_vacuum_scale_factor (0.2) × reltuples
analyze dispara quando: mudanças > autovacuum_analyze_threshold (50)
+ autovacuum_analyze_scale_factor (0.1) × reltuplesEm uma tabela de 500M linhas, 20% = 100M dead tuples antes do primeiro vacuum — tarde demais. Ajuste por tabela:
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_vacuum_cost_limit = 2000 -- deixa o worker trabalhar mais rápido
);
-- Monitorando
SELECT relname, n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS pct_dead,
last_autovacuum, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 20;
-- Progresso de vacuums em execução
SELECT * FROM pg_stat_progress_vacuum;idle in transaction), slots de replicação inativos e hot_standby_feedback com consultas longas na réplica seguram o xmin horizon: o VACUUM roda mas não consegue remover nada. Verifique backend_xmin em pg_stat_activity e pg_replication_slots. Configure idle_in_transaction_session_timeout.Um Heap-Only Tuple update acontece quando nenhuma coluna indexada muda e há espaço livre na mesma página. Nesse caso nenhum índice é tocado — escrita muito mais barata e menos bloat de índice. Reservar espaço com fillfactor aumenta a taxa de HOT em tabelas muito atualizadas.
SELECT relname, n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS pct_hot
FROM pg_stat_user_tables ORDER BY n_tup_upd DESC;
ALTER TABLE orders SET (fillfactor = 85); -- vale para páginas novas; reescreva para aplicar a tudoSELECT * FROM pgstattuple('orders'); -- exato, lê a tabela inteira
SELECT * FROM pgstattuple_approx('orders'); -- rápido, usa o visibility map
SELECT * FROM pgstatindex('idx_orders_created_at'); -- avg_leaf_density, leaf_fragmentation
-- Índice inchado: reconstrua sem lock de escrita
REINDEX INDEX CONCURRENTLY idx_orders_created_at;- Desligue o autovacuum de
orders, atualize 30% das linhas 3 vezes e meça: tamanho, bloat, tempo de um Seq Scan eHeap Fetchesde um IOS. Depois rode VACUUM. - Abra uma transação
REPEATABLE READem outra sessão e mostre que o VACUUM não remove dead tuples enquanto ela existir. - Compare a taxa de HOT updates com
fillfactor100 e 80 atualizandototal(coluna sem índice) estatus(com índice).
Configuração do servidor para performance
Os parâmetros que mudam planos e tempos de execução — memória, custos, I/O, paralelismo e JIT.
| Parâmetro | Ponto de partida | Efeito |
|---|---|---|
| shared_buffers | 25% da RAM | Cache do PostgreSQL. Acima de ~40% raramente ajuda (double buffering com page cache). |
| effective_cache_size | 50–75% da RAM | Só estimativa: valores maiores favorecem index scans. |
| work_mem | 16–64MB (OLTP), mais em DW | Por nó de sort/hash, por processo — uma query pode usar vários ×. Ajuste por role/sessão para relatórios. |
| hash_mem_multiplier | 2.0 | Multiplicador de work_mem para hash tables. |
| maintenance_work_mem | 512MB–2GB | CREATE INDEX, VACUUM, ALTER TABLE ADD FK. |
| random_page_cost | 1.1 (SSD/NVMe/cloud) | Principal alavanca Seq vs Index. 4.0 é calibrado para disco rotacional. |
| effective_io_concurrency | 16 (PG18 padrão) – 200 | Prefetch em bitmap heap scans (e demais leituras com AIO no PG18). |
| io_method (PG18) | worker (padrão) / io_uring | I/O assíncrono para seq scans, bitmap heap scans e vacuum. |
| default_statistics_target | 100 (até 500 em DW) | Tamanho da amostra/histograma do ANALYZE. |
| max_parallel_workers_per_gather | 2–4 | Workers por nó Gather. |
| jit | off em OLTP | JIT compensa em queries analíticas longas; em OLTP o custo de compilação costuma ser maior que o ganho. |
| track_io_timing | on | Tempos de I/O no EXPLAIN e em pg_stat_statements. Verifique o overhead com pg_test_timing. |
-- Ajustes cirúrgicos, sem afetar a instância toda
ALTER ROLE relatorios SET work_mem = '256MB';
ALTER DATABASE dw SET random_page_cost = 1.1;
-- Dentro de uma transação apenas
BEGIN;
SET LOCAL work_mem = '1GB';
SELECT ... ; -- query pesada
COMMIT;
-- Onde está cada valor e de onde veio
SELECT name, setting, unit, source, sourcefile FROM pg_settings
WHERE name IN ('work_mem','random_page_cost','shared_buffers','jit','io_method');
-- Taxa de acerto de cache (referência, não objetivo absoluto)
SELECT datname, round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2) AS hit_pct
FROM pg_stat_database WHERE datname = current_database();work_mem = 1GB global com 200 conexões e queries com 5 nós de hash pode, no pior caso, pedir centenas de GB e acionar o OOM killer. Use valores globais conservadores e eleve por role/sessão. Um pooler (PgBouncer) reduz o número de backends e torna o orçamento de memória previsível.- Rode
pgbench -i -s 100e um workload de leitura comrandom_page_cost4.0 vs 1.1. Compare TPS e planos. - Compare uma agregação analítica com
jit = oneoff; analise o blocoJIT:do EXPLAIN. - PG18: compare Seq Scan de
orderscom cache frio usandoio_method = synceworker.
Particionamento e grandes volumes
Particionamento declarativo, partition pruning em tempo de plano e de execução, partitionwise join/aggregate e índices em partições.
Particionar não é uma ferramenta de performance genérica: ajuda quando as queries filtram pela chave de partição (pruning) e na manutenção (descartar dados antigos com DETACH/DROP em vez de DELETE, vacuum por partição). Queries que não filtram pela chave passam a varrer todas as partições e pagam overhead de planejamento.
CREATE TABLE orders_p (
id bigint, customer_id bigint, status text, total numeric(12,2),
created_at timestamptz NOT NULL, payload jsonb,
PRIMARY KEY (id, created_at) -- a PK precisa conter a chave de partição
) PARTITION BY RANGE (created_at);
CREATE TABLE orders_p_2024 PARTITION OF orders_p FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE orders_p_2025 PARTITION OF orders_p FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
CREATE TABLE orders_p_2026 PARTITION OF orders_p FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
INSERT INTO orders_p SELECT * FROM orders;
CREATE INDEX ON orders_p (customer_id, created_at); -- propagado a todas as partições
ANALYZE orders_p;
-- Pruning em tempo de plano: só a partição 2025 aparece
EXPLAIN SELECT count(*) FROM orders_p WHERE created_at BETWEEN '2025-03-01' AND '2025-04-01';
-- Pruning em tempo de execução (parâmetros, subqueries, now())
EXPLAIN (ANALYZE) SELECT count(*) FROM orders_p WHERE created_at > now() - interval '10 days';
-- Subplans Removed: 2
-- Joins e agregações por partição (desligados por padrão)
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;- Mantenha o número de partições na casa das dezenas/centenas; milhares aumentam o tempo de planejamento e o uso de memória por backend.
- Crie índices em tabelas grandes particionadas com
CREATE INDEX … ON ONLYno pai +CREATE INDEX CONCURRENTLYem cada partição +ALTER INDEX … ATTACH PARTITION. pg_partmanautomatiza criação e retenção de partições por tempo.- Alternativas antes de particionar: BRIN, índices parciais, arquivamento,
materialized viewspara agregados.
- Compare
orderseorders_ppara: filtro por mês, filtro por cliente sem data, e remoção de 2024 (DELETEvsDETACH PARTITION … CONCURRENTLY). - Mostre um caso de runtime pruning com
PREPAREe parâmetro de data.
Observabilidade de queries
Encontrar o que otimizar: pg_stat_statements, auto_explain, logs, pg_stat_activity, wait events, locks e pg_stat_io.
-- Top 15 por tempo total (o que mais consome o servidor)
SELECT queryid,
calls,
round(total_exec_time::numeric / 1000, 1) AS total_s,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(stddev_exec_time::numeric, 2) AS stddev_ms,
rows / nullif(calls, 0) AS rows_per_call,
shared_blks_hit + shared_blks_read AS blks,
round(100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct,
temp_blks_written,
round(100.0 * total_exec_time / sum(total_exec_time) OVER (), 1) AS pct_total,
left(query, 120) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;
-- Outros recortes úteis
-- ORDER BY mean_exec_time DESC → lentas individualmente
-- ORDER BY shared_blks_read DESC → mais I/O
-- ORDER BY temp_blks_written DESC → sorts/hashes em disco (work_mem)
-- ORDER BY calls DESC → candidatas a cache / N+1
SELECT pg_stat_statements_reset(); -- zere antes de um teste de cargashared_preload_libraries = 'pg_stat_statements,auto_explain'
auto_explain.log_min_duration = '500ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_timing = off # reduz overhead; mantém contagem de linhas
auto_explain.log_nested_statements = on
auto_explain.sample_rate = 0.1 # amostrar 10% em ambientes muito carregados
auto_explain.log_format = 'json'
log_min_duration_statement = '1s'
log_lock_waits = on
log_temp_files = 0SELECT pid, usename, state, wait_event_type, wait_event,
now() - query_start AS duracao, backend_xmin, left(query, 100)
FROM pg_stat_activity
WHERE state <> 'idle' AND pid <> pg_backend_pid()
ORDER BY duracao DESC;
-- Quem bloqueia quem
SELECT blocked.pid AS bloqueado, pg_blocking_pids(blocked.pid) AS bloqueadores,
left(blocked.query, 80) AS query
FROM pg_stat_activity blocked
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;
-- I/O por tipo de backend e contexto (PG16+)
SELECT backend_type, object, context, reads, read_bytes, writes, extends, hits
FROM pg_stat_io WHERE reads > 0 ORDER BY reads DESC;| Wait event | Indica |
|---|---|
| IO: DataFileRead | Lendo páginas do disco — working set maior que o cache ou falta de índice. |
| IO: BufFileRead/Write | Arquivos temporários (sort/hash em disco) → work_mem. |
| Lock: relation / tuple / transactionid | Contenção de locks — transações longas, DDL, hot rows. |
| LWLock: BufferMapping / buffer_content | Contenção em shared buffers — páginas muito quentes. |
| LWLock: WALWrite / IO: WALSync | Gargalo de escrita de WAL / fsync. |
| Client: ClientRead | Esperando a aplicação (inclusive idle in transaction). |
| (nulo) + state active | Consumindo CPU. |
- Rode um workload misto com
pgbench -f(arquivo com 5 queries do curso) por 5 minutos e produza o ranking dopg_stat_statements. - Ative o
auto_explaine capture o plano de uma query lenta disparada por uma função PL/pgSQL. - Simule um bloqueio (duas sessões atualizando a mesma linha) e identifique-o pelas views.
Metodologia de tuning e estudos de caso
Um processo repetível para qualquer query lenta e cinco casos resolvidos de ponta a ponta para consolidar o curso.
- As estimativas batem com o real (até ~10×)? Se não: ANALYZE,
SET STATISTICS,CREATE STATISTICS, índice de expressão. - Há
Rows Removed by Filteralto em um scan? → índice ou filtro mais seletivo. - Há
external merge,Batches > 1,temp written? → work_mem, reduzir width, filtrar antes. - Nested Loop com
loopsalto no lado interno sem índice? → índice na chave de join ou corrigir estimativa externa. - Index Only Scan com
Heap Fetchesaltos? → VACUUM / autovacuum mais agressivo. - Plano bom no psql e ruim na aplicação? → plano genérico (
plan_cache_mode), tipos dos parâmetros,search_path, GUCs por role. - Plano bom e tempo ruim? → I/O frio, locks, wait events, rede/serialização (
EXPLAIN (SERIALIZE)), bloat. - Query roda 10.000× por request? → problema é a aplicação (N+1), não o plano.
Caso 1 · "Últimos pedidos do cliente" em 1,8 s
Sintoma: Parallel Seq Scan + top-N heapsort em 5M linhas. Correção: índice (customer_id, created_at DESC) INCLUDE (status, total). Resultado: Index Only Scan, 8 buffers, < 1 ms. Lição: o ORDER BY … LIMIT precisa estar no desenho do índice.
Caso 2 · Relatório por cidade/estado com Nested Loop desastroso
Sintoma: estimativa de 62.500 clientes (real 125.000) em city AND state, que em joins maiores leva a um Nested Loop com 1M+ loops. Correção: CREATE STATISTICS (dependencies, mcv) ON city, state. Resultado: Hash Join, 12× mais rápido. Lição: corrija a estimativa antes de mexer no join.
Caso 3 · Fila de pendentes lenta e cada vez pior
Sintoma: status = 'pending' ORDER BY created_at LIMIT 50 usa o índice de created_at e filtra milhões de linhas não-pending (a estimativa aposta que pending aparece logo). Correção: índice parcial (created_at) WHERE status = 'pending' + autovacuum agressivo na tabela. Lição: status raros pedem índices parciais.
Caso 4 · Rápido no psql, lento na API
Sintoma: a mesma query demora 5 ms no psql e 3 s via JDBC após algumas execuções. Diagnóstico: auto_explain mostra $1 no filtro — plano genérico ruim para valores raros. Correção: ALTER ROLE api SET plan_cache_mode = force_custom_plan (ou separar a query por caso). Lição: sempre teste com EXPLAIN (GENERIC_PLAN).
Caso 5 · DELETE de clientes travando o sistema
Sintoma: DELETE FROM customers WHERE id = … leva 900 ms e segura locks. Diagnóstico: FK de orders.customer_id sem índice → Seq Scan de 5M linhas a cada exclusão (visível como "Trigger for constraint" no EXPLAIN ANALYZE). Correção: índice em orders(customer_id). Lição: toda FK consultada ou com deleções no pai precisa de índice.
- Escreva 10 queries "de aplicação" realistas para o lab (telas, relatórios, filas, busca textual, JSON).
- Rode-as em carga com
pgbench, colete o baseline dopg_stat_statements. - Aplique a metodologia a cada uma, documentando plano antes/depois, buffers, tempo e a justificativa.
- Ao final, meça o custo de escrita dos índices criados (TPS de inserção antes/depois) e remova o que não se pagar.
- Documente tudo como um runbook de tuning reutilizável.
| Sinal no plano | Provável causa | Primeira ação |
|---|---|---|
| rows=10 … actual rows=100000 | Estatística ruim / correlação | ANALYZE, CREATE STATISTICS, índice de expressão |
| Rows Removed by Filter: N grande | Filtro sem índice ou índice pouco seletivo | Índice composto/parcial alinhado ao filtro |
| Sort Method: external merge | work_mem pequeno ou sort desnecessário | work_mem por sessão, índice que entregue a ordem |
| Hash … Batches: 8+ | Hash table não coube em memória | work_mem, projetar menos colunas, filtrar antes |
| Heap Fetches: N alto | Visibility map desatualizado | VACUUM / autovacuum por tabela |
| Heap Blocks: lossy | Bitmap maior que work_mem | work_mem ou índice mais seletivo |
| SubPlan com loops=N | Subquery correlacionada | JOIN, EXISTS ou LATERAL |
| Workers Launched < Planned | Pool de workers esgotado | max_parallel_workers / concorrência |
| Filtro com $1 / $2 | Plano genérico de prepared statement | plan_cache_mode, EXPLAIN (GENERIC_PLAN) |
| Trigger for constraint … time alto | FK sem índice no filho | Índice na coluna FK |
Capítulo oficial sobre EXPLAIN, estatísticas do planner, controle de joins explícitos, cargas em massa e configurações não-duráveis.
A referência mais profunda sobre MVCC, buffer cache, WAL, locks, planner, executor e cada tipo de índice. Partes IV e V são leitura obrigatória para este plano.
Indexação B-tree explicada do ponto de vista do desenvolvedor: índices compostos, faixas, ordenação, paginação e joins.
Capítulo 3 (Query Processing) detalha estimativa de custos e algoritmos de join com diagramas excelentes.
Visualizadores de planos: árvore interativa, tempo exclusivo por nó, destaque de estimativas erradas e sugestões automáticas (pgMustard).
Relatórios de log (pgBadger), predicados mais usados para sugerir índices (pg_qualstats) e amostragem de wait events ao longo do tempo (pg_wait_sampling).