Voltar ao Blog
Laravel9 min de leitura

Eloquent e o problema N+1: como detectar, medir e eliminar

A consulta que vira 500 queries sem ninguém perceber. Como identificar o N+1 no Laravel, corrigir com eager loading, tratar os casos que `with()` não resolve — e reconhecer as vezes em que carregar tudo de uma vez piora.

Neste artigo

O N+1 é o bug de performance mais comum que já vi em projeto Laravel — e o mais fácil de escrever sem perceber. Ele não quebra nada. Não gera exceção. Passa nos testes. A página só fica lenta, e ninguém consegue apontar exatamente onde.

A causa é sempre a mesma: o Eloquent torna tão natural navegar por relações que você escreve $pedido->cliente->nome dentro de um foreach sem lembrar que aquilo é uma consulta ao banco. Multiplique por 200 pedidos e você tem 201 queries onde deveria haver duas.

Este artigo é o processo que eu uso: detectar, corrigir, e — a parte que quase ninguém faz — confirmar que a correção melhorou de verdade.

O que é o N+1, com números

Considere uma listagem de pedidos que mostra o nome do cliente.

app/Http/Controllers/PedidoController.php
public function index()
{
    $pedidos = Pedido::latest()->take(200)->get();

    return view('pedidos.index', compact('pedidos'));
}
resources/views/pedidos/index.blade.php
@foreach ($pedidos as $pedido)
    <tr>
        <td>{{ $pedido->numero }}</td>
        <td>{{ $pedido->cliente->nome }}</td>
    </tr>
@endforeach

O que acontece no banco:

-- 1 query para os pedidos
select * from pedidos order by created_at desc limit 200;

-- e então, uma por linha, 200 vezes:
select * from clientes where id = 1 limit 1;
select * from clientes where id = 2 limit 1;
select * from clientes where id = 3 limit 1;
-- ...

201 queries. Esse é o “N+1”: 1 consulta para a lista, mais N consultas para carregar a relação de cada item.

Em desenvolvimento, com banco local e 20 registros de seed, isso responde em 40 ms e ninguém nota. Em produção, com o banco em outra máquina e 1,5 ms de latência de rede por ida e volta, são 300 ms só de rede — antes de o MySQL executar qualquer coisa.

Por que o Eloquent facilita cair nisso

Não é descuido do programador: é uma consequência do lazy loading. Quando você acessa $pedido->cliente e a relação não está carregada, o Eloquent vai buscar no banco naquele instante, de forma transparente.

Essa transparência é ótima para escrever código e péssima para performance, porque a query fica invisível no ponto de uso. Você olha para a linha {{ $pedido->cliente->nome }} e vê um acesso a propriedade, não um SELECT.

Pior: o problema costuma nascer longe de onde é sentido. O controller está correto. Quem introduziu o N+1 foi a view — ou um accessor, ou uma policy, ou um API Resource, três camadas abaixo.

Detectar

O jeito definitivo: proibir lazy loading

Esta é a mudança de maior impacto que você pode fazer hoje. O Laravel consegue lançar exceção sempre que uma relação for carregada sob demanda.

app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // Em produção não lança exceção — apenas registra.
    Model::preventLazyLoading(! $this->app->isProduction());
}

A partir daí, qualquer N+1 vira um erro estourando na cara em desenvolvimento e na suíte de testes. É a diferença entre descobrir o problema no seu ambiente e descobrir no Grafana às três da manhã.

Se você não quer quebrar produção mas quer visibilidade, registre em log:

Registrar em vez de lançar
Model::preventLazyLoading();

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relacao) {
    if (app()->isProduction()) {
        logger()->warning('Lazy loading detectado', [
            'modelo' => $model::class,
            'relacao' => $relacao,
        ]);

        return;
    }

    throw new \RuntimeException(
        sprintf('Lazy loading de [%s] em [%s].', $relacao, $model::class)
    );
});

O irmão mais rigoroso, Model::shouldBeStrict(), liga três proteções de uma vez: proíbe lazy loading, proíbe atribuição silenciosa de atributo não preenchível e proíbe acesso a atributo que não veio no SELECT. Vale a pena em projeto novo; em projeto legado, ligue uma de cada vez.

Ver as queries de uma requisição específica

Para investigar um endpoint pontual, DB::listen resolve sem instalar nada:

Contagem rápida em um teste ou tinker
use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

