Pular para o conteúdo
Fernando Valler

Como identificar consultas SQL que deixam uma aplicação lenta: do diagnóstico à otimização

Uma aplicação pode ter PHP bem estruturado, uma infraestrutura adequada e ainda assim apresentar lentidão por causa de algumas consultas SQL.

Como identificar consultas SQL que deixam uma aplicação lenta: do diagnóstico à otimização

Uma aplicação pode ter PHP bem estruturado, uma infraestrutura adequada e ainda assim apresentar lentidão por causa de algumas consultas SQL.

O problema é que, quando uma tela demora cinco segundos para carregar, é comum começar a procurar a causa no lugar errado: PHP, servidor, rede, Docker, cache ou até mesmo no banco como um todo.

Na prática, muitas vezes o problema está em uma consulta específica.

E existe uma diferença importante entre dizer que “o banco está lento” e descobrir que uma determinada consulta está consumindo recursos demais.

Neste artigo, vou mostrar uma abordagem prática para identificar essas consultas, entender por que elas estão lentas e validar se uma alteração realmente melhorou o desempenho.


O primeiro erro: otimizar sem medir

Antes de alterar índices, reescrever SQL ou aumentar recursos do servidor, precisamos responder a algumas perguntas:

  • Qual consulta está lenta?
  • Quanto tempo ela está levando?
  • Quantas vezes ela é executada?
  • Quantas linhas estão sendo examinadas?
  • Quantas linhas realmente retornam?
  • O banco está usando um índice?
  • Existe algum JOIN, SORT, GROUP BY ou filtro consumindo recursos?
  • O problema acontece sempre ou apenas em determinadas condições?

Sem essas informações, otimização vira tentativa e erro.

Uma consulta de 500 ms executada uma vez por minuto pode ser menos problemática do que uma consulta de 50 ms executada centenas de vezes por segundo.

Por isso, tempo individual e frequência precisam ser analisados juntos.


1. Primeiro descubra onde está a lentidão

Antes de culpar o SQL, descubra em qual camada o tempo está sendo gasto.

Uma requisição HTTP pode passar por várias etapas:

text
Browser
   ↓
Web Server
   ↓
PHP
   ↓
Application
   ↓
Database connection
   ↓
SQL
   ↓
Database execution
   ↓
PHP processing
   ↓
Response

Uma página que demora três segundos não significa necessariamente que a consulta SQL levou três segundos.

Por exemplo:

text
HTTP request:       3.20s
PHP execution:      3.00s
SQL queries:        0.20s

Nesse caso, procurar um índice no banco provavelmente não resolverá o problema.

Já neste cenário:

text
HTTP request:       3.20s
PHP execution:      3.15s
SQL queries:        2.95s

temos um forte indício de que precisamos investigar o banco.

Por isso, uma das primeiras melhorias que vale implementar é medir o tempo das operações críticas.


2. Medindo consultas no PHP

Durante uma investigação, podemos medir uma consulta diretamente:

php
$start = microtime(true);

$stmt = $pdo->prepare(
    'SELECT id, nome
     FROM clientes
     WHERE email = :email'
);

$stmt->execute([
    'email' => $email,
]);

$result = $stmt->fetch();

$elapsed = microtime(true) - $start;

error_log(sprintf(
    'SQL executado em %.4f segundos',
    $elapsed
));

Isso é útil para investigação, mas não deve ser encarado como substituto da observabilidade da aplicação.

Em um projeto real, é melhor registrar informações como:

text
route
controller
query
duration
database
rows
request_id

e correlacionar essas informações com logs ou ferramentas de observabilidade.

Também é importante evitar registrar dados sensíveis ou valores de parâmetros indiscriminadamente.


3. Procure as consultas lentas no próprio banco

Depois de confirmar que o banco está envolvido, a próxima pergunta é:

Qual consulta está causando o problema?

No MySQL, o Slow Query Log é uma das ferramentas mais úteis para esse tipo de investigação. Ele registra consultas que ultrapassam o limite configurado em long_query_time e pode mostrar, entre outras informações, tempo de execução, tempo de lock, linhas retornadas e linhas examinadas.

Por exemplo:

sql
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

Isso pode fazer sentido durante uma investigação controlada, mas a configuração deve ser planejada para o ambiente e para o volume da aplicação.

