PostgreSQL · Performance & Tuning

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.

17 módulos · 8 semanas
Nível: intermediário → avançado
PostgreSQL 16–18 · setembro/2026
00
Setup
Laboratório de estudos

Docker com PostgreSQL 18, extensões de diagnóstico e um dataset de ~18M linhas com distribuições enviesadas e colunas correlacionadas.

Docker + psql
~1 h
01
Fundamentos
Anatomia da execução

Parser, rewriter, planner e executor; modelo de custo (seq/random_page_cost, cpu_*), páginas, MVCC, TOAST e correlação física.

Modelo de custo
~3 h
02
Essencial
EXPLAIN em profundidade

ANALYZE, BUFFERS, SETTINGS, SERIALIZE, GENERIC_PLAN; custo × tempo real, loops, tempo exclusivo por nó e sinais de alerta.

Leitura de planos
~4 h
03
Executor
Métodos de acesso

Seq, Index, Index Only, Bitmap (lossy/recheck), TID e scans paralelos — e como a seletividade decide entre eles.

Scans
~3 h
04
Executor
Algoritmos de join

Nested Loop, Hash Join (batches), Merge Join e Memoize; ordem de join, collapse limits e GEQO.

Joins
~4 h
05
Executor
Agregação, sort e paralelismo

quicksort vs external merge, top-N, Incremental Sort, Hash/GroupAggregate, Gather e Gather Merge.

work_mem
~3 h
06
Planner
Estatísticas e cardinalidade

pg_stats, MCV, histogramas, n_distinct, SET STATISTICS e estatísticas estendidas (dependencies, ndistinct, mcv, expressões).

Estimativas
~4 h
07
Índices
B-tree a fundo

Estrutura, ordem de colunas, INCLUDE, deduplicação, skip scan (PG18), ORDER BY + LIMIT, opclasses e LIKE.

Índices compostos
~5 h
08
Índices
Hash, GIN, GiST, SP-GiST, BRIN, Bloom

JSONB (jsonb_ops vs jsonb_path_ops), full-text, pg_trgm, ranges e exclusion constraints, KNN, séries temporais.

Tipos de índice
~5 h
09
Índices
Estratégia de indexação

Índices parciais e de expressão, desenho por workload, hypopg, CONCURRENTLY, custo de escrita, índices inúteis e FKs sem índice.

Auditoria
~4 h
10
SQL
Reescrita e anti-padrões

Sargabilidade, casts, OR → UNION, NOT IN vs NOT EXISTS, keyset pagination, LATERAL, SELECT * e N+1.

Reescrita
~4 h
11
Planner
Planos genéricos, CTEs e subqueries

Prepared statements e plan_cache_mode, CTE MATERIALIZED/NOT MATERIALIZED, semi/anti-join, SubPlan e funções.

Prepared statements
~3 h
12
Operação
VACUUM, bloat e HOT

Autovacuum por tabela, xmin horizon, visibility map, HOT updates, fillfactor, pgstattuple e REINDEX CONCURRENTLY.

Manutenção
~4 h
13
Servidor
Configuração para performance

shared_buffers, work_mem, random_page_cost, effective_io_concurrency, io_method (PG18), paralelismo e JIT.

GUCs
~3 h
14
Escala
Particionamento

Particionamento declarativo, pruning em plano e execução, partitionwise join/aggregate e índices em partições.

Grandes volumes
~3 h
15
Diagnóstico
Observabilidade de queries

pg_stat_statements, auto_explain, pg_stat_activity, wait events, locks e pg_stat_io.

Monitoramento
~4 h
16
Prática
Metodologia e estudos de caso

Workflow de tuning em 5 passos, checklist de diagnóstico, 5 casos resolvidos e projeto final.

Projeto final
~6 h
Semana 1fundação

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".

Semana 2executor

Métodos de acesso e algoritmos de join

Seq/Index/Index Only/Bitmap scans, Nested Loop, Hash Join, Merge Join e Memoize — quando cada um vence e como forçá-los para comparar.

Semana 3executor + planner

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.

Semana 4índices I

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.

Semana 5índices II

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.

Semana 6SQL

Reescrita de queries, planos genéricos, CTEs e subqueries

