Dados e bancos de dados
Boas práticas de SQL: consultas corretas, rápidas e fáceis de manter
Intermediário11 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
A maioria dos problemas de desempenho em banco de dados vem da forma como as consultas e os esquemas são escritos, não do motor. Este guia reúne os hábitos que mantêm seu SQL correto e rápido no PostgreSQL e no Oracle: buscar só o que você precisa, escrever predicados que o otimizador consegue usar, tratar NULL com rigor, escolher entre EXISTS e IN, usar bind parameters, paginar por chave (keyset) e agrupar as escritas em lotes.
O que você vai aprender
- Escrever predicados que permitam ao otimizador usar um índice em vez de varrer a tabela inteira
- Explicar a lógica de três valores e evitar os erros clássicos com NULL em NOT IN e em comparações
- Escolher entre JOIN, EXISTS e IN de acordo com o resultado que você realmente quer
- Usar bind parameters, paginação keyset e escritas em lote a partir do código da aplicação
- Reconhecer o padrão de consultas N+1 e substituí-lo por uma única consulta baseada em conjuntos
SQL é uma linguagem declarativa: você descreve o resultado que quer e o otimizador do banco decide como produzi-lo. Isso é libertador, e também é por isso que tanto SQL lento parece perfeitamente razoável. O otimizador só consegue escolher um bom plano se a consulta lhe der a chance, se o esquema lhe oferecer os índices e se a aplicação não puser tudo a perder chamando a consulta mil vezes dentro de um loop. As práticas abaixo valem para qualquer banco relacional; os exemplos usam a sintaxe do PostgreSQL 16, com as diferenças do Oracle indicadas quando importam.
Peça exatamente as linhas e colunas de que você precisa
A linha mais barata é aquela que você nunca busca. Daí decorrem diretamente dois hábitos.
Nomeie suas colunas. SELECT * transfere colunas que você não vai usar, impede os index-only scans (o índice não consegue atender uma consulta que toca colunas que ele não contém) e amarra seu código à ordem das colunas, o que cobra seu preço no dia em que alguém adiciona uma coluna no meio da tabela.
Filtre e agregue no banco, não na aplicação. Carregar 200.000 linhas na memória para somar uma coluna em Java é mais lento, consome mais memória e segura uma conexão por mais tempo do que SELECT SUM(amount) FROM payment WHERE .... A mesma lógica vale para ordenação e contagem: ORDER BY e COUNT(*) pertencem ao SQL, onde podem usar índices e workers paralelos.
ORMs tornam esse erro fácil de cometer. Com uma coleção lazy, percorrer 1.000 pedidos chamando order.getLines() em cada um dispara 1.001 consultas. Esse é o problema N+1, e a correção é sempre a mesma: uma única consulta que faça o join ou busque as linhas filhas como um conjunto.
-- One round trip instead of 1,001
SELECT o.id, o.placed_at, l.sku, l.quantity, l.unit_price
FROM customer_order o
JOIN order_line l ON l.order_id = o.id
WHERE o.customer_id = $1
ORDER BY o.placed_at DESC, o.id, l.line_no;
Em JPA, isso significa um JOIN FETCH ou um entity graph; no Spring Data, @EntityGraph ou uma projeção. O guia de Spring Data JPA mostra as duas abordagens.
Escreva predicados que o otimizador consiga usar
Um índice em email é inútil para a consulta WHERE LOWER(email) = '[email protected]'. A função transforma a coluna, então os valores armazenados no índice já não correspondem ao que a comparação precisa. Um predicado que pode ser resolvido com uma busca no índice é chamado de sargable (de "search argument"). A regra é simples: deixe a coluna intacta de um lado do operador e coloque o cálculo do outro.
| Não sargable | Equivalente sargable |
|---|---|
WHERE LOWER(email) = '[email protected]' | WHERE email = '[email protected]' com uma collation que não diferencia maiúsculas de minúsculas ou uma coluna citext, ou um índice de expressão em LOWER(email) |
WHERE placed_at::date = '2026-09-01' | WHERE placed_at >= '2026-09-01' AND placed_at < '2026-09-02' |
WHERE amount * 1.2 > 100 | WHERE amount > 100 / 1.2 |
WHERE TO_CHAR(placed_at, 'YYYY') = '2026' | WHERE placed_at >= DATE '2026-01-01' AND placed_at < DATE '2027-01-01' |
WHERE description LIKE '%invoice%' | busca full-text ou um índice de trigramas; um curinga no início não consegue usar uma B-tree |
A conversão implícita de tipos é a versão traiçoeira do mesmo problema. Se customer_ref é uma coluna VARCHAR e você escreve WHERE customer_ref = 12345, o PostgreSQL lança um erro, mas o Oracle converte silenciosamente o customer_ref de cada linha com TO_NUMBER e varre a tabela. Faça o bind dos parâmetros com o tipo JDBC correto e compare datas com literais de data, não com strings.
Predicados de intervalo merecem mais uma observação: um índice em (status, placed_at) responde com eficiência a WHERE status = 'PAID' AND placed_at > :since, mas um índice em (placed_at, status) não consegue aproveitar a igualdade em status depois do intervalo em placed_at. A ordem das colunas num índice composto é uma decisão de design; o guia de índices e planos de execução explica como escolhê-la.
Trate NULL como o que ele é: desconhecido
O SQL usa lógica de três valores. Qualquer comparação com NULL não é verdadeira nem falsa, e sim UNKNOWN, e o WHERE só mantém as linhas cuja condição é TRUE. Todas as surpresas abaixo vêm dessa regra:
WHERE discount <> 0descarta as linhas em quediscount IS NULL. Se o que você quer dizer é "sem desconto ou com desconto zero", escrevaWHERE discount IS NULL OR discount = 0, ou defina a coluna comoNOT NULL DEFAULT 0para que a dúvida nem exista.NULL = NULLéUNKNOWN, e é por isso queWHERE a.col = b.colnunca junta dois NULLs. O PostgreSQL ofereceIS NOT DISTINCT FROMpara quando você realmente quer que NULL seja igual a NULL.x NOT IN (SELECT y FROM t)não retorna absolutamente nada se algumyforNULL. UseNOT EXISTS.COUNT(col)ignora NULLs;COUNT(*)conta linhas.AVG(col)calcula a média apenas dos valores não nulos, o que pode ou não ser o que o relatório espera.- O Oracle trata a string vazia
''comoNULLem colunasVARCHAR2. O PostgreSQL, não. Código portado entre os dois não deve depender de nenhum desses comportamentos.
COALESCE(col, fallback) é a forma padrão e portável de substituir um valor; NVL e DECODE do Oracle e IFNULL do MySQL fazem o mesmo trabalho com pior portabilidade. Prefira expressões CASE para qualquer coisa mais complexa do que um único valor padrão.
Joins, EXISTS e IN
Use a sintaxe explícita de join ANSI (JOIN ... ON) em vez de listar as tabelas no FROM e fazer o join no WHERE. Ela separa as condições de join dos filtros, elimina a ambiguidade dos outer joins e é o que todo otimizador moderno e todo revisor de código esperam. A notação (+) do Oracle para outer joins ainda funciona, mas é legado.
Escolha o tipo de join pelo resultado que você quer, não por hábito:
INNER JOINquando uma linha sem correspondência deve sumir.LEFT JOINquando a linha da esquerda precisa sobreviver mesmo sem correspondência. Um filtro sobre a tabela da direita noWHERE(em vez de noON) transforma o left join de volta num inner join, porque as linhas sem correspondência têmNULLali e não passam no filtro.EXISTSquando você faz uma pergunta de sim ou não sobre linhas relacionadas e não precisa das colunas delas. Ele para na primeira correspondência e nunca duplica linhas, o que um join faria se a tabela filha tivesse várias correspondências.IN (subquery)para conjuntos de valores pequenos e sem NULL. Os otimizadores reescrevem a maioria das subconsultasINcomo semi-joins de qualquer forma, então a diferença de desempenho em relação aEXISTScostuma ser nula; a diferença semântica em torno deNULL, não.
-- Customers with at least one unpaid invoice: no duplicates, no NULL trap
SELECT c.id, c.name
FROM customer c
WHERE EXISTS (
SELECT 1
FROM invoice i
WHERE i.customer_id = c.id
AND i.status = 'UNPAID'
);
Adicionar DISTINCT para fazer as duplicatas sumirem é um mau sinal: as duplicatas indicam que o join está multiplicando linhas, e o DISTINCT esconde a causa atrás de uma ordenação extra. Da mesma forma, UNION remove duplicatas e ordena; UNION ALL não, e é o que você quer quando os dois lados não podem se sobrepor.
Bind parameters, sempre
Concatenar a entrada do usuário no SQL é a causa da injeção de SQL, que continua entre os riscos de injeção do OWASP Top 10 décadas depois de os prepared statements terem tornado essa concatenação desnecessária. Os bind parameters (? no JDBC, $1 no protocolo do PostgreSQL, :name no Oracle e no NamedParameterJdbcTemplate do Spring) enviam o comando e os valores separadamente, de modo que um valor nunca consegue alterar a estrutura do comando.
Há também um motivo de desempenho. O Oracle guarda em cache os comandos analisados no shared pool, indexados pelo texto; mil variantes da mesma consulta cheias de literais causam mil hard parses e contenção de latches. Os prepared statements do lado do servidor do PostgreSQL e o prepareThreshold do driver JDBC trazem o mesmo benefício para consultas repetidas.
// JDBC: the value is a parameter, never part of the SQL text
try (PreparedStatement ps = conn.prepareStatement(
"SELECT id, total FROM customer_order WHERE customer_id = ? AND placed_at >= ?")) {
ps.setLong(1, customerId);
ps.setObject(2, since); // java.time.LocalDate maps to DATE
try (ResultSet rs = ps.executeQuery()) { /* ... */ }
}
A única coisa que não dá para fazer bind é um identificador: nomes de tabelas e colunas devem vir de uma whitelist no seu código, nunca da entrada do usuário.
Paginação e ordenação
Um ORDER BY sobre um resultado grande, sem um índice que já forneça essa ordem, força uma ordenação que pode transbordar para o disco. Ordene só quando quem consome os dados precisa de ordem e, ao paginar, garanta que a ordem seja total: ORDER BY placed_at sozinho é ambíguo para linhas com o mesmo timestamp e pode mostrar a mesma linha em duas páginas. Adicione a chave primária como critério de desempate.
A paginação com OFFSET lê e descarta todas as linhas anteriores à página; a página 1.000 custa mil vezes a página 1. A paginação keyset guarda a última linha vista e pede as linhas seguintes:
-- First page
SELECT id, placed_at, total
FROM customer_order
WHERE customer_id = $1
ORDER BY placed_at DESC, id DESC
LIMIT 20;
-- Next page: pass the last row's (placed_at, id) back in
SELECT id, placed_at, total
FROM customer_order
WHERE customer_id = $1
AND (placed_at, id) < ($2, $3) -- row-value comparison
ORDER BY placed_at DESC, id DESC
LIMIT 20;
Com um índice em (customer_id, placed_at DESC, id DESC), cada página é uma varredura curta de um intervalo do índice, qualquer que seja a profundidade. O Oracle usa FETCH FIRST 20 ROWS ONLY (a partir do 12c) em vez de LIMIT; a ideia do keyset é a mesma.
Transações e lotes a partir do código da aplicação
Todo comando roda dentro de uma transação; a questão é se você controla os limites dela. O auto-commit transforma um loop de 10.000 inserts em 10.000 transações, cada uma esperando um flush do log. Envolva o lote numa única transação e use a API de batch do driver para que os comandos trafeguem em bloco:
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO event (id, occurred_at, payload) VALUES (?, ?, ?::jsonb)")) {
for (Event e : events) {
ps.setObject(1, e.id()); // UUID
ps.setObject(2, e.occurredAt()); // OffsetDateTime -> timestamptz
ps.setString(3, e.payloadJson());
ps.addBatch();
if (++n % 500 == 0) ps.executeBatch(); // keep memory bounded
}
ps.executeBatch();
conn.commit();
}
O driver JDBC do PostgreSQL reescreve inserts em lote como comandos INSERT de várias linhas quando reWriteBatchedInserts=true; o driver do Oracle faz batch nativamente. Em qualquer caso, mantenha as transações curtas: uma transação que fica aberta enquanto a aplicação conversa com uma API remota segura locks e, no PostgreSQL, impede o vacuum de fazer a limpeza.
Para atualizações que atingem uma grande parte de uma tabela, um único UPDATE ... WHERE baseado em conjuntos ganha de qualquer loop; para mudanças realmente enormes, considere fatiar por intervalos de chave para que cada transação continue pequena o bastante para não inchar o undo log ou o WAL.
Decisões de esquema que moldam cada consulta
Alguns problemas "de consulta" são, na verdade, problemas de modelagem. Três regras evitam a maioria deles:
- Declare as chaves estrangeiras e indexe a coluna que faz a referência. Sem a constraint, registros órfãos vão aparecendo e o otimizador perde uma informação que poderia usar para eliminar joins. Sem o índice, cada delete na tabela pai varre a tabela filha para verificar referências.
- Use o tipo certo. Datas em colunas
DATE/TIMESTAMP, não em strings; valores monetários emNUMERIC, não em ponto flutuante; identificadores do mesmo tipo dos dois lados de cada join, para que nenhuma conversão seja necessária. Se você gera os identificadores na aplicação, colunasUUIDos armazenam em 16 bytes em vez de 36 caracteres (um gerador de UUID ajuda na hora de popular dados de teste). - Planeje o crescimento e o expurgo. Tabelas que só crescem precisam de uma política de retenção desde o primeiro dia; apagar 100 milhões de linhas mais tarde é muito mais difícil do que descartar uma partição.
O guia de normalização aborda a parte de design em profundidade.
Erros comuns
- Função sobre a coluna indexada.
WHERE UPPER(name) = 'SMITH'varre a tabela. Armazene os dados no formato em que você os busca ou crie um índice de expressão emUPPER(name). - Filtrar o lado externo de um LEFT JOIN no WHERE.
LEFT JOIN address a ON ... WHERE a.country = 'FR'vira silenciosamente um inner join. Mova a condição para a cláusulaON. NOT INcom uma subconsulta que aceita NULL. Não retorna nada. UseNOT EXISTS.- Buscar tudo e filtrar no código. Um
findAll()seguido de um filtro num stream faz a tabela inteira trafegar pela rede. Leve o predicado para a consulta. ORDER BYsem critério de desempate em consultas paginadas. Linhas se repetem ou somem entre as páginas. Adicione a chave primária.- Hints como primeira resposta à lentidão. Um
/*+ PARALLEL */do Oracle ou um índice forçado esconde o predicado ruim que causou o problema e para de funcionar no dia em que a distribuição dos dados muda. Corrija a consulta e depois meça com o plano. - Fazer join de views sobre views. Views genéricas arrastam colunas e joins de que a consulta não precisa, e o otimizador nem sempre consegue podá-los. Consulte as tabelas base ou escreva uma view específica.
Boas práticas
- Formate o SQL de maneira consistente e use aliases de tabela curtos e significativos (
customer_order o, nãot1); uma consulta é lida muito mais vezes do que é escrita. - Prefira o SQL padrão (
COALESCE,CASE,FETCH FIRST, joins ANSI) às funções proprietárias, a menos que o recurso do fornecedor traga um ganho real de desempenho. - Examine o plano de execução de toda consulta que toca uma tabela grande antes de ela ir para produção, e de novo quando os dados crescerem dez vezes.
- Mantenha as transações curtas e com limites explícitos; nunca deixe uma aberta durante uma interação com o usuário ou uma chamada externa.
- Deixe o banco garantir a integridade:
NOT NULL,CHECK,UNIQUEe chaves estrangeiras pegam bugs que a validação da aplicação deixa passar sob concorrência. - Armazene JSON numa coluna
jsonbapenas para dados realmente variáveis; colunas relacionais continuam mais rápidas de indexar, restringir e consultar. Quando fizer isso, valide os documentos com um formatador de JSON antes de depurar um insert malformado.
Principais pontos
- Busque só as colunas e linhas de que precisa, e faça filtragem, ordenação e agregação no banco.
- Um predicado é sargable quando a coluna indexada fica sozinha de um lado da comparação; funções e conversões implícitas aplicadas à coluna forçam uma varredura.
NULLsignifica desconhecido: comparações com ele resultam emUNKNOWN,NOT INcom uma subconsulta que contém NULL não retorna nada eCOUNT(col)o ignora.- Use
EXISTSpara saber "se existe alguma linha relacionada",JOINquando precisar das colunas relacionadas eUNION ALL, a menos que você realmente precise remover duplicatas. - Faça bind de todo valor; a concatenação é ao mesmo tempo um risco de injeção e um veneno para o cache de parsing.
- Pagine com keysets e uma ordem total, não com
OFFSETprofundo. - Agrupe as escritas em lotes dentro de transações explícitas e curtas, e indexe toda chave estrangeira.
Fontes e leituras complementares
Perguntas frequentes
- SELECT * é tão ruim assim?
- Num console, para uma consulta avulsa, tudo bem. No código da aplicação, ele cobra seu preço de três formas: mais bytes na rede e mais memória no driver, uma consulta que quebra silenciosamente quando uma coluna é adicionada ou reordenada (mapeamento posicional, INSERT ... SELECT) e a perda dos index-only scans, que exigem que todas as colunas referenciadas estejam no índice. Liste as colunas que você usa.
- Por que minha consulta não retorna nenhuma linha quando uso NOT IN com uma subconsulta?
- Porque a subconsulta retorna pelo menos um NULL. Na lógica de três valores, x NOT IN (1, 2, NULL) é avaliado como UNKNOWN para qualquer x, e UNKNOWN nunca passa por uma cláusula WHERE. Use NOT EXISTS, que não tem essa armadilha, ou adicione WHERE col IS NOT NULL à subconsulta.
- Devo paginar com OFFSET?
- OFFSET funciona para páginas pequenas e rasas, mas se degrada de forma linear: a página 500 de uma lista de 20 linhas obriga o banco a ler e descartar 10.000 linhas. A paginação keyset (WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20) lê apenas as linhas que retorna e continua estável quando linhas são inseridas entre uma requisição e outra.
- Stored procedures são boa prática ou antipadrão?
- Nenhum dos dois, por si só. São a ferramenta certa quando uma operação baseada em conjuntos precisa rodar perto dos dados (grandes atualizações em lote, agregações para relatórios) e a ferramenta errada quando escondem regras de negócio dos testes da aplicação e do controle de versão. Mantenha-as pequenas, guarde-as no controle de versão e trate-as como uma API do banco de dados.