O MySQL permite ainda registrar consultas que não utilizam índices, porém essa configuração pode gerar uma quantidade grande de registros em ambientes movimentados. A própria documentação recomenda atenção ao volume, inclusive oferecendo mecanismos de limitação para esse tipo de log.

Depois de identificar as consultas, podemos começar a analisar o plano de execução.


4. No PostgreSQL, use pg_stat_statements

Em PostgreSQL, uma ferramenta especialmente útil é a extensão pg_stat_statements.

Ela mantém estatísticas de planejamento e execução das consultas realizadas pelo servidor, permitindo identificar consultas relevantes pelo comportamento acumulado, em vez de olhar apenas uma execução isolada.

Por exemplo:

sql
SELECT
    calls,
    total_exec_time,
    mean_exec_time,
    rows,
    query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Isso ajuda a encontrar um tipo de problema que costuma passar despercebido:

text
Consulta A
Tempo médio: 1.2s
Execuções: 10

Consulta B
Tempo médio: 30ms
Execuções: 100.000

A primeira consulta é individualmente muito mais lenta.

Mas a segunda pode estar consumindo muito mais tempo total do banco.

É por isso que não devemos olhar apenas para a consulta mais lenta. Frequência também importa.


5. O principal aliado: EXPLAIN

Encontrou uma consulta suspeita?

Agora precisamos descobrir como o banco pretende executá-la.

No MySQL:

sql
EXPLAIN
SELECT *
FROM pedidos
WHERE cliente_id = 123;

O EXPLAIN apresenta o plano de execução e permite entender como o banco pretende acessar as tabelas e utilizar os índices.

No PostgreSQL:

sql
EXPLAIN
SELECT *
FROM pedidos
WHERE cliente_id = 123;

O conceito é semelhante: o PostgreSQL apresenta o plano produzido pelo planner, incluindo operações como Seq Scan, Index Scan, joins, agregações e ordenações.

O objetivo não é decorar todas as opções do EXPLAIN.

O objetivo é aprender a perguntar:

O banco está fazendo exatamente o que eu esperava?


6. EXPLAIN mostra o plano. EXPLAIN ANALYZE mostra o que aconteceu

Essa diferença é extremamente importante.

No MySQL 8.4, EXPLAIN ANALYZE executa a consulta e acrescenta informações como custo estimado, quantidade estimada de linhas, tempo real e número de loops por iterador.

Exemplo:

sql
EXPLAIN ANALYZE
SELECT *
FROM pedidos
WHERE cliente_id = 123;

No PostgreSQL:

sql
EXPLAIN ANALYZE
SELECT *
FROM pedidos
WHERE cliente_id = 123;

O PostgreSQL também executa a consulta e mostra tempos e quantidade real de linhas por nó do plano.

Isso permite encontrar situações como:

text
Estimativa: 10 linhas
Real:       150.000 linhas

Uma diferença muito grande entre estimativa e realidade pode indicar que o planner está tomando decisões com informações estatísticas inadequadas ou que a distribuição dos dados é diferente do esperado.

No PostgreSQL, por exemplo, as estatísticas coletadas por ANALYZE são usadas pelo planner para construir os planos de execução.


7. Cuidado: EXPLAIN ANALYZE executa a consulta

Esse detalhe é importante principalmente para INSERT, UPDATE e DELETE.

Imagine:

sql
EXPLAIN ANALYZE
UPDATE contas
SET saldo = saldo + 100
WHERE id = 10;

A consulta será realmente executada.

No PostgreSQL, a própria documentação recomenda utilizar uma transação com ROLLBACK quando for necessário analisar uma operação que modifica dados sem manter os efeitos:

sql
BEGIN;

EXPLAIN ANALYZE
UPDATE contas
SET saldo = saldo + 100
WHERE id = 10;

ROLLBACK;

Em produção, esse tipo de teste exige ainda mais cuidado.


8. Um dos problemas mais comuns: ausência de índice

Imagine:

sql
SELECT id, nome, email
FROM clientes
WHERE email = '[email protected]';

Se email não estiver indexado e a tabela possuir milhões de registros, o banco pode precisar examinar uma quantidade enorme de linhas.

Uma possível solução:

sql
CREATE INDEX idx_clientes_email
ON clientes (email);

Depois:

sql
EXPLAIN
SELECT id, nome, email
FROM clientes
WHERE email = '[email protected]';

