Ir para o conteúdo principal

A consulta que lia 3,7 bilhões de linhas por dia para vender 4 mil números

Diagnóstico real de uma query MySQL em produção: por que ela custava 92% do banco, por que o SKIP LOCKED devolvia zero e como corrigir sem trocar de servidor.

Em um SaaS de números virtuais para verificação por SMS que eu desenvolvo e opero, o faturamento estava estável e os gráficos comerciais não acusavam nada. Mas dois números não fechavam, e eles contavam uma história diferente da que a receita contava.

Este post é o diagnóstico completo: as medições, a causa e a correção. Escrevi para dois públicos: quem é dono de um sistema e quer entender por que ele fica lento, e quem programa e quer os detalhes técnicos. Os nomes de tabelas e colunas foram simplificados para facilitar a leitura.

Resumo em 30 segundos

  • O sistema fazia cinco vezes mais entregas, mas vendia menos. A receita parada escondia o problema.
  • Uma única consulta ao banco relia o estoque inteiro, 30 mil registros, a cada pedido, para no fim escolher só 10 números.
  • Essa mesma consulta causava um segundo bug: às vezes ela dizia "não tenho nenhum número disponível" com 30 mil disponíveis.
  • A causa era código antigo que já não precisava existir. A correção é mudar a consulta e criar um índice. Não precisa de servidor maior.

Os dois números que não fechavam

A plataforma entrega números descartáveis para parceiros que revendem verificação por SMS. O cliente pede um número, recebe o código por SMS e a ativação é concluída. Só ativação concluída gera receita.

Em seis dias, o volume de entregas subiu de 25.317 para 124.757 por dia, quase cinco vezes mais. No mesmo período, a taxa de conversão (a parte das entregas que vira venda) caiu:

Data Entregas Concluídas Conversão
14/09 41.425 7.768 18,75%
15/09 39.329 6.449 16,40%
16/09 25.317 1.597 6,31%
18/09 64.330 4.234 6,58%
19/09 124.757 4.872 3,91%
20/09 67.201 1.294 1,93%

A receita ficou praticamente igual, na casa de US$ 500 por dia antes e depois (valor ilustrativo). Foi isso que escondeu o problema por dias: se o faturamento não muda, ninguém investiga.

Mas 124 mil entregas para 4.872 vendas significam que 96% do trabalho do servidor foi desperdiçado. Esse desperdício não aparece na linha de receita. Aparece no processador.

O servidor: pequeno de propósito

A máquina é uma VPS com 2 vCPUs, ou seja, dois núcleos de processador. Hardware modesto, escolhido para manter o custo do produto baixo.

  • O MySQL sozinho usava 41,3% de CPU, com load average de 2,16. Na prática, os dois núcleos estavam acima do limite.
  • A aplicação em Node.js usava 1%. O gargalo não era o código da aplicação.
  • O banco tinha acumulado 371.644 consultas lentas registradas.
  • O pico de conexões chegou a 130 de um limite de 151.

A saída óbvia seria contratar um servidor maior. É a resposta errada e cara: você passa a pagar todo mês por um problema que é de desenho, não de capacidade. Com o diagnóstico certo, 2 núcleos dão conta.

A consulta

Toda compra passa por uma única consulta, que escolhe qual número entregar seguindo três regras de negócio:

  • Descanso de 30 dias: um número usado para um serviço (por exemplo, WhatsApp) não pode ser usado de novo para esse serviço por 30 dias.
  • Rotação: um número pedido há menos de 20 minutos está "em uso" e não pode ser oferecido de novo.
  • Justiça: quem foi pedido há mais tempo vem primeiro, para o estoque girar por igual.

O banco tem duas tabelas envolvidas: chips, com os números, e chip_servico, com o histórico de uso de cada número em cada serviço. As regras foram implementadas com uma CTE, uma espécie de tabela auxiliar que o banco monta na hora, dentro da própria consulta:

WITH historico AS (
  SELECT
    c.telefone,
    MIN(c.id)          AS chip_id,
    MAX(cs.usado_em)   AS ultimo_uso,
    MAX(cs.pedido_em)  AS ultimo_pedido,
    SUM(cs.usos)       AS total_usos
  FROM chips c
  LEFT JOIN chip_servico cs
    ON cs.chip_id = c.id AND cs.servico = ?
  GROUP BY c.telefone
)
SELECT c.id, c.telefone
FROM chips c
INNER JOIN historico h ON h.chip_id = c.id
WHERE ...
ORDER BY h.ultimo_pedido ASC, h.total_usos ASC, RAND()
LIMIT 10
FOR UPDATE SKIP LOCKED

Em português: "junte o histórico de cada número, ordene do menos usado para o mais usado, pegue 10 e reserve-os para mim".

A CTE tinha um motivo legítimo. No passado, o mesmo número de telefone podia aparecer em mais de uma linha da tabela, e a regra dos 30 dias precisa valer para o número, não para a linha. O GROUP BY resolvia isso.

