A maioria dos problemas de performance de backend começa perto dos dados

API lenta e a reunião já enche de soluções. Eu começo perto dos dados — não por culpar o banco, mas porque essa verificação costuma dar sinal rápido.

Uma API fica lenta e a reunião já enche de soluções antes de ganhar uma medição. Aparecem mais CPU e RAM, cache, fila, microserviços e refatoração. Às vezes alguém propõe até trocar a linguagem. Raramente a primeira sugestão é abrir o plano de execução.

Por isso, começo a investigação perto dos dados. Não porque o banco seja o culpado por padrão, mas porque muita coisa passa por ali e essa verificação costuma dar sinal rápido. Se a consulta estiver saudável, tiro o banco da frente e sigo o fluxo da requisição.

Soluções rápidas também escondem trabalho caro

Cache e fila resolvem problemas reais. Mais máquina também pode ser a decisão certa. Quando entram por reflexo, porém, esses recursos podem apenas mudar o tamanho da conta enquanto ninguém sabe qual trecho da requisição está segurando a resposta.

Subir CPU reduz a disputa por recurso, cache tira algumas requisições do caminho e fila absorve um pico. Enquanto isso, uma consulta que faz um trabalho enorme para retornar quase nada continua cara em cada execução. O mesmo vale para um loop que dispara uma chamada por item.

A latência pode cair por um tempo, o alerta para de tocar e o time respira. Quando a carga cresce, a operação cara reaparece, agora acompanhada de uma infraestrutura maior.

A pergunta que precisa vir antes da discussão de arquitetura: quanto trabalho essa requisição está produzindo para entregar o resultado?

Quase dobramos a máquina e o dashboard continuou lento

Foi o que aconteceu em um dashboard em que trabalhei. Ele carregava de forma síncrona, e uma única consulta fazia tudo de uma vez: buscava os dados, cruzava várias informações e calculava os valores exibidos. Como a CPU do banco ficava muito alta, quase dobramos CPU e RAM.

O banco ficou maior. O dashboard continuou passando de um minuto para carregar. A mesma consulta ainda concentrava todo o trabalho pesado em uma única execução.

Foi quando abrimos o plano de execução. A tela devolvia poucos indicadores, mas a consulta atravessava relacionamentos, formava um volume intermediário grande e gastava CPU em agregações antes de chegar neles. A resposta final era pequena. O trabalho para produzi-la, enorme.

Dobrar CPU e RAM tinha dado mais fôlego ao banco, mas a busca continuava obrigando-o a fazer tudo de uma vez. O plano mostrou que a investigação precisava entrar no caminho percorrido pela consulta.

O que preciso ver antes de mexer no banco

Para o banco virar suspeito de verdade, quero o plano de execução e as métricas apontando nessa direção. Tempo da consulta, leituras e cardinalidade geralmente confirmam ou eliminam uma hipótese em poucos minutos.

Se essas medidas estiverem saudáveis, tiro o banco da frente. Já peguei API lenta com a consulta respondendo dentro do esperado, enquanto o atraso estava no processamento da aplicação e em chamadas remotas feitas de forma sequencial. A partir daí, continuar procurando um defeito no banco seria apenas insistir na camada errada.

Antes de aprofundar, passo um radar curto pela consulta e pelo código. Uma query para carregar a lista seguida de outra para cada registro pede uma contagem de consultas. Esse N+1 aparece com frequência quando o ORM deixa as relações para o backend buscar uma a uma.

Um JOIN que multiplica linhas antes da agregação pede a medição do volume intermediário. Índice ausente pede plano. Um filtro que aplica uma função sobre a coluna indexada também.

No código, procuro chamadas remotas ou consultas com await dentro de for. Cada espera pode parecer pequena isoladamente e ainda assim dominar o tempo total quando todas executam em fila.

Nenhum desses sinais fecha o diagnóstico. Um N+1 numa rota com dois itens pode ter impacto irrelevante, um índice novo pode ajudar pouco numa coluna com baixa seletividade e um join volumoso talvez seja necessário para produzir o resultado. Por isso, conto as consultas, cronometro o loop inteiro e confiro no plano quantas linhas e leituras foram produzidas.

