BANCO DE DADOS · MYSQL · PERFORMANCE

    Erros que Todo Desenvolvedor Comete ao Escrever Queries MySQL — Guia de Performance e Economia de Recursos 2026.

    Cada query mal escrita é uma nave inteira mandada buscar um copo de café. Funciona — só custa caro demais para o que entrega.

    HTI Tecnologia · Equipe TécnicaPublicado em julho de 202613–15 min de leitura

    Diário de bordo, DBA sênior, estardalhaço nº 52.

    Nenhuma query nasce lenta por acidente cósmico. Ela nasce lenta porque alguém — sob prazo, sob pressão, ou só sem saber que existia outro jeito — escreveu exatamente o comando que o MySQL executaria da forma mais cara possível. E o pior: ela costuma funcionar perfeitamente bem em desenvolvimento, com 200 linhas na tabela, e só declara guerra quando a produção passa de alguns milhões de registros e concorrência real.

    Isso não é sobre sintaxe errada — SQL errado não roda. É sobre SQL sintaticamente correto e semanticamente ingênuo: query que devolve o resultado certo, mas pede para o banco fazer trabalho de mais para chegar lá. Multiplique isso por centenas de milhares de execuções por dia, e você tem exatamente o padrão que mais aparece nos Health Checks de MySQL que a HTI conduz: não é um bug, é uma frota inteira de pequenas ineficiências voando em formação, cada uma gastando um pouco mais de combustível do que deveria.

    Este guia reúne os erros de escrita de query que mais custam IOPS, CPU e memória em produção — com o porquê técnico de cada um, o exemplo antes/depois, e como identificar o padrão no seu próprio código antes que ele vire incidente.

    01

    SELECT * — MANDAR A NAVE INTEIRA PARA TRAZER UM COPO DE CAFÉ

    Pedir todas as colunas quando a aplicação só usa três é o erro mais comum e mais barato de corrigir. O custo não é só estético:

    • Mais bytes trafegados entre banco e aplicação — em rede, em serialização, em memória do lado do cliente.
    • Impede o uso de índice de cobertura (covering index): se a query pedisse só as colunas do índice, o MySQL responderia lendo apenas a B-tree do índice, sem tocar a tabela. Com SELECT *, ele é forçado a fazer o lookup completo na tabela para toda linha.
    • Quebra silenciosamente quando alguém adiciona uma coluna TEXT ou BLOB grande à tabela no futuro — a query que já existia fica lenta sem que ninguém a tenha tocado.
    -- ❌ antes: traz tudo, inclusive colunas que a aplicação nunca lê
    SELECT * FROM pedidos WHERE cliente_id = 1234;
    -- ✅ depois: pede só o necessário, viabiliza índice de cobertura
    SELECT id, status, total, criado_em
    FROM pedidos
    WHERE cliente_id = 1234;

    02

    FUNÇÃO EM CIMA DA COLUNA DO WHERE — CEGANDO O PRÓPRIO TRICORDER

    Envolver a coluna indexada numa função no WHERE impede o MySQL de usar o índice, porque ele precisaria calcular a função para cada linha da tabela antes de comparar — o que é, na prática, um full table scan disfarçado de query otimizada.

    -- ❌ antes: MySQL não pode usar índice em criado_em, calcula DATE() linha a linha
    SELECT id FROM pedidos
    WHERE DATE(criado_em) = '2026-07-01';
    -- ✅ depois: mesma resposta, índice em criado_em funciona normalmente
    SELECT id FROM pedidos
    WHERE criado_em >= '2026-07-01 00:00:00'
      AND criado_em <  '2026-07-02 00:00:00';

    O mesmo problema aparece com UPPER(coluna) = ..., coluna + 1 = ..., CONCAT(coluna, '') = ... — qualquer transformação do lado esquerdo da comparação. Desde o MySQL 8.0.13 é possível criar functional indexes para casos onde a transformação é realmente necessária, mas a primeira pergunta deve sempre ser: dá para reescrever a condição sem função?

    03

    CONVERSÃO IMPLÍCITA DE TIPO E COLLATION — FALANDO O IDIOMA ERRADO COM O ÍNDICE

    Comparar uma coluna VARCHAR indexada com um número, ou comparar colunas com COLLATION diferentes, força o MySQL a converter tipos silenciosamente antes de comparar — e essa conversão, de novo, impede o uso direto do índice.

    -- ❌ antes: cpf é VARCHAR, mas a aplicação manda um inteiro
    SELECT id FROM clientes WHERE cpf = 12345678900;
    -- ✅ depois: tipo compatível com a coluna, índice usado normalmente
    SELECT id FROM clientes WHERE cpf = '12345678900';

    Em JOINs, o mesmo problema aparece quando colunas com COLLATION diferentes (utf8mb4_general_ci de um lado, utf8mb4_0900_ai_ci do outro — comum em bancos migrados entre versões) são comparadas: o MySQL faz a conversão nos bastidores e o JOIN deixa de usar índice, sem nenhum erro visível, só lentidão.

    04

    LIKE '%TERMO%' — VASCULHANDO O QUADRANTE INTEIRO ÀS CEGAS

    Wildcard no início da string (%termo) impede completamente o uso de índice B-tree, porque a estrutura é ordenada pelo início da string — buscar por um sufixo é, para o índice, equivalente a não ter índice nenhum.

    -- ❌ antes: full table scan garantido, índice em nome é inútil aqui
    SELECT id FROM clientes WHERE nome LIKE '%silva%';
    -- ✅ depois, quando a busca é só por prefixo: usa índice normalmente
    SELECT id FROM clientes WHERE nome LIKE 'silva%';
    -- ✅ depois, quando a busca precisa ser por qualquer trecho: full-text index
    SELECT id FROM clientes
    WHERE MATCH(nome) AGAINST('silva' IN NATURAL LANGUAGE MODE);

    Busca textual livre de verdade (qualquer posição, com relevância) é trabalho para full-text index ou, em escala maior, para um motor de busca dedicado (Elasticsearch, Meilisearch) — não para LIKE com wildcard nas duas pontas.

    05

    N+1 — UMA NAVE PARA CADA TRIPULANTE, QUANDO UMA BASTAVA

    Esse é o erro mais caro em aplicações modernas com ORM — e o mais invisível, porque nenhuma query individual parece lenta. O padrão: buscar uma lista (1 query), depois, para cada item da lista, disparar uma nova query para buscar dado relacionado. Cem pedidos na tela viram cento e uma queries.

    -- ❌ antes: 1 query para pedidos + N queries, uma por pedido, para buscar o cliente
    SELECT id, cliente_id FROM pedidos WHERE status = 'pendente';
    -- para cada linha do resultado acima, o ORM dispara:
    SELECT * FROM clientes WHERE id = ?;
    -- ✅ depois: uma única query com JOIN resolve tudo de uma vez
    SELECT p.id, c.nome, c.email
    FROM pedidos p
    JOIN clientes c ON c.id = p.cliente_id
    WHERE p.status = 'pendente';

    O sintoma clássico em produção: CPU e conexões do banco disparando sem nenhuma query individual aparecer no slow query log — porque cada uma, isolada, é rápida. É a soma de mil naves pequenas decolando ao mesmo tempo que derruba o hangar, não uma nave lenta.

    DIAGNÓSTICO COMPLETO

    🔍 MySQL Health Check.

    Auditoria completa de queries, índices, performance e concorrência — entregue em até 5 dias úteis com plano de ação priorizado por consumo real de recurso.

    Solicitar Health Check →

    06

    PAGINAÇÃO COM OFFSET ALTO — PERCORRER A GALÁXIA INTEIRA ATÉ O PLANETA 10.000

    LIMIT 20 OFFSET 100000 parece inofensivo, mas o MySQL precisa ler e descartar as cem mil linhas anteriores antes de devolver as vinte que interessam. Em página 1, é rápido. Em página 5.000 de um catálogo grande, é uma varredura cara disfarçada de paginação simples.

    -- ❌ antes: MySQL lê e descarta 100.000 linhas antes de responder
    SELECT id, nome FROM produtos
    ORDER BY id
    LIMIT 20 OFFSET 100000;
    -- ✅ depois: paginação por keyset (seek), sempre O(1) independente da página
    SELECT id, nome FROM produtos
    WHERE id > 458392   -- último id visto na página anterior
    ORDER BY id
    LIMIT 20;

    Keyset pagination (também chamada de "seek method") troca "pular N linhas" por "continuar a partir de onde parei" — o custo deixa de crescer com o número da página.

    07

    SUBQUERY CORRELACIONADA — CHAMANDO A PONTE A CADA LINHA DA TRIPULAÇÃO

    Uma subquery correlacionada é executada uma vez para cada linha da query externa — o equivalente a abrir um canal de comunicação novo com a ponte para cada tripulante, em vez de fazer uma única chamada geral.

    -- ❌ antes: subquery roda uma vez por linha de pedidos
    SELECT p.id, p.total,
      (SELECT nome FROM clientes c WHERE c.id = p.cliente_id) AS cliente
    FROM pedidos p
    WHERE p.status = 'pendente';
    -- ✅ depois: JOIN resolve em uma única passada
    SELECT p.id, p.total, c.nome AS cliente
    FROM pedidos p
    JOIN clientes c ON c.id = p.cliente_id
    WHERE p.status = 'pendente';

    O otimizador do MySQL 8.0+ consegue reescrever alguns casos simples de subquery correlacionada como JOIN internamente (semi-join optimization), mas não é garantido para todos os padrões — a reescrita explícita continua sendo a aposta mais segura. Padrão que detalhamos junto com índices em Índices Demais no MySQL.

    08

    TRANSAÇÃO LONGA SEGURANDO LOCK — MOTOR DE DOBRA LIGADO, NAVE PARADA

    Abrir uma transação, fazer uma chamada de rede, processar um arquivo, esperar resposta de uma API externa, e só então dar COMMIT — tudo dentro da mesma transação — mantém locks do InnoDB abertos por todo esse tempo. Outras transações que dependem das mesmas linhas ficam na fila, mesmo sem nenhuma delas estar "lenta" isoladamente.

    -- ❌ antes: transação aberta durante uma chamada externa lenta
    START TRANSACTION;
    UPDATE estoque SET reservado = reservado + 1 WHERE produto_id = 42;
    -- ...chamada de API de pagamento aqui, pode levar segundos...
    COMMIT;
    -- ✅ depois: a transação de banco só cobre o que é, de fato, transacional
    -- 1) reserva otimista e curta
    START TRANSACTION;
    UPDATE estoque SET reservado = reservado + 1 WHERE produto_id = 42;
    COMMIT;
    -- 2) chamada externa acontece fora de qualquer transação de banco
    -- 3) confirma ou desfaz a reserva com uma segunda transação curta, também rápida

    Regra prática: nenhuma chamada de rede, fila, e-mail ou processamento pesado deve acontecer dentro de uma transação de banco aberta. Transação é para operações atômicas de dado — não para orquestração de processo. O mesmo cuidado vale sob replicação e alta disponibilidade, como discutimos em Consultoria MySQL Alta Disponibilidade.

    CONSULTORIA MYSQL

    Query lenta que ninguém quer tocar?

    Reescrita de queries, plano de execução, revisão de transações e concorrência — com quem já viu esse mesmo padrão em mais de 1.000 servidores em produção.

    Falar com um especialista →

    09

    INSERT/UPDATE LINHA A LINHA EM LOOP — TELETRANSPORTANDO UM ÁTOMO DE CADA VEZ

    Inserir ou atualizar mil linhas com mil comandos separados, cada um com seu próprio round-trip de rede e sua própria transação implícita, multiplica overhead de rede, de commit e de log por mil — quando o mesmo resultado cabe numa única operação em lote.

    -- ❌ antes: 1.000 INSERTs, 1.000 round-trips, 1.000 transações implícitas
    INSERT INTO log_eventos (usuario_id, evento) VALUES (1, 'login');
    INSERT INTO log_eventos (usuario_id, evento) VALUES (2, 'login');
    -- ... repetido 998 vezes
    -- ✅ depois: um único INSERT em lote
    INSERT INTO log_eventos (usuario_id, evento) VALUES
      (1, 'login'), (2, 'login'), /* ... */ (1000, 'login');

    Para volumes muito grandes (dezenas de milhares de linhas), quebre em lotes de 500–1.000 registros por INSERT — lotes gigantescos demais também pressionam o redo log e o buffer pool de uma vez só, trocando um problema pelo outro. Ajuste fino desses parâmetros está em Performance Tuning MySQL 8.4.

    10

    SUBIR PARA PRODUÇÃO SEM EXPLAIN ANALYZE — VOAR SEM TRICORDER

    O erro que engloba todos os anteriores: escrever a query, testar com poucos dados locais, ver que "funciona", e considerar terminado. EXPLAIN ANALYZE (disponível desde o MySQL 8.0.18) não só mostra o plano de execução — ele executa a query de verdade e mostra tempo real gasto em cada etapa: quantas linhas foram examinadas, se houve filesort, se houve tabela temporária, se um índice esperado foi realmente usado.

    EXPLAIN ANALYZE
    SELECT p.id, c.nome
    FROM pedidos p
    JOIN clientes c ON c.id = p.cliente_id
    WHERE p.status = 'pendente'
    ORDER BY p.criado_em DESC
    LIMIT 20;

    Sinais de alerta no resultado: type: ALL (full table scan), Using filesort, Using temporary, ou um número de linhas examinadas muito maior que o número de linhas devolvidas. Nenhuma dessas informações aparece testando só "funcionou ou não funcionou" — elas só aparecem quando alguém efetivamente olha o plano antes do deploy.

    CHECKLIST FINAL — REVISÃO DE QUERY ANTES DO DEPLOY

    • Nenhum SELECT * em código de produção — sempre colunas explícitas.
    • Nenhuma função envolvendo coluna indexada do lado esquerdo do WHERE.
    • Tipos e collation compatíveis em toda comparação e todo JOIN.
    • LIKE com wildcard líder substituído por prefixo ou full-text index.
    • Nenhum padrão N+1 — relacionamentos resolvidos via JOIN ou eager loading.
    • Paginação de listas grandes usando keyset, não OFFSET alto.
    • Nenhuma subquery correlacionada onde um JOIN resolveria o mesmo caso.
    • Nenhuma chamada de rede/API/fila dentro de uma transação de banco aberta.
    • Operações em massa feitas em lote, não linha a linha em loop.
    • EXPLAIN ANALYZE rodado em toda query nova ou alterada antes do merge, olhando para type, filesort, temporary e linhas examinadas vs. devolvidas.

    QUANDO CHAMAR UM DBA ESPECIALISTA

    Revisão de query individual qualquer desenvolvedor sênior consegue fazer com este guia. O ponto em que vale a pena trazer um especialista é quando o problema não está numa query isolada, mas no padrão agregado: centenas de queries "aceitáveis" somando um consumo de CPU e I/O que nenhuma delas sozinha explica, ou uma aplicação que cresceu de tráfego mais rápido do que o schema foi desenhado para suportar.

    A HTI revisa exatamente esse tipo de padrão em todo Health Check de banco de dados — usando pt-query-digest e o performance_schema para rankear as queries que realmente consomem o orçamento do servidor, não só as mais lentas isoladamente. É o mesmo tipo de trabalho de auditoria que descrevemos em Índices Demais no MySQL — query mal escrita e índice mal planejado normalmente andam juntos no mesmo incidente.

    PRÓXIMO PASSO

    Escrever query performática exige entender o otimizador.

    E isso raramente está documentado no ORM que sua equipe usa no dia a dia.

    PERGUNTAS FREQUENTES

    SELECT * realmente impacta performance, mesmo em tabelas pequenas?

    Em tabelas pequenas o impacto é desprezível. O problema aparece em escala: mais tráfego de rede por execução, impossibilidade de usar índice de cobertura, e o risco de que a tabela cresça (novas colunas grandes) sem que a query seja revisada. O hábito de pedir só o necessário evita que o problema apareça mais tarde, quando já é caro corrigir em todo o código.

    Por que uma função como DATE() no WHERE impede o uso de índice?

    Porque o índice B-tree é ordenado pelo valor original da coluna, não pelo resultado da função. Para aplicar a função e comparar, o MySQL precisaria calculá-la para cada linha da tabela antes de saber se ela bate com a condição — o que elimina a vantagem do índice. Reescrever como intervalo (>= e <) resolve na maioria dos casos.

    Paginação com OFFSET é sempre um problema?

    Não — em listas pequenas ou páginas iniciais, o custo é irrelevante. O problema cresce conforme o número da página aumenta, porque o MySQL precisa ler e descartar todas as linhas anteriores. Para catálogos grandes ou feeds com rolagem infinita, keyset pagination evita que esse custo cresça com o tempo.

    N+1 aparece no slow query log?

    Geralmente não. Cada query individual do padrão N+1 costuma ser rápida isoladamente e não ultrapassa o threshold do slow query log. O sintoma aparece em métricas agregadas — CPU alta, muitas conexões simultâneas, Questions por segundo elevado — sem nenhuma query isolada parecendo culpada.

    Vale a pena revisar EXPLAIN de toda query, mesmo as simples?

    Para queries triviais sobre chave primária, geralmente não compensa o esforço. Para qualquer query com JOIN, ORDER BY, GROUP BY ou filtro sobre coluna indexada, sim — é o único jeito confiável de confirmar que o índice está sendo usado como esperado antes de ir para produção.