O problema não estava na lógica. Estava no trabalho que o banco precisava fazer para executá-la.

O plano de execução

Todo banco de dados, antes de rodar uma consulta, monta um plano de execução: o roteiro de quais tabelas ler, em que ordem e de que jeito. O comando EXPLAIN mostra esse roteiro. Rodei em produção, com os dados reais. Resumido, ele dizia:

-> Limit: 10 linhas
  -> Ordenar por ultimo_pedido, total_usos, rand()      (custo total 14.797)
    -> Ler a tabela temporária "historico"             (29.992 linhas)
      -> Montar a CTE numa tabela temporária
        -> Agrupar usando tabela temporária
          -> Ler a tabela chips inteira                  (custo 13.550, 29.992 linhas)

Três pontos contam a história:

  • A tabela chips é lida inteira: a CTE não sabe qual país ou serviço o cliente pediu, então processa os 30 mil registros, sempre.
  • O resultado vai para uma tabela temporária, montada do zero a cada pedido.
  • Custo de 13.550 de um total de 14.797: a CTE é 91,6% do custo da consulta. Todo o resto cabe nos 8% que sobram.

Tudo isso para devolver 10 linhas. É como reorganizar a biblioteca inteira toda vez que alguém pede um livro.

O tamanho do desperdício

Cada pedido lê 29.992 linhas. Em um dia de pico foram 124.757 pedidos:

3,74 bilhões de linhas lidas por dia para entregar 4.872 números.

Isso dá cerca de 768 mil linhas lidas por venda concluída.

Cada execução levava entre 0,97 s e 1,60 s em produção. Com esse tempo, o sistema aguenta cerca de 1,5 pedidos por segundo, ou 90 por minuto. O estoque de 30 mil números, com reserva de 20 minutos, aguentaria 1.500 por minuto. A consulta trava o sistema muito antes de o estoque acabar.

O gargalo nunca foi o estoque de números, nem o servidor. Era uma consulta SQL.

A descoberta que mudou o diagnóstico

Antes de propor qualquer correção, fui conferir a premissa que justificava a CTE: quantos números duplicados existem hoje?

SELECT COUNT(DISTINCT telefone) AS distintos, COUNT(*) AS linhas FROM chips;
distintos: 30551
linhas:    30551

Zero duplicatas.

A CTE agrupava 30.551 grupos de exatamente um item cada, 124 mil vezes por dia, para eliminar duplicatas que não existiam. Era código que já tinha sido necessário, deixou de ser, e ninguém voltou para medir, porque ele nunca deu erro. Só ficou caro.

É o tipo de dívida técnica mais traiçoeira: a que funciona. Não gera erro, não acorda ninguém de madrugada, não aparece no log. Só consome processador em silêncio até o dia em que o volume chega.

O segundo bug: "nenhum número disponível" com 30 mil disponíveis

Em paralelo havia outro sintoma, registrado semanas antes. Para duas vendas não pegarem o mesmo número ao mesmo tempo, a consulta usa FOR UPDATE SKIP LOCKED. Em português: "reserve estas linhas para mim e, se alguém já reservou alguma, pule e pegue a próxima".

Só que a consulta devolvia zero linhas, com 30 mil candidatos e apenas 41 reservados naquele instante.

Medi as variações na mesma janela e no mesmo banco:

Variante da consulta Linhas devolvidas
Completa, com FOR UPDATE SKIP LOCKED 0
Completa, com FOR UPDATE (sem SKIP LOCKED) 10
Sem ORDER BY ... RAND(), com SKIP LOCKED 10
Sem ORDER BY nenhum, com SKIP LOCKED 0
Completa, sem reserva nenhuma 10

Isso não é disputa entre pedidos: 41 reservas não transformam 30 mil candidatos em zero. E o detalhe revelador: mexer na ordenação, que não tem nada a ver com reserva, muda o resultado.

Quando um sintoma reage a uma mudança que logicamente não deveria afetá-lo, a causa quase sempre está no plano de execução, não no SQL em si.

A hipótese

FOR UPDATE reserva linhas de tabelas reais. Mas o plano mostra que as linhas chegam ao final passando por uma CTE materializada, uma tabela temporária. E tabela temporária não tem linha real para reservar.

Quando o banco não consegue ligar a linha temporária de volta à linha original, o SKIP LOCKED faz o que o nome diz: pula. E pula tudo. Isso explica por que tirar o RAND(), que muda o plano para um caminho sem tabela temporária, faz as 10 linhas voltarem.

Trato isso como hipótese, não como fato. Ela bate com as cinco medições e com o plano, mas a confirmação só vem com teste de carga em ambiente controlado.

Se estiver certa, é a melhor notícia do diagnóstico: os dois bugs têm a mesma causa. A lentidão e o "zero números" são o mesmo problema visto de dois ângulos, e eliminar a tabela temporária resolve os dois.