Esse radar apenas escolhe a primeira medição. A próxima camada da investigação vem da conta.

Uma linha correta pode desperdiçar o índice

Uma dessas linhas costuma passar batida na revisão. Ela devolve os registros de um dia inteiro.

WHERE CAST(campo_data_hora AS date) = @data

O resultado da tela parece correto. No plano, a história pode ser outra. Aplicar CAST à coluna obriga a consulta a transformar os valores antes da comparação. Com um índice em campo_data_hora, isso pode impedir uma busca direta pelo intervalo, aumentar bastante as leituras e até levar a um scan.

Quando o pedido é pelos registros de um dia inteiro, calculo as bordas fora da coluna.

WHERE campo_data_hora >= @inicio_do_dia
  AND campo_data_hora < @inicio_do_proximo_dia

Se o início é 16 de julho à meia-noite, o limite seguinte é 17 de julho à meia-noite. Assim entram todos os valores do dia 16, inclusive aqueles com frações de segundo no final, sem depender de 23:59:59.999.

O resultado permanece correto, mas agora existe um intervalo que o índice pode percorrer. A confirmação vem da comparação das leituras e do operador de acesso nos dois planos. Se a métrica não mudar, a hipótese não ficou de pé.

Leia o plano pela sequência do trabalho

Depois de comparar as versões do filtro, leio o plano como uma história do trabalho produzido pela consulta. Começo pelo tempo total e pelas leituras. Se a tela devolve dez indicadores, mas a execução faz centenas de milhares de leituras, existe uma conta para explicar.

Depois comparo a cardinalidade estimada com a real. Quando o otimizador esperava poucas linhas e recebeu muitas, ele pode ter escolhido joins, memória e agregações para um cenário bem menor do que encontrou durante a execução.

Em seguida acompanho onde os dados crescem. Procuro o operador em que um JOIN leva milhares de linhas a centenas de milhares, pouco antes de um filtro ou GROUP BY reduzir tudo novamente. A resposta final pequena pode esconder um volume intermediário enorme. É nesse caminho que agregações e ordenações começam a gastar CPU demais.

Também não tomo o percentual de custo exibido pelo plano como sentença. Ele serve para escolher onde medir. Marco o operador caro, quantas linhas entram e saem dele e quanto tempo ele consome.

Se o custo aparece depois da multiplicação das linhas e antes dos poucos indicadores necessários, já existe uma hipótese concreta para testar.

Quebrar a consulta onde o custo cresce

O que resolveu o dashboard foi quebrar a consulta e aproveitar os índices corretamente. A divisão não aconteceu de forma arbitrária. O próprio plano mostrou onde o custo começava a crescer.

Mantivemos no banco os filtros e a busca indexada dos registros necessários. Levar esse recorte para a aplicação faria o servidor receber um conjunto maior antes de poder descartá-lo. Também desperdiçaria o caminho que os índices já encurtavam.

A composição final dos indicadores foi para a aplicação. Essa etapa vinha depois dos relacionamentos e das agregações que elevavam o volume intermediário e mantinham alta a CPU do banco. Com os dados já recortados, o servidor podia montar os valores da tela sem concentrar toda a execução em uma única query.

O banco passou a reduzir o conjunto cedo. A aplicação recebia os registros selecionados e compunha os indicadores. Depois da mudança, o dashboard deixou de depender daquela consulta pesada e ficou mais previsível, porque a divisão acompanhava o ponto em que o custo crescia no plano.

Essa fronteira não veio de uma preferência por query única ou por mais lógica na aplicação. Marquei onde CPU, leituras e linhas intermediárias disparavam depois dos filtros seletivos. Então verifiquei se o trecho caro poderia receber fora do banco um conjunto já reduzido.

Antes de mover essa etapa, duas contas precisavam fechar. O banco deveria continuar fazendo o recorte que aproveitava os índices. A aplicação deveria compor o resultado sem buscar linhas demais, criar chamadas por item ou transformar a rede no novo gargalo.