Sargabilidade, OR → UNION, NOT IN vs NOT EXISTS, keyset pagination, plan_cache_mode, MATERIALIZED, LATERAL.

Semana 7servidor

Manutenção, configuração e particionamento

VACUUM/autovacuum, bloat, HOT updates, fillfactor; work_mem, random_page_cost, io_method; partition pruning e partitionwise join.

Semana 8prática

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.

00

Laboratório de estudos

Um ambiente reprodutível com volume suficiente para que as escolhas do planner façam diferença de verdade.

Subindo o PostgreSQL 18 com as extensões de diagnósticoBásico
Docker, extensões de diagnóstico e psql

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.

bash
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 postgres
sql · setup
CREATE 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 on
Modelo de dados e cargaBásico
~18M linhas com distribuições propositalmente enviesadas
sql · dataset
CREATE 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;
Por quêO 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.
Convenções usadas em todo o planoBásico
Notação e padrões usados no resto do guia
  • Sempre comparar planos com EXPLAIN (ANALYZE, BUFFERS) — no PG18 BUFFERS já vem ligado por padrão com ANALYZE.
  • 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.
↑ voltar ao topo
01

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.

Como o planner "pensa"Básico
Parser, rewriter, planner/optimizer e executor
1 · ParserAnálise léxica/sintática → parse tree. Resolve nomes, tipos e funções (analyze).
2 · RewriterAplica regras: expande views, RLS policies. Gera query tree(s).
3 · PlannerEnumera caminhos (paths), estima custo de cada um e escolhe o mais barato.
4 · ExecutorExecuta a árvore de nós no modelo pull (Volcano): cada nó pede linhas ao filho.

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".

O modelo de custoIntermediário
seq_page_cost, random_page_cost e os custos de CPU

Custo é uma unidade arbitrária, ancorada em "ler uma página sequencialmente = 1.0". A fórmula simplificada de um Seq Scan é:

fórmula
cost(Seq Scan) = relpages × seq_page_cost
               + reltuples × cpu_tuple_cost
               + reltuples × cpu_operator_cost × (nº de operadores no filtro)
ParâmetroPadrãoSignificado
seq_page_cost1.0Ler uma página de 8 kB sequencialmente.
random_page_cost4.0Ler uma página em posição aleatória. Em SSD/NVMe costuma-se usar 1.1–1.5.
cpu_tuple_cost0.01Processar uma linha.
cpu_index_tuple_cost0.005Processar uma entrada de índice.
cpu_operator_cost0.0025Avaliar um operador/função (multiplicado pelo COST da função).
parallel_setup_cost1000Custo de iniciar workers paralelos.
effective_cache_size4GBEstimativa de cache total (shared_buffers + page cache do SO). Não aloca memória; torna index scans mais atraentes.
sql · recalculando o custo à mão
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)
Armazenamento físico que importa para performanceIntermediário
Páginas, MVCC, TOAST e correlação física
  • 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 UPDATE cria 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 com EXPLAIN (SERIALIZE).
  • shared_buffers + page cache: uma página "lida" pode vir do cache do PostgreSQL (shared hit), do cache do SO (aparece como read, 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.
sql · espiando o físico
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)
Exercícios
  1. Calcule manualmente o custo total do Seq Scan de orders com o filtro total > 2000 e compare com o EXPLAIN.
  2. Altere SET random_page_cost = 1.1 e observe em qual seletividade um filtro em customers.created_at (com índice) passa de Seq Scan para Index/Bitmap Scan.
  3. Faça UPDATE orders SET total = total WHERE id <= 1000 e use pgstattuple('orders') para ver as dead tuples geradas.