$pedidos = Pedido::latest()->take(200)->get();
$pedidos->each(fn ($p) => $p->cliente->nome);

$queries = DB::getQueryLog();

dump([
    'total' => count($queries),
    'tempo_ms' => round(collect($queries)->sum('time'), 2),
]);

Em teste automatizado, dá para transformar isso em asserção — e aí o N+1 não volta:

tests/Feature/PedidoIndexTest.php
it('lista pedidos sem N+1', function () {
    Pedido::factory()->count(50)->create();

    DB::enableQueryLog();

    $this->get('/pedidos')->assertOk();

    expect(DB::getQueryLog())->toHaveCount(2);
});

Ferramentas

  • Laravel Debugbar — mostra a contagem e destaca queries duplicadas. É o caminho mais rápido em desenvolvimento.
  • Laravel Telescope — melhor para ambiente compartilhado, porque grava a requisição inteira para você analisar depois.
  • Clockwork — alternativa leve ao Debugbar, com um painel decente de banco de dados.

Eliminar

with() — eager loading no momento da consulta

A correção básica
// 201 queries
$pedidos = Pedido::latest()->take(200)->get();

// 2 queries
$pedidos = Pedido::with('cliente')->latest()->take(200)->get();

O que o Eloquent faz agora:

select * from pedidos order by created_at desc limit 200;
select * from clientes where id in (1, 2, 3, /* ... */);

Duas queries, independente de serem 200 ou 20.000 pedidos.

load() e loadMissing() — depois do fato

Quando o modelo já veio de outro lugar (um route model binding, por exemplo):

Carregando depois
public function show(Pedido $pedido)
{
    // Carrega mesmo se já estiver carregado — desperdiça uma query
    $pedido->load('itens.produto');

    // Só carrega o que ainda falta
    $pedido->loadMissing('itens.produto');

    return view('pedidos.show', compact('pedido'));
}

Prefira loadMissing por padrão. load só quando você quer forçar a releitura.

Contar sem carregar: withCount e família

Este é o erro que mais desperdiça memória. Para mostrar “12 itens”, muita gente carrega os 12 itens.

Antes e depois
// Traz todos os itens de todos os pedidos, só para contar
$pedidos = Pedido::with('itens')->get();
// blade: {{ $pedido->itens->count() }}

// Faz a contagem no banco, traz um número por linha
$pedidos = Pedido::withCount('itens')->get();
// blade: {{ $pedido->itens_count }}

A mesma família resolve outros agregados sem hidratar objeto nenhum:

Agregados
$pedidos = Pedido::query()
    ->withCount('itens')
    ->withSum('itens', 'valor')     // $pedido->itens_sum_valor
    ->withMax('itens', 'valor')     // $pedido->itens_max_valor
    ->withExists('pagamentos')      // $pedido->pagamentos_exists (bool)
    ->get();

withExists merece destaque: para responder “tem pelo menos um?”, ele gera um EXISTS no SQL, que o banco resolve muito mais rápido que um COUNT completo.

Eager loading aninhado e com condição

Casos mais elaborados
Pedido::with([
    // Aninhado: pedido -> itens -> produto -> categoria
    'itens.produto.categoria',

    // Só as colunas necessárias. A chave estrangeira é obrigatória!
    'cliente:id,nome,email',

    // Com condição e ordenação
    'itens' => fn ($query) => $query->where('ativo', true)->orderBy('posicao'),

    // Relação de relação com condição
    'itens.produto' => fn ($query) => $query->select('id', 'nome', 'sku'),
])->get();

A armadilha do cliente:id,nome,email: se você omitir a coluna que liga as tabelas, o Eloquent não consegue casar os resultados e a relação vem null sem erro nenhum. Para belongsTo, inclua a chave primária; para hasMany, inclua a chave estrangeira.

Relações polimórficas: morphWith

with() não resolve morphTo, porque o Eloquent não sabe de antemão qual modelo vai carregar. O resultado é um N+1 por tipo.

Polimórfico feito certo
use Illuminate\Database\Eloquent\Relations\MorphTo;

$atividades = Atividade::with([
    'assunto' => fn (MorphTo $morphTo) => $morphTo->morphWith([
        Pedido::class => ['cliente'],
        Comentario::class => ['autor', 'post'],
        Fatura::class => ['itens'],
    ]),
])->get();

Sempre carregar: $with no modelo

Se uma relação é necessária em toda leitura, declare no modelo:

app/Models/Pedido.php
class Pedido extends Model
{
    protected $with = ['cliente'];
}

Use com parcimônia. Isso passa a carregar a relação em toda consulta, inclusive nas que não precisam dela. Quando não precisar, desligue com Pedido::without('cliente'). Na minha experiência, $with compensa em pouquíssimos casos — normalmente é melhor ser explícito em cada consulta.

Quando eager loading piora

Aqui é onde a maioria dos artigos para, e onde os problemas de verdade começam. with() não é remédio universal.

Carregar 40 colunas para usar 2

Se o cliente tem um campo observacoes do tipo TEXT com 8 KB e você só precisa do nome, with('cliente') traz os 8 KB por linha. Em 200 linhas são 1,6 MB atravessando a rede e ocupando memória do PHP.

Peça só o que vai usar
Pedido::with('cliente:id,nome')->get();

O produto cartesiano em hasMany aninhado

Carregar duas relações hasMany no mesmo nível é seguro — o Eloquent faz uma query separada para cada uma. O problema aparece quando você força tudo numa query só com join:

O que não fazer
// 10 pedidos × 20 itens × 5 pagamentos = 1.000 linhas
// para representar 10 pedidos
Pedido::query()
    ->join('itens', 'itens.pedido_id', '=', 'pedidos.id')
    ->join('pagamentos', 'pagamentos.pedido_id', '=', 'pedidos.id')
    ->get();

Deixe o Eloquent fazer queries separadas. Três consultas de 10, 200 e 50 linhas custam menos que uma de 1.000.

Conjuntos grandes: chunk e lazy

Em comando de terminal ou job que processa a base inteira, with() sozinho não salva — você ainda carrega tudo na memória.

Processando muitos registros
// Estoura a memória com 500 mil pedidos
Pedido::with('itens')->get()->each(fn ($p) => $this->processar($p));

// Processa em blocos de 500, com eager loading dentro de cada bloco
Pedido::with('itens')->chunkById(500, function ($pedidos) {
    foreach ($pedidos as $pedido) {
        $this->processar($pedido);
    }
});

// Mesma coisa com sintaxe de iterador
foreach (Pedido::with('itens')->lazyById(500) as $pedido) {
    $this->processar($pedido);
}

Use chunkById em vez de chunk sempre que estiver alterando os registros durante a iteração. chunk usa OFFSET, e se você modifica a condição do WHERE no meio do caminho, registros são pulados silenciosamente.

O limite do IN (...)

Com dezenas de milhares de IDs, a cláusula WHERE id IN (...) gerada pelo eager loading vira uma query gigantesca. Alguns bancos têm limite de tamanho de statement, e o planejador pode desistir do índice. Se você chegou nesse ponto, chunk já era necessário de qualquer jeito.

Medir de verdade

Contar queries é o primeiro sinal, não a conclusão. Duas queries ruins podem ser piores que vinte boas.

Verificando o plano de execução
DB::listen(function ($query) {
    if ($query->time > 100) {
        logger()->warning('Query lenta', [
            'sql' => $query->sql,
            'tempo_ms' => $query->time,
        ]);
    }
});

E no banco, confirme que a query de eager loading usa índice:

EXPLAIN select * from clientes where id in (1, 2, 3, 4, 5);

O que você quer ver na coluna type é range ou eq_ref. Se aparecer ALL, a consulta está varrendo a tabela inteira — e nesse caso o problema não era o N+1, era a falta de índice na chave estrangeira. Vale conferir: foreignId() no Laravel não cria índice sozinho a menos que você adicione ->constrained() ou ->index().

Checklist

O que eu verifico antes de considerar uma listagem pronta:

  1. Model::preventLazyLoading() está ligado fora de produção
  2. Toda consulta que alimenta uma listagem declara suas relações no with()
  3. Contadores usam withCount / withExists, não ->count() sobre coleção
  4. Relações com colunas grandes pedem só os campos usados (cliente:id,nome)
  5. morphTo usa morphWith
  6. Comandos e jobs que varrem a base usam chunkById ou lazyById
  7. Toda chave estrangeira tem índice
  8. Existe um teste que trava a contagem de queries do endpoint crítico

O item 8 é o que impede a regressão. Sem ele, o N+1 volta no próximo sprint — alguém adiciona um campo na view, e ninguém percebe até a página ficar lenta de novo.

LaravelEloquentPerformanceMySQL

Leia também