Há também um efeito comercial silencioso. Dos parceiros conectados, um concentrava quase todo o volume e outro passou semanas quase sem receber números. A leitura fácil seria culpar a demanda do parceiro. Mas se a consulta devolve zero de forma intermitente, quem tenta mais vezes recebe mais, não por mérito comercial, mas por sorte no plano de execução. Um problema de banco disfarçado de problema de vendas.

A correção

O princípio: não calcule na hora da leitura o que já pode estar pronto antes dela.

A tabela chip_servico já guarda, para cada par número e serviço, a data do último uso, a data do último pedido e o total de usos. A CTE não descobria nada novo: só reagrupava esses dados para resolver duplicatas que não existem mais.

1. Provar que não há duplicatas, em vez de supor. Hoje a coluna aceita duplicatas, mesmo sem ter nenhuma. Uma restrição UNIQUE faz o banco recusar qualquer duplicata futura. Sem ela, remover a CTE seria fé, não engenharia.

2. Ler direto dos dados que já estão prontos:

SELECT c.id, c.telefone
FROM chip_servico cs
INNER JOIN chips c ON c.id = cs.chip_id
WHERE cs.servico = ?
  AND cs.livre = 1
  AND (cs.usado_em  IS NULL OR cs.usado_em  < ?)
  AND (cs.pedido_em IS NULL OR cs.pedido_em < ?)
  AND c.ativo = 1
ORDER BY cs.pedido_em ASC
LIMIT 10
FOR UPDATE SKIP LOCKED

3. Criar o índice que torna isso barato. Um índice funciona como o índice remissivo de um livro: em vez de ler todas as páginas, você vai direto à certa.

CREATE INDEX idx_busca_chip
  ON chip_servico (servico, livre, pedido_em);

A ordem das colunas importa. Primeiro os filtros exatos, por último o campo de ordenação: assim o índice já entrega as linhas na ordem certa, o banco encontra 10 válidas e para. Nenhum dos índices existentes servia para isso.

4. Remover o RAND(). Ele existia para espalhar os pedidos simultâneos e evitar que todos disputassem o mesmo número. Com o SKIP LOCKED funcionando, o próprio banco faz esse trabalho: cada pedido pula o que está reservado e pega o próximo. O RAND() era um remendo para uma reserva que não funcionava e, ironicamente, parte do motivo de ela não funcionar.

O que muda na prática

Hoje a consulta lê o estoque inteiro. Depois da correção, ela vai direto ao ponto no índice e lê só as 10 linhas de que precisa. O custo para de crescer junto com o estoque: dobrar o número de chips deixa de dobrar o custo de cada venda.

A projeção, a partir do plano atual: de 29.992 linhas lidas por pedido para algumas dezenas, e de 3,7 bilhões de linhas por dia para a casa de alguns milhões. No mesmo servidor de 2 núcleos.

Isso é projeção, não resultado. A correção vai para produção junto com um teste de carga que mede a taxa de entrega sob concorrência, e não só a velocidade. Uma consulta mais rápida que entrega menos números é uma piora disfarçada de otimização. Publico os números reais quando a correção estiver no ar.

O que fica desse diagnóstico

Receita estável pode esconder um problema estrutural. O faturamento não se mexeu enquanto o custo de produzi-lo quadruplicava. Taxa de conversão e custo por venda precisam estar no mesmo painel que o dinheiro.

Código que funciona pode ser a dívida mais cara. A CTE nunca deu erro. Só tinha deixado de ser necessária, e ninguém volta para medir o que não reclama.

Medir antes de opinar. A primeira hipótese, plausível e errada, era que o estoque tinha acabado. Trinta segundos de EXPLAIN mostraram onde estavam 91,6% do custo. Sem isso, o caminho natural seria dobrar o servidor e pagar por isso todo mês, sem resolver nada.

Dois bugs com a mesma causa são um bug só. A lentidão e a reserva quebrada foram tratadas separadamente por semanas. Quando duas coisas estranhas acontecem no mesmo lugar, a causa única deve ser a primeira hipótese testada.

Adiar com medição registrada é engenharia, não omissão. O problema foi encontrado semanas antes e adiado de propósito, com a medição documentada e o critério para retomar escrito. Faltava ambiente de carga para validar, e essa consulta atende todas as compras. Mexer em reserva de linhas em produção sem poder medir seria apostar com o dinheiro do negócio.


Diagnóstico e medições feitos em produção, setembro de 2026. Nomes de tabelas e colunas simplificados e valores de receita ilustrativos; volumes, tempos e custos do plano de execução são reais. Se o custo do seu sistema cresce mais rápido que a receita e ninguém sabe apontar onde, esse é o trabalho da minha consultoria técnica em software.

// contato

Vamos conversar sobre o seu projeto?

Diagnóstico técnico, arquitetura e desenvolvimento sob medida para startups e empresas.

Fale comigo →