↑ voltar ao topo
02

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ções do EXPLAINBásico
ANALYZE, BUFFERS, SETTINGS, SERIALIZE, GENERIC_PLAN
OpçãoO que acrescenta
ANALYZEExecuta a query de verdade e mostra tempos e linhas reais. Cuidado com DML: envolva em BEGIN … ROLLBACK.
BUFFERSPáginas shared hit/read/dirtied/written, local e temp. Padrão com ANALYZE a partir do PG18.
VERBOSEColunas de saída de cada nó, nomes qualificados, workers paralelos individualmente, query identifier.
SETTINGSLista GUCs de planejamento diferentes do padrão — fundamental ao anexar planos em tickets.
WALRegistros/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.
FORMATTEXT, JSON, YAML, XML. JSON para visualizadores (explain.dalibo.com, explain.depesz.com).
TIMING OFFMantém contagem de linhas sem o overhead de cronometrar cada nó (útil em planos com milhões de loops).
Anatomia de um nóIntermediário
Custo estimado vs. tempo real, loops e buffers
plano
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
CampoLeitura
cost=A..BCusto estimado de startup (A) e total (B), acumulado dos filhos.
rows (estimado)Linhas que o planner espera que o nó emita por loop.
widthTamanho médio estimado da linha em bytes.
actual time=A..BTempo 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.
loopsQuantas vezes o nó foi executado (lado interno de Nested Loop, workers paralelos).
Rows Removed by FilterLinhas lidas e descartadas — alto valor = candidato a índice ou a filtro melhor.
Buffershit = cache do PG, read = pediu ao SO, dirtied/written = escrita; temp = spill em disco.
Metodologia de leituraIntermediário
A regra de dentro para fora e os sinais de alerta
  1. 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).
  2. 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.
  3. 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).
  4. Procure sinais de alerta: Disk:/external merge, Batches > 1, Rows Removed by Filter gigante, Heap Fetches alto, loops na casa de milhares com tempo por loop não desprezível, temp read/written.
  5. 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.
ArmadilhaEm um Nested Loop, 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.
sql · planos genéricos e DML seguro
-- 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 ...;
Exercícios
  1. Rode a query de exemplo, calcule o tempo exclusivo de cada nó e identifique o mais caro.
  2. Rode com SET work_mem = '64MB' e observe o que muda no HashAggregate (Batches, Disk Usage).
  3. Cole o plano JSON no explain.dalibo.com e no explain.depesz.com; compare as visualizações.
  4. Execute a mesma query com cache frio (reinicie o container) e quente, comparando shared read vs hit.
↑ voltar ao topo
03

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.

Da seletividade à escolha do scanBásico
Como a fração de linhas decide o método de acesso
NóComo funcionaVence quando
Seq ScanLê 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 ScanPercorre 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 ScanResponde 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 ScanColeta 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 ScanAcesso direto por ctid.Operações de manutenção, deleção em lotes por faixa de páginas.
Parallel Seq / Index / Bitmap Heap ScanDivide blocos entre workers sob um nó Gather.Tabelas grandes em queries analíticas.
sql
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';
Bitmap Scan: lossy e RecheckIntermediário
Quando o bitmap não cabe em work_mem

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:

plano
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.

Index Only Scan e Heap FetchesIntermediário
O papel da visibility map na velocidade do scan
sql
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 novamente
DicaTabelas append-only (logs, eventos) historicamente não disparavam autovacuum e perdiam o benefício do Index Only Scan. Desde o PG13 existe autovacuum_vacuum_insert_threshold/_scale_factor, que dispara vacuum também por inserções.
Exercícios
  1. Encontre, variando a faixa de datas, os pontos de virada Index → Bitmap → Seq Scan. Repita com random_page_cost = 1.1.
  2. Crie índices separados em status e created_at e escreva um filtro que gere BitmapAnd. Depois compare com um índice composto.
  3. Provoque um bitmap lossy com SET work_mem = '1MB' e elimine-o aumentando o valor.
↑ voltar ao topo
04

Algoritmos de join

Nested Loop, Hash Join, Merge Join e Memoize — custo, memória, ordem de join e os limites da busca exaustiva.

O mesmo join, três planosBásico
Nested Loop, Hash Join e Merge Join lado a lado
AlgoritmoFuncionamentoCusto aproximadoIdeal para
Nested LoopPara 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 JoinConstrói hash table com o lado menor (build) e varre o maior (probe).O(N + M), memória = work_mem × hash_mem_multiplierEqui-joins com volumes médios/grandes sem ordem útil.
Merge JoinPercorre dois fluxos ordenados pela chave em paralelo.O(N + M) + custo de ordenarEntradas 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 chaveLado externo com muitos valores repetidos.
sql
-- 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 Join: batches e memóriaIntermediário
Quando a hash table não cabe em work_mem
plano
->  Hash  (actual time=2210.4..2210.4 rows=5000000 loops=1)
      Buckets: 262144  Batches: 32  Memory Usage: 11265kB
      Buffers: temp written=21456

