Performance em SQL, no contexto de análise de dados, é a capacidade de responder perguntas com rapidez e previsibilidade, consumindo o mínimo possível de recursos (CPU, memória, disco e tempo de execução). Mesmo quando você não administra o banco, suas consultas competem com outras rotinas (ETL, APIs, relatórios) e podem ficar lentas ou até causar bloqueios. Este capítulo foca em noções práticas: como filtros afetam custo, como índices ajudam (e quando não ajudam), como ler planos de execução e por que evitar SELECT *.
O que torna uma consulta lenta (visão prática)
Em geral, uma consulta fica lenta por um ou mais motivos:
- Leitura excessiva de dados: varrer uma tabela grande inteira quando só precisa de uma fração.
- Filtros pouco seletivos ou aplicados tarde: filtrar depois de juntar/agregar aumenta o volume intermediário.
- Operações que impedem uso de índice: funções na coluna, conversões implícitas, padrões com
%no início. - Ordenações e agregações grandes:
ORDER BY,GROUP BY,DISTINCTpodem exigir ordenar ou criar estruturas em memória/disco. - Junções caras: juntar tabelas grandes sem bons predicados ou com chaves não indexadas.
- Retornar colunas demais: aumenta I/O e rede; também pode impedir “cobertura” por índice.
Uma regra mental útil: custo ≈ (quantidade de linhas lidas) × (custo por linha) + (custo de ordenar/agregar/juntar). A maior alavanca costuma ser reduzir linhas lidas e reduzir colunas retornadas.
Filtros: seletividade, sargabilidade e “filtrar cedo”
Seletividade: o quanto o filtro reduz o conjunto
Um filtro seletivo reduz muito o número de linhas. Exemplo: filtrar por order_id (único) costuma ser altamente seletivo; filtrar por status = 'PAID' pode ser pouco seletivo se a maioria está paga. Índices tendem a ajudar mais quando o filtro é seletivo.
Sargabilidade: filtros que permitem uso eficiente de índice
Um predicado “sargável” (search-argument-able) é aquele que o otimizador consegue transformar em busca eficiente no índice. Alguns padrões comuns que prejudicam:
Continue em nosso aplicativo
Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.
ou continue lendo abaixo...Baixar o aplicativo
- Função na coluna:
WHERE DATE(created_at) = '2025-01-01'(tende a impedir uso de índice emcreated_at). - Conversão implícita: comparar coluna numérica com texto pode forçar conversão e varredura.
- LIKE com coringa no início:
LIKE '%gmail.com'geralmente não usa índice B-tree.
Prefira reescrever para intervalos e comparações diretas na coluna:
-- Evite (função na coluna pode impedir índice em created_at) SELECT COUNT(*) FROM orders WHERE DATE(created_at) = DATE '2025-01-01'; -- Prefira (intervalo sargável) SELECT COUNT(*) FROM orders WHERE created_at >= TIMESTAMP '2025-01-01 00:00:00' AND created_at < TIMESTAMP '2025-01-02 00:00:00';Filtrar cedo: reduza o volume antes de operações caras
Em consultas analíticas, é comum juntar e só depois filtrar. Em muitos casos, filtrar antes reduz drasticamente o custo. A ideia é aplicar restrições o quanto antes, especialmente em tabelas grandes (fatos).
-- Exemplo: reduzir o conjunto de pedidos antes de juntar com itens WITH filtered_orders AS ( SELECT order_id, customer_id, created_at FROM orders WHERE created_at >= TIMESTAMP '2025-01-01 00:00:00' AND created_at < TIMESTAMP '2025-02-01 00:00:00' AND status = 'PAID' ) SELECT fo.customer_id, COUNT(DISTINCT fo.order_id) AS paid_orders FROM filtered_orders fo JOIN order_items oi ON oi.order_id = fo.order_id GROUP BY fo.customer_id;Mesmo que o otimizador consiga “empurrar” filtros automaticamente, escrever de forma explícita ajuda a evitar ambiguidades e facilita a leitura do plano.
Índices: o que são e como impactam consultas
Um índice é uma estrutura auxiliar (geralmente B-tree) que permite localizar linhas sem varrer a tabela inteira. Pense nele como um “mapa” ordenado por uma ou mais colunas. Em análise do dia a dia, você não precisa decorar detalhes internos, mas precisa entender três efeitos práticos:
- Acelera filtros em colunas indexadas (especialmente seletivos).
- Acelera junções quando as colunas de junção estão indexadas (principalmente do lado “procurado”).
- Pode acelerar ordenações se a ordem do índice coincide com o
ORDER BY.
Quando um índice pode não ajudar
- Filtro pouco seletivo: se 90% das linhas atendem ao filtro, pode ser mais barato varrer a tabela.
- Funções/conversões na coluna: como visto, podem impedir o uso.
- Retornar muitas colunas: mesmo encontrando as linhas via índice, o banco pode precisar buscar a linha inteira na tabela (lookup), o que pode ser caro.
- Consultas que agregam quase tudo: se você precisa ler a maior parte da tabela para somar/contar, o índice pode ter pouco efeito.
Índices compostos: ordem das colunas importa
Índices com múltiplas colunas funcionam melhor quando o filtro usa um “prefixo” da ordem do índice. Exemplo: índice em (created_at, status) ajuda bem em filtros por created_at e por created_at + status, mas pode não ajudar tanto em status sozinho (depende do banco e do plano).
Como analista, você pode usar essa noção para conversar com o time de dados/DBA: “minhas consultas mais frequentes filtram por período e status; faz sentido um índice composto começando por data?”.
Índices e colunas de junção
Em junções, é comum que o banco escolha entre estratégias como nested loop, hash join ou merge join. Um índice na coluna de junção pode tornar nested loop viável (buscar rapidamente correspondências). Sem índice, o banco pode preferir hash join (construindo uma tabela hash em memória) ou até varrer mais dados.
Se você percebe que uma junção entre uma tabela grande e outra média está lenta, uma pergunta prática é: “a coluna usada no ON está indexada em pelo menos um lado, especialmente no lado onde o banco precisa procurar?”
Planos de execução: como ler o essencial
O plano de execução é a explicação de como o banco pretende executar sua consulta (estimado) ou como executou (real). A forma de obter varia por banco, mas os conceitos são parecidos. Procure por comandos como EXPLAIN ou EXPLAIN ANALYZE (quando disponível) para ver:
- Tipo de leitura: varredura completa (table/seq scan) vs. busca por índice (index seek/scan).
- Ordem das operações: onde filtra, onde junta, onde agrega.
- Estimativa vs. realidade (no plano real): linhas estimadas e linhas retornadas.
- Pontos caros: operações com alto custo, tempo ou muitas linhas processadas.
Passo a passo para diagnosticar performance com plano
Use este roteiro sempre que uma query “parece simples” mas está lenta:
1) Confirme o objetivo e reduza o escopo
Antes de otimizar, garanta que a consulta está respondendo a pergunta certa. Em seguida, rode uma versão mínima: limite o período, reduza colunas, remova ordenações temporariamente. Isso ajuda a identificar o componente caro.
-- Versão mínima para medir leitura e filtros SELECT COUNT(*) FROM orders WHERE created_at >= TIMESTAMP '2025-01-01 00:00:00' AND created_at < TIMESTAMP '2025-02-01 00:00:00';2) Rode o plano e identifique o “maior consumidor”
Procure no plano o operador que mais lê linhas ou mais tempo consome. Frequentemente será uma varredura grande, uma ordenação (sort) ou uma agregação pesada.
3) Verifique se o filtro está sargável
Se o plano mostra varredura completa apesar de existir índice, revise o WHERE em busca de funções na coluna, conversões e padrões de LIKE.
4) Cheque cardinalidade estimada vs. real
Quando o plano real mostra grande diferença entre linhas estimadas e reais, o otimizador pode escolher uma estratégia ruim (por exemplo, nested loop quando deveria ser hash join). Isso pode indicar estatísticas desatualizadas ou filtros complexos. Como analista, você pode:
- Reescrever filtros para ficarem mais diretos.
- Evitar expressões muito complexas no predicado.
- Solicitar atualização de estatísticas ao time responsável (quando aplicável).
5) Avalie junções e ordenações
Se a parte cara é uma junção, verifique:
- Se o predicado de junção está correto e restritivo.
- Se há filtros aplicados antes da junção para reduzir linhas.
- Se as colunas de junção têm índices adequados.
Se a parte cara é um SORT, pergunte:
- Você precisa mesmo ordenar tudo ou só um top N?
- O
ORDER BYpoderia ser feito depois de reduzir o conjunto? - Existe índice que já entrega na ordem desejada?
Como evitar SELECT * (e por que isso melhora performance)
SELECT * parece conveniente, mas tem custos reais:
- Mais I/O e rede: você lê e transfere colunas que não usa.
- Mais CPU: o banco precisa materializar mais dados.
- Menos chance de usar índice “cobrindo”: se você seleciona só colunas que estão no índice, o banco pode evitar buscar a linha completa na tabela (dependendo do banco).
- Fragilidade: se alguém adiciona uma coluna grande (ex.: JSON, texto longo), sua query passa a ficar mais lenta sem você mudar nada.
- Ambiguidade em joins: em consultas com múltiplas tabelas,
*pode trazer colunas duplicadas e confusas.
Prática recomendada: selecione apenas o necessário
-- Evite SELECT * FROM customers WHERE customer_id = 123; -- Prefira SELECT customer_id, full_name, email, created_at FROM customers WHERE customer_id = 123;Passo a passo para substituir SELECT * com segurança
1) Liste as colunas realmente usadas
Verifique quais colunas aparecem em:
- Filtros (
WHERE) - Junções (
ON) - Agrupamentos (
GROUP BY) - Ordenações (
ORDER BY) - Cálculos e expressões no
SELECT
2) Selecione apenas colunas de saída (e, se necessário, chaves técnicas)
Se você precisa de uma coluna apenas para juntar e não para exibir, avalie se ela precisa estar no SELECT. Muitas vezes não precisa.
-- Você pode usar a chave para JOIN sem retorná-la SELECT c.customer_id, c.full_name, SUM(oi.quantity * oi.unit_price) AS revenue FROM customers c JOIN orders o ON o.customer_id = c.customer_id JOIN order_items oi ON oi.order_id = o.order_id WHERE o.created_at >= TIMESTAMP '2025-01-01 00:00:00' AND o.created_at < TIMESTAMP '2025-02-01 00:00:00' GROUP BY c.customer_id, c.full_name;3) Atenção a colunas grandes
Campos como descrições longas, blobs, JSON e logs podem ser muito pesados. Se você precisa deles, traga apenas quando necessário e, se possível, em uma consulta separada para poucos registros (por exemplo, após identificar IDs relevantes).
-- Primeiro: encontre os IDs relevantes (leve) SELECT o.order_id FROM orders o WHERE o.created_at >= TIMESTAMP '2025-01-01 00:00:00' AND o.created_at < TIMESTAMP '2025-02-01 00:00:00' AND o.status = 'CHARGEBACK' ORDER BY o.created_at DESC LIMIT 50; -- Depois: busque detalhes pesados só para esses IDs (quando necessário) SELECT order_id, chargeback_reason, raw_payload_json FROM orders WHERE order_id IN (/* lista dos 50 IDs */);Ordenação, DISTINCT e paginação: custos escondidos
ORDER BY: ordenar tudo é caro
ORDER BY pode exigir ordenar um grande volume de linhas. Se você precisa apenas dos “primeiros N”, combine com LIMIT (ou equivalente) e garanta que o filtro reduza o conjunto antes da ordenação.
-- Top N com filtro antes SELECT order_id, customer_id, total_amount, created_at FROM orders WHERE created_at >= TIMESTAMP '2025-01-01 00:00:00' AND created_at < TIMESTAMP '2025-02-01 00:00:00' ORDER BY total_amount DESC LIMIT 100;Se houver índice que suporte a ordenação (por exemplo, em total_amount ou em combinação com o filtro), o banco pode evitar um sort completo. Caso contrário, ele pode ordenar em memória e, se não couber, “derramar” para disco (muito mais lento).
DISTINCT: remédio que pode sair caro
DISTINCT força o banco a eliminar duplicidades, frequentemente com sort ou hash. Se você está usando DISTINCT para “consertar” duplicidade causada por join, é melhor atacar a causa (chave correta, granularidade correta) do que pagar o custo toda vez. Use DISTINCT quando a pergunta realmente exige unicidade e você entende a origem das duplicidades.
Paginação: OFFSET grande tende a degradar
Paginar com OFFSET alto (ex.: página 1000) pode ser caro porque o banco precisa percorrer e descartar muitas linhas. Para extrações e relatórios, prefira paginação por chave (keyset pagination) quando aplicável, usando um marcador (por exemplo, created_at + order_id) para buscar o “próximo bloco”.
-- Exemplo conceitual de paginação por chave SELECT order_id, created_at, total_amount FROM orders WHERE (created_at, order_id) < (TIMESTAMP '2025-01-31 10:00:00', 987654) ORDER BY created_at DESC, order_id DESC LIMIT 100;Checklist prático de performance para consultas do dia a dia
- Evite SELECT *: traga só colunas necessárias, especialmente em tabelas largas.
- Filtre cedo: reduza linhas antes de joins, agregações e ordenações.
- Escreva filtros sargáveis: evite função na coluna; prefira intervalos e comparações diretas.
- Cuidado com LIKE: padrões com
%no início tendem a não usar índice B-tree. - Desconfie de ORDER BY sem LIMIT em tabelas grandes.
- Use EXPLAIN/EXPLAIN ANALYZE: identifique varreduras grandes, sorts e discrepâncias de cardinalidade.
- Junções: confirme colunas de junção, filtros antes do join e possibilidade de índices.
- Teste incrementalmente: comece com contagens e amostras pequenas, depois adicione complexidade.
Mini laboratório: otimizando uma consulta típica (passo a passo)
Imagine a necessidade de listar pedidos pagos do último mês com informações do cliente e valor total, para exportar a um relatório. Uma versão ingênua pode ser lenta por trazer colunas demais e filtrar de forma pouco eficiente.
Passo 1: versão ingênua (problemas comuns)
SELECT * FROM orders o JOIN customers c ON c.customer_id = o.customer_id WHERE DATE(o.created_at) >= DATE '2025-01-01' AND DATE(o.created_at) <= DATE '2025-01-31' AND o.status = 'PAID' ORDER BY o.created_at DESC;Problemas: SELECT * (colunas demais), função DATE() na coluna (pode impedir índice), intervalo com <= no fim do dia (pode ser confuso), ordenação possivelmente grande.
Passo 2: tornar o filtro sargável e correto por intervalo
SELECT * FROM orders o JOIN customers c ON c.customer_id = o.customer_id WHERE o.created_at >= TIMESTAMP '2025-01-01 00:00:00' AND o.created_at < TIMESTAMP '2025-02-01 00:00:00' AND o.status = 'PAID' ORDER BY o.created_at DESC;Melhora: o filtro por data vira um intervalo direto na coluna.
Passo 3: remover SELECT * e trazer apenas o necessário
SELECT o.order_id, o.created_at, o.total_amount, o.status, c.customer_id, c.full_name, c.email FROM orders o JOIN customers c ON c.customer_id = o.customer_id WHERE o.created_at >= TIMESTAMP '2025-01-01 00:00:00' AND o.created_at < TIMESTAMP '2025-02-01 00:00:00' AND o.status = 'PAID' ORDER BY o.created_at DESC;Melhora: menos dados trafegando e menor custo de materialização.
Passo 4: reduzir antes de juntar (quando fizer sentido)
Se orders é muito grande e customers também, filtrar pedidos primeiro pode diminuir o custo da junção. Em muitos bancos isso é otimizado automaticamente, mas estruturar ajuda a leitura e pode evitar surpresas.
WITH paid_orders AS ( SELECT order_id, customer_id, created_at, total_amount, status FROM orders WHERE created_at >= TIMESTAMP '2025-01-01 00:00:00' AND created_at < TIMESTAMP '2025-02-01 00:00:00' AND status = 'PAID' ) SELECT po.order_id, po.created_at, po.total_amount, po.status, c.customer_id, c.full_name, c.email FROM paid_orders po JOIN customers c ON c.customer_id = po.customer_id ORDER BY po.created_at DESC;Passo 5: validar no plano
Agora rode o plano e verifique:
- O filtro por
created_atestatusestá sendo aplicado antes da junção? - Há varredura completa em
ordersou uso de índice? - O custo maior está no sort do
ORDER BY? Se sim, você precisa mesmo dessa ordenação para exportação, ou pode ordenar no consumidor (BI/planilha) após extrair um conjunto menor?