No MySQL, a própria documentação aponta os índices como uma das primeiras coisas a verificar quando uma consulta SELECT ... WHERE está lenta. Também recomenda evitar o excesso de índices e construir um conjunto que atenda às consultas reais da aplicação.

Mas existe uma armadilha:

Não significa que toda coluna usada em WHERE deve receber um índice.

Índices também ocupam espaço e precisam ser mantidos durante operações de escrita.

A pergunta correta é:

Este índice melhora uma consulta importante o suficiente para justificar seu custo?


9. Cuidado com funções nas colunas filtradas

Considere:

sql
SELECT *
FROM pedidos
WHERE YEAR(created_at) = 2026;

Apesar de parecer simples, estamos aplicando uma função sobre a coluna.

Uma abordagem mais amigável ao índice seria:

sql
SELECT *
FROM pedidos
WHERE created_at >= '2026-01-01'
  AND created_at < '2027-01-01';

A ideia geral é evitar transformar a coluna antes que o banco consiga utilizar eficientemente o índice disponível.

Esse princípio aparece em diversos formatos:

sql
WHERE DATE(created_at) = ...
sql
WHERE LOWER(email) = ...
sql
WHERE CAST(codigo AS CHAR) = ...

Não significa que funções são sempre ruins.

Significa que devemos verificar como elas afetam o plano de execução.


10. SELECT * também pode ser um problema

Veja esta consulta:

sql
SELECT *
FROM clientes
WHERE id = 10;

Se precisamos apenas de:

text
id
nome
email

podemos fazer:

sql
SELECT id, nome, email
FROM clientes
WHERE id = 10;

Isso reduz a quantidade de dados transferidos e pode facilitar algumas estratégias de acesso do banco.

Além disso, em uma tabela grande com várias colunas, retornar dados desnecessários aumenta o custo entre banco, PHP e aplicação.

Não é uma regra de que SELECT * automaticamente torna uma consulta lenta.

É uma questão de não buscar dados que não serão utilizados.


11. JOIN não é o vilão

É comum encontrar código como:

sql
SELECT *
FROM pedidos p
JOIN clientes c
    ON c.id = p.cliente_id;

e concluir que o JOIN é o problema.

Mas JOIN é uma operação fundamental em bancos relacionais.

A pergunta deve ser:

  • Qual algoritmo de join foi escolhido?
  • Quantas linhas existem em cada lado?
  • As colunas utilizadas no relacionamento estão indexadas quando necessário?
  • Existem filtros adequados?
  • Quantas linhas estão sendo produzidas antes da filtragem?

Uma consulta com vários JOINs pode ser extremamente rápida.

Uma consulta aparentemente simples pode ser extremamente lenta.

O plano de execução é que nos mostra o que está acontecendo.


12. Cuidado com o problema N+1

Esse é um dos problemas que mais aparecem em aplicações PHP com ORM.

Imagine que a aplicação busca 100 pedidos:

sql
SELECT *
FROM pedidos
LIMIT 100;

Depois, para cada pedido:

sql
SELECT *
FROM clientes
WHERE id = ?;

Podemos acabar com:

text
1 consulta para pedidos
+
100 consultas para clientes
=
101 consultas

Cada consulta individual pode parecer rápida.

O problema está no conjunto.

Em vez disso, dependendo do caso, podemos utilizar um JOIN, eager loading ou outra estratégia apropriada:

sql
SELECT
    p.id,
    p.valor,
    c.id AS cliente_id,
    c.nome AS cliente_nome
FROM pedidos p
JOIN clientes c
    ON c.id = p.cliente_id
LIMIT 100;

Esse tipo de problema é especialmente importante em aplicações que utilizam Doctrine, Eloquent ou outros ORMs.

A abstração do ORM facilita o desenvolvimento, mas não elimina o custo das consultas geradas.


13. Paginação com OFFSET pode piorar com o crescimento da tabela

Uma paginação comum:

sql
SELECT *
FROM pedidos
ORDER BY id DESC
LIMIT 50 OFFSET 500000;

parece perfeitamente razoável.

Mas conforme o OFFSET cresce, o banco precisa avançar por uma quantidade cada vez maior de registros antes de entregar os próximos 50.

Em tabelas grandes, uma estratégia baseada em cursor ou no último identificador processado pode ser mais adequada:

sql
SELECT *
FROM pedidos
WHERE id < 500000
ORDER BY id DESC
LIMIT 50;

A solução correta depende do padrão de navegação e dos índices existentes.

A ideia importante é:

Paginação também precisa ser analisada como parte do plano de consulta.


14. Muitas linhas examinadas para poucas linhas retornadas

Esse é um dos sinais que mais gosto de observar.

Imagine:

text
Rows examined: 1.000.000
Rows returned: 20

Isso merece investigação.

Naturalmente, nem toda consulta que examina muitas linhas está errada. Existem casos em que isso é esperado.

Mas quando o banco precisa examinar milhares ou milhões de registros para devolver poucos resultados, devemos perguntar:

text
Existe um índice adequado?
O filtro está sendo aplicado cedo?
A ordem das colunas do índice é adequada?
O JOIN está produzindo linhas demais?
As estatísticas estão atualizadas?

No MySQL, EXPLAIN e o slow query log podem ajudar justamente nesse diagnóstico.


15. Nem todo Seq Scan é um problema

No PostgreSQL, encontrar:

text
Seq Scan

não significa automaticamente que existe um problema.

Se uma tabela possui apenas algumas centenas de registros, percorrer a tabela inteira pode ser mais barato do que utilizar um índice.

O planner toma decisões com base em custos estimados e estatísticas disponíveis.

Por isso:

text
Seq Scan = problema

é uma simplificação perigosa.

A pergunta correta é:

Para o tamanho e a distribuição atual desses dados, esse plano é adequado?


16. Ordenação também pode ser cara

Considere:

sql
SELECT id, nome
FROM clientes
WHERE status = 'active'
ORDER BY created_at DESC;

Mesmo com um filtro eficiente, a ordenação pode gerar custo adicional.

Um índice adequado pode ajudar, dependendo do banco, da distribuição dos dados e da consulta:

sql
CREATE INDEX idx_clientes_status_created
ON clientes (status, created_at);

Mas não devemos criar esse índice automaticamente apenas porque WHERE e ORDER BY aparecem na consulta.

Precisamos validar com EXPLAIN antes e depois.


17. Estatísticas desatualizadas também podem prejudicar o plano

O banco precisa conhecer características dos dados para escolher um plano adequado.

No PostgreSQL, ANALYZE coleta estatísticas utilizadas pelo planner.

No MySQL, manter as estatísticas atualizadas também faz parte das recomendações de otimização.

Isso significa que, quando uma tabela sofre alterações significativas, também precisamos considerar o estado das estatísticas.

Às vezes o SQL não mudou.

O índice não mudou.

Mas a distribuição dos dados mudou bastante.

E o plano escolhido pode deixar de ser o mais adequado.


18. Locks também podem parecer uma consulta lenta

Imagine:

text
Consulta A → 20 ms
Consulta B → 25 ms
Consulta C → 10 ms

Todas são rápidas.

Mas uma delas está esperando um lock por cinco segundos.

No ponto de vista da aplicação:

text
SQL demorou 5 segundos

Mas o problema não está necessariamente no processamento da consulta.

Está na concorrência entre transações.

Por isso, uma investigação de performance precisa considerar:

text
CPU
I/O
locks
conexões
tempo de espera
execução do SQL
volume de dados

e não apenas o texto da consulta.


19. Faça uma alteração por vez

Um erro comum durante uma otimização é alterar tudo:

text
novo índice
+
SQL reescrito
+
cache
+
configuração do banco
+
alteração no PHP

Depois:

text
ficou mais rápido!

Mas não sabemos o motivo.

Uma abordagem melhor é:

text
1. Medir
2. Identificar
3. Alterar
4. Medir novamente
5. Comparar

Por exemplo:

text
Antes
Query: 1.82s
Rows examined: 850.000

Alteração
Índice em (status, created_at)

Depois
Query: 0.07s
Rows examined: 320

Agora temos evidência de que a alteração realmente teve impacto.


20. Não compare apenas uma execução

Imagine:

text
Execução 1: 80ms
Execução 2: 20ms
Execução 3: 18ms
Execução 4: 22ms

Isso pode acontecer por diversos motivos, incluindo cache, I/O e carga momentânea.

Por isso, performance deve ser analisada com repetibilidade.

Sempre que possível, compare:

text
tempo médio
percentis
número de execuções
linhas examinadas
linhas retornadas
plano de execução
uso de índices

E faça os testes com dados representativos do ambiente real.

A documentação do PostgreSQL chama atenção para o fato de que resultados obtidos em tabelas pequenas não devem ser automaticamente extrapolados para tabelas muito maiores, porque o planner pode escolher planos diferentes conforme o tamanho dos dados.


21. Um exemplo completo de investigação

Imagine que uma tela de pedidos está demorando aproximadamente três segundos.

Encontramos a consulta:

sql
SELECT *
FROM pedidos
WHERE cliente_id = 123
  AND status = 'paid'
ORDER BY created_at DESC;

Primeiro:

sql
EXPLAIN
SELECT *
FROM pedidos
WHERE cliente_id = 123
  AND status = 'paid'
ORDER BY created_at DESC;

Percebemos que a consulta está examinando uma quantidade enorme de registros.

Então verificamos os índices:

sql
SHOW INDEX FROM pedidos;

No PostgreSQL, poderíamos consultar os índices da tabela ou analisar diretamente o plano com EXPLAIN.

Criamos um índice que faça sentido para o padrão de acesso:

sql
CREATE INDEX idx_pedidos_cliente_status_created
ON pedidos (cliente_id, status, created_at);

Depois executamos novamente:

sql
EXPLAIN
SELECT *
FROM pedidos
WHERE cliente_id = 123
  AND status = 'paid'
ORDER BY created_at DESC;

E, em um ambiente apropriado, validamos também com EXPLAIN ANALYZE.

O objetivo não é simplesmente descobrir que “agora apareceu um índice”.

Queremos verificar:

text
O número de linhas examinadas caiu?
A ordenação ficou mais barata?
O tempo caiu?
O plano continua adequado com uma quantidade maior de dados?

Essa é a diferença entre adicionar um índice e otimizar uma consulta.


22. Uma consulta lenta não significa necessariamente SQL ruim

Esse ponto é importante.

Uma consulta pode estar lenta porque:

text
A tabela cresceu muito

ou:

text
O volume de dados retornado é enorme

ou:

text
Existe concorrência

ou:

text
Existe um lock

ou:

text
O servidor está sob pressão de I/O

ou:

text
As estatísticas estão inadequadas

ou:

text
A aplicação executa a mesma consulta milhares de vezes

ou ainda:

text
O banco é rápido, mas o PHP está processando muitos dados depois da consulta.

Por isso, performance exige diagnóstico.


23. Meu checklist para investigar uma consulta SQL lenta

Quando encontro uma consulta problemática, normalmente sigo esta sequência:

text
[ ] Confirmar que o banco realmente é o gargalo

[ ] Medir o tempo da consulta

[ ] Descobrir quantas vezes ela é executada

[ ] Verificar quantidade de linhas retornadas

[ ] Verificar quantidade de linhas examinadas

[ ] Executar EXPLAIN

[ ] Executar EXPLAIN ANALYZE quando apropriado

[ ] Verificar índices utilizados

[ ] Verificar filtros e JOINs

[ ] Procurar funções sobre colunas filtradas

[ ] Verificar ORDER BY / GROUP BY

[ ] Procurar SELECT *

[ ] Verificar paginação

[ ] Procurar N+1

[ ] Verificar locks e concorrência

[ ] Verificar estatísticas

[ ] Fazer uma alteração por vez

[ ] Medir novamente

[ ] Validar com dados representativos

Conclusão

A maioria dos problemas de performance em SQL não é resolvida simplesmente “colocando um índice”.

O processo mais confiável é:

text
Medir
  ↓
Encontrar a consulta
  ↓
Analisar o plano
  ↓
Entender o gargalo
  ↓
Alterar
  ↓
Medir novamente

Ferramentas como Slow Query Log, EXPLAIN, EXPLAIN ANALYZE e, no PostgreSQL, pg_stat_statements, tornam esse processo muito mais objetivo.

O ponto principal é não confundir sintoma com causa.

Uma página lenta não significa necessariamente que o PHP está lento.

Um banco com CPU alta não significa necessariamente que todas as consultas são ruins.

E uma consulta com Seq Scan não significa automaticamente que está errada.

O melhor caminho é transformar a suspeita em evidência.

Performance de banco não deveria ser um exercício de adivinhação. Deveria ser um processo de medição, análise e validação.