Batches > 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.

Ordem de join e limites do plannerAvançado
Collapse limits, GEQO e busca exaustiva
  • Até join_collapse_limit (8) relações, o planner reordena JOINs 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 JOIN restringe 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.
Memoize em açãoAvançado
Cache de resultados dentro de um Nested Loop
plano
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)
ArmadilhaO Nested Loop é o algoritmo mais sensível a subestimativas: o planner o escolhe achando que o lado externo terá 10 linhas, vêm 200 mil, e cada uma dispara um index scan. Sempre que vir um Nested Loop lento, confira o rows estimado × real do lado externo — a correção normalmente está nas estatísticas (módulo 06), não no join.
Exercícios
  1. Meça o join orders ⋈ customers para um único estado com os três algoritmos forçados. Anote tempo e buffers.
  2. Provoque um Hash Join com Batches: 16+ com work_mem = '4MB' e resolva de duas formas diferentes.
  3. Escreva uma query com 10 tabelas (use subqueries/CTEs sobre as 4 existentes) e observe o efeito de join_collapse_limit = 1.
↑ voltar ao topo
05

Agregação, ordenação e paralelismo

Sort em memória vs. disco, top-N heapsort, Incremental Sort, HashAggregate vs GroupAggregate, Gather e Gather Merge.

Métodos de sortBásico
quicksort em memória vs. external merge em disco
Sort MethodSignificado
quicksortTudo coube em work_mem. Ideal.
top-N heapsortORDER BY … LIMIT N: mantém só N linhas em um heap. Muito barato.
external mergeEstourou 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.
sql
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
Estratégias de agregaçãoIntermediário
HashAggregate vs GroupAggregate e Incremental Sort
  • 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.
ParalelismoAvançado
Gather, Gather Merge e os limites do paralelismo
plano
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 < Planned indica 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. Declare PARALLEL SAFE quando for verdade.
  • Paralelismo acelera queries analíticas, mas em OLTP com alta concorrência pode piorar o throughput total.
Exercícios
  1. Encontre o menor work_mem que evita o external merge ao ordenar orders por total. Compare o tempo com o sort em disco.
  2. Compare count(*) … GROUP BY status com max_parallel_workers_per_gather = 0, 2 e 4.
  3. Crie uma função SQL simples sem PARALLEL SAFE, use-a no WHERE e veja o paralelismo desaparecer.
↑ voltar ao topo
06

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.

O que existe em pg_statsBásico
MCV, histogramas, n_distinct e como o ANALYZE os gera
ColunaUso pelo planner
null_fracSeletividade de IS NULL / IS NOT NULL.
n_distinctNº de valores distintos (negativo = fração do total de linhas). Base para GROUP BY e joins.
most_common_vals / _freqsLista de MCV com frequências — igualdade com valores frequentes.
histogram_boundsBuckets de mesma frequência para os demais valores — filtros de faixa.
correlationCorrelação entre ordem física e lógica — custo de index scans por faixa.
avg_widthLargura média — memória de hash/sort.
most_common_elemsPara arrays/tsvector — operadores @>, &&.
sql
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);
O problema das colunas correlacionadasIntermediário
Por que estimativas erram em colunas dependentes

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':

sql
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';
Tiposdependencies = 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.
Fontes clássicas de estimativa erradaAvançado
Estatísticas estendidas: dependencies, ndistinct, mcv e expressões
  • Estatística desatualizada: tabela carregada em lote e consultada antes do autoanalyze. Rode ANALYZE ao 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_elements estima 100 linhas (ROWS da função). Use ROWS em funções próprias ou SUPPORT functions.
  • 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 tmp manualmente.
Exercícios
  1. Liste as colunas das 4 tabelas com n_distinct, MCVs e correlação. Explique cada valor à luz do script de carga.
  2. Carregue 1M novos pedidos com datas futuras sem rodar ANALYZE e observe a estimativa para created_at > now() com e sem o índice em created_at.
  3. Compare a estimativa de WHERE payload->>'channel' = 'app' antes e depois de CREATE STATISTICS … ON ((payload->>'channel')) FROM orders.
↑ voltar ao topo
07

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.

