N+1 queries: como 2 consultas viram 101 e derrubam a performance

Uma página busca os 100 posts mais recentes. Para cada post, a aplicação também precisa exibir o nome do autor. Parece um trabalho pequeno, até o log do banco mostrar 101 consultas para montar uma única resposta.

Esse é o problema N+1: uma consulta carrega uma lista com N itens e, depois, outra consulta é executada para cada item. O código costuma ser simples, o banco responde rápido nos testes e ninguém percebe nada com três registros. Quando a lista cresce e a latência entre aplicação e banco entra na conta, a rota começa a gastar mais tempo viajando pela rede do que fazendo trabalho útil.

Onde as 101 consultas aparecem

O formato clássico é uma consulta dentro de um loop. Neste exemplo com Node.js e PostgreSQL, a primeira ida ao banco retorna os posts. As outras cem buscam os autores individualmente:

const { rows: posts } = await pool.query(`
  SELECT id, title, author_id
  FROM posts
  ORDER BY published_at DESC
  LIMIT 100
`);

for (const post of posts) {
  const { rows: authors } = await pool.query(
    'SELECT id, name FROM authors WHERE id = $1',
    [post.author_id],
  );

  post.author = authors[0];
}

O nome N+1 vem exatamente dessa conta: uma consulta inicial mais N consultas relacionadas. Se dez posts pertencem ao mesmo autor, o código ainda pode buscar esse autor dez vezes. Trocar o for por Promise.all() reduz o tempo de espera em alguns cenários, mas não muda a quantidade de consultas. Agora as cem apenas disputam conexões ao mesmo tempo, o que pode pressionar o pool e piorar outras requisições.

O padrão também não pertence a um ORM específico. Ele aparece com propriedades carregadas sob demanda, resolvers GraphQL, chamadas para serviços internos e qualquer código que primeiro monta uma lista e depois busca um detalhe de cada item separadamente.

Primeira saída: trazer a relação com JOIN

Quando a relação é simples e os dados cabem naturalmente na mesma resposta, um JOIN pode resolver tudo em uma ida ao banco:

SELECT
  p.id,
  p.title,
  a.id AS author_id,
  a.name AS author_name
FROM posts AS p
JOIN authors AS a ON a.id = p.author_id
ORDER BY p.published_at DESC
LIMIT 100;

Para uma relação muitos-para-um, como post e autor, essa costuma ser uma solução direta. Em relações um-para-muitos, porém, o resultado pode repetir os dados do registro principal em várias linhas. Um post com cinquenta comentários vira cinquenta linhas antes de a aplicação reagrupar o conteúdo. Paginação, ordenação e volume transferido precisam entrar na decisão; “uma consulta” não é automaticamente melhor do que duas consultas bem planejadas.

Segunda saída: carregar em lote

Outra opção é manter a consulta da lista e buscar todas as relações necessárias de uma vez. Em vez de uma busca por autor, coletamos os IDs únicos e fazemos uma segunda consulta:

const { rows: posts } = await pool.query(`
  SELECT id, title, author_id
  FROM posts
  ORDER BY published_at DESC
  LIMIT 100
`);

const authorIds = [...new Set(posts.map((post) => post.author_id))];

const { rows: authors } = await pool.query(
  'SELECT id, name FROM authors WHERE id = ANY($1::int[])',
  [authorIds],
);

const authorsById = new Map(
  authors.map((author) => [author.id, author]),
);

for (const post of posts) {
  post.author = authorsById.get(post.author_id);
}

A rota passa de 101 para duas consultas. Muitos ORMs chamam isso de eager loading, carregamento antecipado, include ou preload. O nome muda, mas a ideia é avisar antes quais relações serão usadas, permitindo que a biblioteca faça um JOIN ou uma consulta com IN em vez de descobrir cada relação durante o loop.

Essa abordagem também ajuda quando juntar tudo em uma única consulta produziria linhas demais. A aplicação recebe um conjunto de posts e outro de autores, depois relaciona os dois em memória. O custo fica previsível e não cresce uma ida ao banco por item.

Batching em GraphQL e camadas distribuídas

Resolvers GraphQL facilitam o reaparecimento do N+1 porque cada campo parece resolver seu próprio pedaço de forma isolada. Uma ferramenta como o DataLoader mantém essa separação no código, agrupa as chaves pedidas no mesmo ciclo e chama uma função que carrega todas em lote.

Há um cuidado importante: o cache padrão do DataLoader serve para eliminar repetições dentro de uma requisição, não para substituir Redis ou outro cache compartilhado. A instância normalmente deve ser criada por requisição, especialmente quando usuários diferentes possuem permissões diferentes. Reaproveitar o mesmo loader globalmente pode misturar dados entre contextos que deveriam continuar separados.

Como encontrar um N+1 antes que ele vire incidente

O sinal mais útil não é apenas uma consulta lenta. Em muitos casos, cada consulta individual termina em poucos milissegundos. O problema aparece na repetição da mesma forma de SQL, mudando apenas um ID, dezenas ou centenas de vezes durante a mesma requisição.

  • Ative o log de consultas em desenvolvimento e relacione as consultas ao request que as originou.
  • Use traces de APM para enxergar várias operações de banco semelhantes dentro da mesma rota.
  • Teste listagens com volume representativo. Cinco registros raramente expõem o comportamento que quinhentos tornam óbvio.
  • Em rotas importantes, considere um teste que acompanhe a quantidade de consultas e falhe quando ela crescer sem explicação.

O plano de execução do banco continua importante para descobrir índices ausentes e operações caras, mas ele analisa uma consulta por vez. Para encontrar N+1, primeiro é preciso observar o conjunto de consultas executado para produzir uma resposta.

Nem todo carregamento antecipado é grátis

Depois de encontrar um N+1, é tentador carregar todas as relações possíveis por padrão. Isso troca um problema por outro. Árvores grandes de objetos podem consumir memória, transferir colunas que a tela não usa e gerar consultas difíceis de otimizar.

A correção deve acompanhar o formato da resposta. Se a tela precisa somente do nome do autor, selecione esse campo. Se a relação é opcional, use a junção adequada. Se a lista é grande, preserve a paginação e carregue apenas os IDs daquela página. Se os dados vêm de outro serviço, defina um endpoint em lote em vez de disparar cem chamadas HTTP concorrentes.

O N+1 é traiçoeiro porque o código local parece inocente e cada operação isolada parece barata. A conta certa é feita por requisição: quantas idas ao banco ou a outro serviço foram necessárias para entregar a resposta? Quando esse número cresce junto com o tamanho da lista, vale parar o loop e planejar o carregamento.

Fontes