A validação precisava mostrar menos CPU e leituras no banco sem aumentar o payload ou a latência da rota. Sem essa melhora, a divisão apenas transferiria o custo para outra camada e o dashboard continuaria caro.

Quando o plano aponta para fora do banco

Em outra rota, o plano mostrava uma consulta saudável, com leituras e tempo dentro do esperado. Mesmo assim, a API continuava lenta. Quando medi o fluxo inteiro, o atraso apareceu depois da consulta, no processamento da aplicação e em chamadas remotas executadas em sequência.

Um trecho assim muda a conta.

for (const item of itens) {
  await buscarDetalhe(item.id);
}

Com 50 itens e 40 ms por chamada, o loop pode acrescentar cerca de dois segundos à rota. Cada await inicia a próxima chamada somente depois da resposta anterior. Os 40 ms parecem pequenos quando vistos sozinhos. O tempo total mostra as cinquenta esperas em fila.

Quando o profiling aponta esse caminho, mexo no código e no desenho do fluxo. Separo as operações que dependem da resposta anterior daquelas que podem rodar juntas. Para as independentes, testo concorrência limitada.

Disparar cinquenta requisições de uma vez também pode apenas deslocar a pressão para a integração. A mudança precisa ser validada pelo tempo total da rota, pela taxa de erros e pela saturação do serviço remoto. Melhorar uma medida enquanto outra explode não resolve o problema.

Duas horas para localizar a camada, um dia para testar

“Achamos o gargalo” costuma virar “resolvemos a lentidão” cedo demais. Depois de localizar uma consulta ruim ou um trecho sequencial, ainda faltam a mudança, o deploy e a confirmação em produção. Encontrar a camada apenas encurta a lista de suspeitos.

Nas primeiras duas horas, quero localizar onde aprofundar: banco, aplicação, integração ou infraestrutura. Abro o plano, comparo tempo e leituras, conto consultas e cronometro o trecho executado depois do banco.

Se o plano está saudável e cinquenta chamadas de 40 ms somam cerca de dois segundos, paro de procurar índice e meço o código. Se as leituras explodem antes da agregação, o código espera enquanto testo a consulta.

Esse prazo é uma régua de investigação, não uma garantia. No dashboard lento, passei cerca de três horas no terminal olhando o plano, comparando métricas e testando a quebra da consulta. O restante do dia envolveu reunião, acesso, deploy e confirmação em produção.

Por isso, no primeiro dia quero ao menos uma hipótese testada com medida de antes e depois. O teste pode reescrever o filtro sem CAST, dividir a query ou limitar a concorrência do loop. Depois do deploy, comparo a métrica que motivou a mudança: leituras e CPU para o banco; latência, erros e saturação para a rota.

Em falhas intermitentes, essa janela cresce. É preciso esperar o sintoma reaparecer com métricas suficientes para comparar.

A confirmação acontece depois do deploy

Depois de alguns erros em produção, parei de procurar um culpado padrão. Banco, aplicação e infraestrutura entram como hipóteses, cada uma acompanhada de uma medida que pode confirmá-la ou descartá-la.

Quando o plano mostra leituras altas e cardinalidade distante da realidade, trabalho na consulta. Quando a consulta responde bem e o profiling concentra tempo depois dela, sigo para o código ou para a integração.

Cache, fila, índice e máquina maior continuam disponíveis. Não são decisões proibidas. Só não deveriam entrar antes de sabermos qual trabalho está segurando a resposta.

Antes de mudar, defino a medida que precisa melhorar e a que não pode piorar. Depois do deploy, volto ao plano, às métricas e ao profiling. Se a métrica-alvo não melhora, a hipótese não ficou de pé. Se a medida de proteção piora, a solução criou outro problema.

Descarto a hipótese e retorno ao ponto do fluxo em que o tempo ainda cresce.


Publicado originalmente no LinkedIn em 16 de julho de 2026.