EstruturaBásico
Como uma árvore B-tree é organizada em páginas

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).

Índices compostos: a ordem das colunasBásico
Por que a ordem das colunas muda tudo
  • 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ó em a; um índice só em a sobreposto a (a, b) geralmente é redundante.
sql
-- 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);
INCLUDE vs coluna-chaveIntermediário
Covering index sem inflar a árvore de busca

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).

Operator classes, collation e LIKEIntermediário
Por que um índice às vezes não é usado
sql
-- 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);
Tamanho, fillfactor e inspeçãoIntermediário
Medindo e ajustando o espaço físico do índice
sql
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)
Exercícios
  1. Para WHERE customer_id = ? AND status = 'paid' AND created_at > ?, teste 3 ordens de colunas e compare buffers.
  2. Compare o tamanho de um índice em status com deduplicate_items = off e on.
  3. Construa um cenário em que o skip scan do PG18 é escolhido e outro em que não é (coluna líder de alta cardinalidade).
  4. Transforme "últimos pedidos do cliente" em Index Only Scan com Heap Fetches: 0.
↑ voltar ao topo
Deduplicação (PG13+) e skip scan (PG18+)Avançado
As otimizações mais recentes do B-tree
  • 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 servir WHERE created_at > … sem condição em status, "pulando" entre os poucos valores distintos da primeira coluna. Vale quando a coluna líder tem baixa cardinalidade.
sql · PG18
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)
08

Outros tipos de índice

Hash, GIN, GiST, SP-GiST, BRIN e Bloom — o que cada um indexa, operadores suportados e onde brilham.

GIN para JSONBIntermediário
jsonb_ops vs jsonb_path_ops e o custo de escrita
TipoEstruturaOperadores típicosCasos de uso
B-treeÁrvore balanceada ordenada< = >, BETWEEN, IN, LIKE prefixoPadrão para quase tudo.
HashHash de 32 bits por valor= apenasIgualdade 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 textoDados com particionamento natural: IPs, pontos, telefones.
BRINResumo (min/max) por faixa de blocos< = > em dados correlacionadosTabelas enormes append-only ordenadas por tempo/ID. Minúsculo.
Bloom (ext)Filtro de Bloom por linha= em qualquer combinação de colunasTabelas com muitas colunas filtradas em combinações arbitrárias.
sql
-- 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';
GIN para busca textualIntermediário
Full-text search e trigramas com pg_trgm
sql
-- 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'];
DicaGIN tem escrita cara. Com 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.
BRIN para séries temporaisIntermediário
Índices minúsculos para dados fisicamente ordenados
sql
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.

GiST: ranges, exclusion constraints e KNNAvançado
Ranges, geoespacial e busca por vizinho mais próximo
sql
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;
Hash, SP-GiST e BloomAvançado
Os índices especializados menos usados, e quando valem a pena
sql
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);
Exercícios
  1. Compare tamanho e tempo de criação de jsonb_ops vs jsonb_path_ops, e o tempo da consulta @> em cada um.
  2. Crie B-tree e BRIN em orders.created_at; compare buffers para 1 hora, 1 dia e 1 mês. Ajuste pages_per_range.
  3. Destrua a correlação física (CLUSTER orders USING idx_orders_cust_created) e refaça o teste BRIN.
  4. Implemente busca "contém" em customers.name com pg_trgm e compare com o Seq Scan.
↑ voltar ao topo
09

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.

Índices parciaisIntermediário
Indexando só a fração de linhas que importa

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).

sql
-- 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;
Índices de expressãoIntermediário
Indexando o resultado de uma função, não a coluna crua
sql
-- 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';
BônusÍndices de expressão ganham estatísticas próprias no ANALYZE — então, além de acelerar, corrigem estimativas de filtros sobre a expressão.
Desenho orientado ao workloadAvançado
hypopg, CONCURRENTLY e índices hipotéticos
  1. Parta do pg_stat_statements (módulo 15): top queries por total_exec_time, não pela mais lenta isolada.
  2. Para cada query, identifique: colunas de igualdade, colunas de faixa, ORDER BY, colunas projetadas.
  3. Monte o índice: igualdade → faixa/ordenação → INCLUDE das projetadas (se o IOS compensar).
  4. Tente consolidar: um índice bem desenhado frequentemente atende 3–4 queries.
  5. Valide com hypopg antes de criar, depois crie com CONCURRENTLY.
  6. Monitore idx_scan por algumas semanas; remova o que não for usado.
