DEV Community

Denis Augusto
Denis Augusto

Posted on

N+1: o bug silencioso que trava seu Laravel em produção

Sua tela demora 8 segundos e o servidor não tem culpa

O cliente manda a mensagem: "o painel tá travando".

Você abre em produção. Conta os segundos. Um... dois... oito. A tela de listagem de pedidos, que tem 50 linhas, demora uma eternidade pra carregar.

Primeiro palpite: servidor fraco. Você já tá quase abrindo um chamado pra subir o plano da VPS.

Segura o dedo. Na esmagadora maioria das vezes, não é o servidor. É o N+1 query — o bug mais silencioso e mais comum do Eloquent. Ele não quebra nada, não estoura erro, não aparece no log. Só deixa tudo lento e finge que a culpa é de outro.

O código parece inofensivo (e é aí que mora o problema)

Olha esse trecho. Aposto que tem um igualzinho no seu projeto agora:

$pedidos = Pedido::all(); // 1 query pra buscar os pedidos

foreach ($pedidos as $pedido) {
    // e aqui... +1 query POR pedido, toda vez que você toca no cliente
    echo $pedido->cliente->nome;
}
Enter fullscreen mode Exit fullscreen mode

Bonito, limpo, legível. E secretamente fazendo o banco chorar.

Cada vez que o loop acessa $pedido->cliente, o Eloquent dispara uma nova consulta pra buscar aquele cliente. São 50 pedidos? 1 query dos pedidos + 50 dos clientes = 51 queries numa tela só.

Agora imagina uma listagem com 500 registros e três relações (cliente, produtos, endereço). O número explode. Já vi tela abrir o Debugbar e mostrar mais de 3.000 queries. Cada uma custa alguns milissegundos de ida e volta ao banco. Some tudo: sua tela de 8 segundos.

Por que "N+1"? O nome entrega o problema

É matemática simples e meio cruel.

  • 1 query pra buscar a lista principal (os N pedidos).
  • N queries extras, uma pra cada item, quando você acessa a relação dentro do loop.

Total: N + 1. Quanto mais dados, pior fica — e o ambiente de dev, com 5 registros de teste, nunca te avisa. O bug só acorda em produção, com dado de verdade. Traiçoeiro.

A solução cabe em uma palavra: with()

Aqui vem o $pedido->restore() dessa história. Uma linha e você vira o herói do dia:

// Eager loading: já traz os clientes junto, de uma vez
$pedidos = Pedido::with('cliente')->get();

foreach ($pedidos as $pedido) {
    echo $pedido->cliente->nome; // já tá na memória, ZERO query nova
}
Enter fullscreen mode Exit fullscreen mode

O que mudou? Em vez de buscar cliente por cliente dentro do loop (lazy loading), o with() faz o Eloquent buscar todos os clientes de uma vez só, com um WHERE IN, antes do loop começar. Isso é eager loading — "carregamento adiantado".

De 51 queries pra 2. Sempre 2, tanto faz se são 50 ou 5.000 pedidos. A tela de 8 segundos vira 200 milissegundos.

Não mexeu no servidor. Não instalou nada. Uma palavra.

Como usar na prática

O with() é mais flexível do que parece:

// Várias relações de uma vez
Pedido::with(['cliente', 'itens'])->get();

// Relação aninhada: o item e o produto de cada item
Pedido::with('itens.produto')->get();

// Já filtrou a query? Carrega depois, na collection que você já tem
$pedidos = Pedido::all();
$pedidos->load('cliente'); // mesmo efeito, pra collection pronta

// Só quer a CONTAGEM, não os dados? withCount é seu amigo
Pedido::withCount('itens')->get(); // vira a coluna itens_count
Enter fullscreen mode Exit fullscreen mode

with() você usa na query. load() quando a collection já foi buscada. withCount() quando só precisa do número, sem carregar os registros.

A pegadinha: eager loading não é "coloca em tudo"

Antes de sair jogando with() em toda query, cuidado com o outro extremo.

Se você carrega uma relação que não vai usar naquela tela, tá trazendo dados à toa — gastando memória e banda pra nada. O with('itens.produto.fornecedor.endereco') que ninguém lê é tão ruim quanto o N+1.

A régua é simples: carregue o que você vai acessar no loop. Nada além disso.

E tem a confusão clássica: with() não é a mesma coisa que has() ou whereHas(). O with() traz a relação junto. O whereHas() filtra pela existência dela. Um carrega, o outro filtra. Já vi muita gente usar um esperando o comportamento do outro.

Bônus: faça o Laravel gritar quando você esquecer

O melhor jeito de nunca mais deixar um N+1 passar é pedir pro próprio Laravel te avisar. No AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // Em dev, estoura exceção sempre que uma relação
    // for carregada de forma lazy (o gatilho do N+1)
    Model::preventLazyLoading(! app()->isProduction());
}
Enter fullscreen mode Exit fullscreen mode

Agora, se você esquecer um with(), o Laravel lança uma exceção na sua cara em desenvolvimento — muito antes de virar tela travada em produção. Deixa ligado só fora de produção pra não derrubar o app do cliente por um esquecimento.

Antes de você fechar a aba

O N+1 é aquele bug que a gente escreve sem perceber e só descobre quando alguém reclama. A boa notícia: achar e matar é fácil quando você sabe o que procurar.

Abre o Laravel Debugbar (ou o Telescope) numa tela lenta do seu projeto e olha a contagem de queries. Achou uma listagem com dezenas ou centenas? É N+1 quase na certa. Um with() e você resolve.

Me conta nos comentários: qual foi a tela mais absurda de queries que você já pegou? Aposto que tem gente com número maior que 3.000. 👀

Top comments (0)