sql · hypopg
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;
O custo dos índicesAvançado
Escrita mais lenta, espaço e índices inúteis
  • Cada índice é atualizado em todo INSERT e em UPDATEs 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_buffers com os dados.
  • Mais índices = mais caminhos para o planner avaliar (tempo de planejamento).
sql · auditoria de índices
-- 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]);
Réplicaspg_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.
Exercícios
  1. Rode a auditoria de FKs no lab e crie os índices faltantes. Meça um DELETE FROM customers WHERE id = … antes e depois.
  2. Desenhe um único índice que atenda: "fila de pendentes por data", "pendentes de um cliente" e "contagem de pendentes".
  3. Use hypopg para avaliar 3 índices candidatos para uma query de sua escolha sem criá-los.
↑ voltar ao topo
10

Reescrita de queries e anti-padrões

Quando o problema está no SQL: sargabilidade, tipos, OR, NOT IN, paginação, SELECT *, contagens e DISTINCT.

ExemplosIntermediário
Sargabilidade, casts, OR/UNION, NOT IN/EXISTS e paginação
Anti-padrãoPor que é lentoReescrita
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 = dIdem — cast na coluna.created_at >= d AND created_at < d + 1
col_text = 123Tipos divergentes forçam cast na coluna (ex.: varchar vs numeric).Parâmetro com o tipo da coluna.
a = x OR b = yUm ú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 100000Lê 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" joinGera linhas duplicadas e depois as remove (Sort/Hash).EXISTS/semi-join.
Subquery correlacionada no SELECTExecuta por linha (SubPlan, loops=N).JOIN + GROUP BY ou LATERAL.
N+1 do ORMMilhares de queries pequenas; latência de rede domina.Carga em lote (= ANY($1)), joins.
sql · sargabilidade
-- ✗ 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';
sql · NOT IN vs NOT EXISTS
-- 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);
sql · OR → UNION ALL
-- ✗ 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"}';
sql · keyset pagination
-- ✗ 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;
sql · top-N por grupo com LATERAL
-- 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';
Exercícios
  1. Para cada linha da tabela de anti-padrões, escreva a versão ruim e a boa no lab e registre tempo e buffers.
  2. Implemente paginação keyset para "pedidos de um cliente" e compare a página 1 com a página 500 via OFFSET.
  3. Reescreva uma subquery correlacionada no SELECT (ex.: total gasto por cliente) em JOIN + GROUP BY e em LATERAL.
↑ voltar ao topo
11

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.

Custom plan vs generic planIntermediário
O parameter sniffing do PostgreSQL em prepared statements

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.

sql
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;
CTEs: MATERIALIZED vs NOT MATERIALIZEDIntermediário
Quando uma CTE vira uma barreira de otimização

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:

sql
-- 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;
Subqueries: pull-up, semi-join e SubPlanAvançado
Como o planner reescreve subqueries por baixo dos panos
  • IN (SELECT …) e EXISTS viram Semi Join; NOT EXISTS vira Anti Join — ambos podem usar hash/merge/nested loop.
  • Subqueries no FROM são "achatadas" (pull-up) quando não têm agregação/LIMIT/window.
  • SubPlan no 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 sql simples, IMMUTABLE/STABLE, podem ser incorporadas (inlined).
  • Para investigar o plano das queries dentro de funções: auto_explain.log_nested_statements = on.
Exercícios
  1. Reproduza a troca para plano genérico com PREPARE e mostre um valor de parâmetro em que ele é 10× pior que o custom.
  2. Compare a mesma CTE com MATERIALIZED e NOT MATERIALIZED quando há filtro externo seletivo.
  3. Encontre um SubPlan com loops alto e elimine-o reescrevendo a query.
↑ voltar ao topo
12

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.

O que o VACUUM faz (e não faz)Básico
Dead tuples, visibility map e o que fica de fora
  • 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/CLUSTER reescrevem a tabela inteira com ACCESS EXCLUSIVE lock — em produção, prefira pg_repack ou pg_squeeze.
  • ANALYZE (ou autoanalyze) é o que atualiza as estatísticas do planner.
Autovacuum: gatilhos e tuningIntermediário
Os parâmetros que decidem quando ele dispara
fórmula
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) × reltuples

Em uma tabela de 500M linhas, 20% = 100M dead tuples antes do primeiro vacuum — tarde demais. Ajuste por tabela:

sql
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;
BloqueadorTransações longas (inclusive 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.
HOT updates e fillfactorIntermediário
O atalho que evita reescrever índices num UPDATE

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.

sql
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 tudo
Medindo bloatAvançado
pgstattuple e REINDEX CONCURRENTLY
sql
SELECT * 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;
Exercícios
  1. Desligue o autovacuum de orders, atualize 30% das linhas 3 vezes e meça: tamanho, bloat, tempo de um Seq Scan e Heap Fetches de um IOS. Depois rode VACUUM.
  2. Abra uma transação REPEATABLE READ em outra sessão e mostre que o VACUUM não remove dead tuples enquanto ela existir.
  3. Compare a taxa de HOT updates com fillfactor 100 e 80 atualizando total (coluna sem índice) e status (com índice).
↑ voltar ao topo
13

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.

Configuração do servidor para performanceIntermediário
shared_buffers, work_mem, random_page_cost e JIT
ParâmetroPonto de partidaEfeito
shared_buffers25% da RAMCache do PostgreSQL. Acima de ~40% raramente ajuda (double buffering com page cache).
effective_cache_size50–75% da RAMSó estimativa: valores maiores favorecem index scans.
work_mem16–64MB (OLTP), mais em DWPor nó de sort/hash, por processo — uma query pode usar vários ×. Ajuste por role/sessão para relatórios.
hash_mem_multiplier2.0Multiplicador de work_mem para hash tables.
maintenance_work_mem512MB–2GBCREATE INDEX, VACUUM, ALTER TABLE ADD FK.
random_page_cost1.1 (SSD/NVMe/cloud)Principal alavanca Seq vs Index. 4.0 é calibrado para disco rotacional.
effective_io_concurrency16 (PG18 padrão) – 200Prefetch em bitmap heap scans (e demais leituras com AIO no PG18).
io_method (PG18)worker (padrão) / io_uringI/O assíncrono para seq scans, bitmap heap scans e vacuum.
default_statistics_target100 (até 500 em DW)Tamanho da amostra/histograma do ANALYZE.
max_parallel_workers_per_gather2–4Workers por nó Gather.
jitoff em OLTPJIT compensa em queries analíticas longas; em OLTP o custo de compilação costuma ser maior que o ganho.
track_io_timingonTempos de I/O no EXPLAIN e em pg_stat_statements. Verifique o overhead com pg_test_timing.
sql
-- 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();
Cuidadowork_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.
Exercícios
  1. Rode pgbench -i -s 100 e um workload de leitura com random_page_cost 4.0 vs 1.1. Compare TPS e planos.
  2. Compare uma agregação analítica com jit = on e off; analise o bloco JIT: do EXPLAIN.
  3. PG18: compare Seq Scan de orders com cache frio usando io_method = sync e worker.
↑ voltar ao topo
14

Particionamento e grandes volumes

Particionamento declarativo, partition pruning em tempo de plano e de execução, partitionwise join/aggregate e índices em partições.

Particionamento e grandes volumesAvançado
Partition pruning, partitionwise join/aggregate e índices por partição

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.

sql
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 ONLY no pai + CREATE INDEX CONCURRENTLY em cada partição + ALTER INDEX … ATTACH PARTITION.
  • pg_partman automatiza criação e retenção de partições por tempo.
  • Alternativas antes de particionar: BRIN, índices parciais, arquivamento, materialized views para agregados.
Exercícios
  1. Compare orders e orders_p para: filtro por mês, filtro por cliente sem data, e remoção de 2024 (DELETE vs DETACH PARTITION … CONCURRENTLY).
  2. Mostre um caso de runtime pruning com PREPARE e parâmetro de data.
↑ voltar ao topo
15

Observabilidade de queries

Encontrar o que otimizar: pg_stat_statements, auto_explain, logs, pg_stat_activity, wait events, locks e pg_stat_io.

pg_stat_statements: o ponto de partidaBásico
A extensão obrigatória para achar as queries mais custosas
sql
-- 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 carga
auto_explain: planos reais das queries lentas em produçãoIntermediário
Capturando o EXPLAIN de queries que já rodaram
postgresql.conf
shared_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 = 0
O que está rodando agoraIntermediário
pg_stat_activity, wait events, locks e pg_stat_io
sql
SELECT 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 eventIndica
IO: DataFileReadLendo páginas do disco — working set maior que o cache ou falta de índice.
IO: BufFileRead/WriteArquivos temporários (sort/hash em disco) → work_mem.
Lock: relation / tuple / transactionidContenção de locks — transações longas, DDL, hot rows.
LWLock: BufferMapping / buffer_contentContenção em shared buffers — páginas muito quentes.
LWLock: WALWrite / IO: WALSyncGargalo de escrita de WAL / fsync.
Client: ClientReadEsperando a aplicação (inclusive idle in transaction).
(nulo) + state activeConsumindo CPU.
Exercícios
  1. Rode um workload misto com pgbench -f (arquivo com 5 queries do curso) por 5 minutos e produza o ranking do pg_stat_statements.
  2. Ative o auto_explain e capture o plano de uma query lenta disparada por uma função PL/pgSQL.
  3. Simule um bloqueio (duas sessões atualizando a mesma linha) e identifique-o pelas views.
↑ voltar ao topo
16

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.

Checklist de diagnósticoIntermediário
Um processo repetível para qualquer query lenta
1 · Priorizarpg_stat_statements por tempo total; negocie a meta (p95, throughput).
2 · ReproduzirMesmos parâmetros, mesmo volume, mesmas GUCs (SETTINGS). Plano genérico?
3 · DiagnosticarEXPLAIN (ANALYZE, BUFFERS): nó mais caro, estimativa × real, spills.
4 · CorrigirEstatística → SQL → índice → configuração → modelo. Uma mudança por vez.
5 · ValidarBuffers e tempo antes/depois; impacto em escrita; outras queries.
  • As estimativas batem com o real (até ~10×)? Se não: ANALYZE, SET STATISTICS, CREATE STATISTICS, índice de expressão.
  • Há Rows Removed by Filter alto em um scan? → índice ou filtro mais seletivo.
  • Há external merge, Batches > 1, temp written? → work_mem, reduzir width, filtrar antes.
  • Nested Loop com loops alto no lado interno sem índice? → índice na chave de join ou corrigir estimativa externa.
  • Index Only Scan com Heap Fetches altos? → 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.
Estudos de casoAvançado
Cinco casos reais resolvidos de ponta a ponta

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.

Projeto final
  1. Escreva 10 queries "de aplicação" realistas para o lab (telas, relatórios, filas, busca textual, JSON).
  2. Rode-as em carga com pgbench, colete o baseline do pg_stat_statements.
  3. Aplique a metodologia a cada uma, documentando plano antes/depois, buffers, tempo e a justificativa.
  4. 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.
  5. Documente tudo como um runbook de tuning reutilizável.
↑ voltar ao topo
Sinal no planoProvável causaPrimeira ação
rows=10 … actual rows=100000Estatística ruim / correlaçãoANALYZE, CREATE STATISTICS, índice de expressão
Rows Removed by Filter: N grandeFiltro sem índice ou índice pouco seletivoÍndice composto/parcial alinhado ao filtro
Sort Method: external mergework_mem pequeno ou sort desnecessáriowork_mem por sessão, índice que entregue a ordem
Hash … Batches: 8+Hash table não coube em memóriawork_mem, projetar menos colunas, filtrar antes
Heap Fetches: N altoVisibility map desatualizadoVACUUM / autovacuum por tabela
Heap Blocks: lossyBitmap maior que work_memwork_mem ou índice mais seletivo
SubPlan com loops=NSubquery correlacionadaJOIN, EXISTS ou LATERAL
Workers Launched < PlannedPool de workers esgotadomax_parallel_workers / concorrência
Filtro com $1 / $2Plano genérico de prepared statementplan_cache_mode, EXPLAIN (GENERIC_PLAN)
Trigger for constraint … time altoFK sem índice no filhoÍndice na coluna FK