Blog

O N+1 que só aparece em produção: a tela rápida em teste que faz mil consultas com dados reais

Uma distribuidora de materiais de construção lançou a nova tela de pedidos do dia: lista de pedidos com cliente, itens, produto de cada item e último status de entrega. Em teste, com o seed de 10 pedidos, a página respondia em 40 milissegundos. Em staging, em 180. Em produção, para o cliente que mais vendia, levava 6 segundos no horário de pico e derrubava outras telas junto, porque esgotava o pool de conexões. O código não mudou entre os ambientes. O que mudou foi o tamanho dos dados e a distância até o banco: a tela fazia uma consulta por pedido, por item e por evento, 51 consultas com o seed e 1.201 com dados reais, cada uma pagando uma ida e volta à rede. Este artigo mostra por que o N+1 é um defeito que os testes de unidade não enxergam, como medi-lo em cada requisição e no banco, como corrigi-lo com carregamento em lote, onde ele se esconde quando o laço não está no seu código, e como escrever o teste que impede que ele volte.

2026-10-07 / Arquitetura / 16 min

01

Por que o teste passa: o N+1 é função dos dados, não do código

O padrão é sempre o mesmo: uma consulta traz a lista e, para cada linha, outra consulta traz algo relacionado. Com N linhas e k relações, o total é 1 + N × k consultas. Em um teste de unidade, N é pequeno porque o seed é pequeno, e cada consulta custa frações de milissegundo porque o banco roda na mesma máquina. Em produção, N é o que o cliente tem, e cada consulta paga a ida e volta até o banco gerenciado, que fica em outra máquina, em outra zona, com 0,5 a 2 milissegundos de latência. Como as consultas acontecem em série dentro do laço, esse tempo soma, não se sobrepõe.

É por isso que o problema é invisível em teste e em staging e aparece em produção. Nada falha: o resultado está correto, o teste passa, a revisão de código não vê nenhuma consulta lenta porque não existe consulta lenta. Existem mil consultas rápidas. O exemplo abaixo é a tela do caso, em Node com o driver pg, e vale igual para qualquer ORM que faça carregamento preguiçoso de relações.

// Versao com N+1: uma consulta para a lista e mais uma por pedido, por item e por evento
export async function listarPedidosDoDia(db, { porPagina = 100 } = {}) {
  const { rows: pedidos } = await db.query(
    `SELECT id, cliente_id, criado_em FROM pedidos
      WHERE criado_em >= current_date ORDER BY criado_em DESC LIMIT $1`,
    [porPagina],
  );

  for (const pedido of pedidos) {
    const cliente = await db.query('SELECT id, nome FROM clientes WHERE id = $1', [pedido.cliente_id]);
    pedido.cliente = cliente.rows[0] ?? null;

    const itens = await db.query(
      'SELECT id, produto_id, quantidade FROM itens_pedido WHERE pedido_id = $1',
      [pedido.id],
    );
    for (const item of itens.rows) {
      const produto = await db.query('SELECT id, nome, sku FROM produtos WHERE id = $1', [item.produto_id]);
      item.produto = produto.rows[0] ?? null;
    }
    pedido.itens = itens.rows;

    const ultimo = await db.query(
      'SELECT status, ocorrido_em FROM eventos_pedido WHERE pedido_id = $1 ORDER BY ocorrido_em DESC LIMIT 1',
      [pedido.id],
    );
    pedido.ultimoEvento = ultimo.rows[0] ?? null;
  }
  return pedidos;
}
// Seed de teste (10 pedidos, 2 itens cada): 1 + 10 + 10 + 20 + 10 = 51 consultas, 5 ms na rede local.
// Producao (100 pedidos, 9 itens em media): 1 + 100 + 100 + 900 + 100 = 1.201 consultas.
AmbientePedidos na páginaItens por pedidoConsultasIda e volta ao bancoTempo só de rede
Teste local (seed)102510,1 ms5 ms
Staging3042110,4 ms84 ms
Produção, cliente médio10091.2010,8 ms961 ms
Produção, pico, pool de 10 disputado10091.2010,8 ms + espera por conexão3 a 6 s

A última linha explica a parte mais grave do incidente. Cada uma das 1.201 consultas pega uma conexão do pool, usa por menos de um milissegundo e devolve. Com 40 usuários abrindo a tela no mesmo minuto, são 48 mil pedidos de conexão disputando 10 vagas. As outras telas, que fazem 3 consultas e deveriam responder em 20 milissegundos, ficam na fila atrás delas. O N+1 de uma tela vira a latência de todas.

02

Enxergar o problema: contar consultas por requisição e ler o banco pelo lado certo

Latência média e consultas lentas não mostram N+1, porque nenhuma consulta é lenta. A métrica que mostra é a contagem de consultas por requisição. Ela é barata de produzir: um contexto por requisição com AsyncLocalStorage e um envoltório em volta do método de consulta do driver. O resultado vai para um histograma por rota e para o log quando passa de um teto. Uma rota saudável faz entre 1 e 10 consultas por requisição. Uma rota com 400 está fazendo laço sobre dados.

import { AsyncLocalStorage } from 'node:async_hooks';
import pg from 'pg';

const contexto = new AsyncLocalStorage();
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

// Envolve pool.query: cada consulta soma no contador da requisicao em andamento.
// Para transacoes (pool.connect), envolva client.query do mesmo jeito.
const queryOriginal = pool.query.bind(pool);
pool.query = async (...args) => {
  const inicio = process.hrtime.bigint();
  try {
    return await queryOriginal(...args);
  } finally {
    const req = contexto.getStore();
    if (req) {
      req.consultas += 1;
      req.tempoDbMs += Number(process.hrtime.bigint() - inicio) / 1e6;
    }
  }
};

// Middleware: abre um contexto por requisicao e, ao terminar, registra quantas
// consultas ela fez. Acima do limite, marca como suspeita de N+1.
export function contarConsultas({ limite = 30, registrar }) {
  return (req, res, next) => {
    const medicao = { consultas: 0, tempoDbMs: 0 };
    contexto.run(medicao, () => {
      res.on('finish', () => {
        registrar({
          rota: req.route?.path ?? req.path,
          status: res.statusCode,
          consultas: medicao.consultas,
          tempoDbMs: Math.round(medicao.tempoDbMs),
          suspeitaDeNMais1: medicao.consultas > limite,
        });
      });
      next();
    });
  };
}

// app.use(contarConsultas({ registrar: (m) => { metricas.histograma('db_consultas_por_requisicao', m.consultas, { rota: m.rota }); if (m.suspeitaDeNMais1) logger.warn(m, 'possivel N+1'); } }));

Com a métrica ligada, a tela do caso aparece em minutos: a rota de pedidos faz, em média, 1.150 consultas por requisição, com p99 acima de 3.000 para os clientes grandes, enquanto todas as outras rotas ficam abaixo de 12. Se o ORM for Prisma, o evento de query serve ao mesmo propósito; em Sequelize e TypeORM, o logger de consultas recebe uma chamada por comando e pode somar no mesmo contexto.

Do lado do banco, o N+1 também tem assinatura, mas ela só aparece quando a extensão pg_stat_statements é ordenada por número de chamadas e não por tempo médio. Uma consulta por chave primária com média de 0,3 milissegundos e 4 milhões de chamadas no dia é a prova: ninguém escreve uma consulta assim fora de um laço.

-- A assinatura do N+1 vista do banco: uma consulta barata chamada milhares de vezes.
-- Ordene por chamadas, nao por tempo medio: o N+1 nunca aparece no topo das consultas lentas.
SELECT calls,
       round(mean_exec_time::numeric, 2)           AS media_ms,
       round(total_exec_time::numeric)             AS total_ms,
       round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 1) AS pct_do_tempo,
       left(query, 70)                             AS consulta
FROM pg_stat_statements
WHERE calls > 10000
ORDER BY calls DESC
LIMIT 10;

-- Exemplo do que aparece:
-- calls    media_ms  total_ms  pct_do_tempo  consulta
-- 4812330  0.31      1491822   38.2          SELECT id, nome, sku FROM produtos WHERE id = $1
-- 534700   0.44      235268    6.0           SELECT id, nome FROM clientes WHERE id = $1

03

Corrigir: trocar o laço por carregamento em lote

A correção não é fazer cada consulta mais rápida, é fazer menos consultas. Em vez de buscar o cliente de cada pedido, busca-se de uma vez todos os clientes cujos ids aparecem na página, com WHERE id = ANY($1), e monta-se um mapa em memória para associar. O mesmo para itens, eventos e produtos. O total deixa de ser 1 + N × k e vira 1 + k: uma consulta por relação, independentemente de quantas linhas a página tem.

const indexarPor = (linhas, chave = 'id') => new Map(linhas.map((linha) => [linha[chave], linha]));

const agruparPor = (linhas, chave) =>
  linhas.reduce((mapa, linha) => {
    const lista = mapa.get(linha[chave]) ?? [];
    lista.push(linha);
    return mapa.set(linha[chave], lista);
  }, new Map());

// Versao em lote: 5 consultas, com 10 ou com 100 pedidos na pagina
export async function listarPedidosDoDia(db, { porPagina = 100 } = {}) {
  const { rows: pedidos } = await db.query(
    `SELECT id, cliente_id, criado_em FROM pedidos
      WHERE criado_em >= current_date ORDER BY criado_em DESC LIMIT $1`,
    [porPagina],
  );
  if (pedidos.length === 0) return [];
  const idsPedidos = pedidos.map((p) => p.id);

  const [{ rows: clientes }, { rows: itens }, { rows: eventos }] = await Promise.all([
    db.query('SELECT id, nome FROM clientes WHERE id = ANY($1)', [
      [...new Set(pedidos.map((p) => p.cliente_id))],
    ]),
    db.query(
      'SELECT id, pedido_id, produto_id, quantidade FROM itens_pedido WHERE pedido_id = ANY($1)',
      [idsPedidos],
    ),
    // O evento mais recente de cada pedido em uma unica consulta.
    // Precisa do indice (pedido_id, ocorrido_em DESC) para nao varrer o historico inteiro.
    db.query(
      `SELECT DISTINCT ON (pedido_id) pedido_id, status, ocorrido_em
         FROM eventos_pedido
        WHERE pedido_id = ANY($1)
        ORDER BY pedido_id, ocorrido_em DESC`,
      [idsPedidos],
    ),
  ]);

  // Produtos dependem dos itens, por isso vem depois: segunda rodada, ainda uma consulta so
  const { rows: produtos } = await db.query('SELECT id, nome, sku FROM produtos WHERE id = ANY($1)', [
    [...new Set(itens.map((i) => i.produto_id))],
  ]);

  const clientePorId = indexarPor(clientes);
  const produtoPorId = indexarPor(produtos);
  const itensPorPedido = agruparPor(itens, 'pedido_id');
  const eventoPorPedido = indexarPor(eventos, 'pedido_id');

  return pedidos.map((pedido) => ({
    ...pedido,
    cliente: clientePorId.get(pedido.cliente_id) ?? null,
    itens: (itensPorPedido.get(pedido.id) ?? []).map((item) => ({
      ...item,
      produto: produtoPorId.get(item.produto_id) ?? null,
    })),
    ultimoEvento: eventoPorPedido.get(pedido.id) ?? null,
  }));
}
GET /pedidos (100 pedidos, 9 itens em media, 0,8 ms de ida e volta ate o banco)

Com N+1                                        Em lote
|- SELECT pedidos ............... 1            |- SELECT pedidos ..................... 1
|- para cada pedido:                           |- SELECT clientes WHERE id = ANY .... 1
|   |- SELECT cliente ........... 100          |- SELECT itens WHERE pedido_id = ANY  1
|   |- SELECT itens ............. 100          |- SELECT eventos DISTINCT ON ........ 1
|   |- para cada item:                         |- SELECT produtos WHERE id = ANY .... 1
|   |   |- SELECT produto ....... 900
|   |- SELECT ultimo evento ..... 100          Total: 5 consultas (constante)
Total: 1.201 consultas (cresce com os dados)   Rede: 5 x 0,8 ms = 4 ms
Rede: 1.201 x 0,8 ms = 961 ms, em serie

Três detalhes fazem diferença. O primeiro é remover ids repetidos antes de consultar: 100 pedidos de 12 clientes viram uma lista de 12 ids, não 100. O segundo é que relações independentes podem ir em paralelo com Promise.all, usando conexões distintas do pool, enquanto relações encadeadas, como produto que depende de item, esperam a rodada anterior. O terceiro é o tamanho da lista: ANY com 100 ids é trivial, mas com 50 mil ids o planejador perde eficiência e o pacote de rede cresce. Quando a página pode ser grande, divida a lista em blocos de cerca de mil ids por consulta.

Esse padrão é o que os ORMs chamam de carregamento antecipado: include no Prisma, with na maioria dos query builders, eager loading no Sequelize e no Hibernate. Por baixo, eles geram exatamente essas consultas em lote. O que o ORM não faz é obrigar você a usá-las, e qualquer acesso a uma relação não carregada dentro de um laço volta a disparar uma consulta por linha.

04

O N+1 que não está no seu laço: ORM, serializador e o "último evento"

O caso mais traiçoeiro é o laço que você não escreveu. Um serializador que acessa pedido.cliente.nome para montar o JSON, um template que percorre itens e imprime item.produto.sku, um resolver GraphQL que resolve o campo cliente de cada Pedido. Em todos esses lugares o código parece inocente, porque a consulta é disparada pelo getter preguiçoso do ORM ou pelo motor de resolução, e o laço é o framework percorrendo a lista.

Quando não dá para controlar quem chama, a solução é um carregador em lote com escopo de requisição, o padrão popularizado pelo DataLoader. Cada chamada registra o id que quer e recebe uma promessa; no fim do tick, todas as promessas pendentes são atendidas com uma única consulta. Os resolvers continuam pedindo um cliente por pedido, mas o banco recebe uma consulta por página.

// Carregador em lote com escopo de requisicao: junta os ids pedidos no mesmo tick
// e dispara uma consulta so. Resolve o N+1 que nasce fora do seu laco (resolvers
// GraphQL, serializadores, getters de ORM), sem reescrever quem chama.
export function criarCarregador(buscarEmLote) {
  const pendentes = new Map(); // id -> { resolve, reject }
  const cache = new Map(); // id -> Promise (vale so para esta requisicao)
  let agendado = false;

  const despachar = async () => {
    agendado = false;
    const lote = new Map(pendentes);
    pendentes.clear();
    try {
      const resultados = await buscarEmLote([...lote.keys()]); // Map<id, linha>
      for (const [id, { resolve }] of lote) resolve(resultados.get(id) ?? null);
    } catch (erro) {
      for (const { reject } of lote.values()) reject(erro);
    }
  };

  return {
    carregar(id) {
      if (cache.has(id)) return cache.get(id);
      const promessa = new Promise((resolve, reject) => pendentes.set(id, { resolve, reject }));
      cache.set(id, promessa);
      if (!agendado) {
        agendado = true;
        // Depois das promessas ja enfileiradas, antes da proxima fase de I/O:
        // todos os resolvers do mesmo nivel ja pediram seus ids quando isto roda.
        Promise.resolve().then(() => process.nextTick(despachar));
      }
      return promessa;
    },
  };
}

// Por requisicao (contexto do GraphQL ou do handler), nunca global:
export const criarCarregadores = (db) => ({
  clientes: criarCarregador(async (ids) => {
    const { rows } = await db.query('SELECT id, nome FROM clientes WHERE id = ANY($1)', [ids]);
    return new Map(rows.map((c) => [c.id, c]));
  }),
  produtos: criarCarregador(async (ids) => {
    const { rows } = await db.query('SELECT id, nome, sku FROM produtos WHERE id = ANY($1)', [ids]);
    return new Map(rows.map((p) => [p.id, p]));
  }),
});

// Resolver que antes fazia uma consulta por pedido:
// Pedido: { cliente: (pedido, _args, ctx) => ctx.carregadores.clientes.carregar(pedido.cliente_id) }

O cache precisa viver dentro da requisição e morrer com ela. Um carregador global, compartilhado entre requisições, devolveria dados de um usuário para outro e nunca veria atualizações. Por isso ele é criado no contexto de cada requisição, junto com a conexão ou a transação que vai usar.

Há ainda o N+1 que persiste mesmo com include: o campo calculado por linha. O "último evento de entrega" de cada pedido é uma consulta com ORDER BY e LIMIT 1 que o carregamento antecipado comum não cobre, e que por isso sobrevive à primeira rodada de correção. Em PostgreSQL, DISTINCT ON (pedido_id) com ORDER BY pedido_id, ocorrido_em DESC devolve o mais recente de cada pedido em uma consulta só, como no exemplo em lote acima. Em outros bancos, uma função de janela com ROW_NUMBER() OVER (PARTITION BY pedido_id ORDER BY ocorrido_em DESC) filtrada em 1 faz o mesmo. Nos dois casos o índice composto (pedido_id, ocorrido_em DESC) é o que transforma essa consulta em uma leitura curta por pedido em vez de uma varredura do histórico.

05

Impedir que volte: o teste que conta consultas e o seed que parece produção

Corrigir a tela resolve o incidente. Impedir que a próxima tela nasça com o mesmo defeito exige um teste que falhe quando o número de consultas depender do número de linhas. O teste não afirma um número mágico; ele executa a função com 5 pedidos e com 100, e afirma que a contagem foi a mesma. Essa afirmação é estável, sobrevive a refatorações e falha exatamente quando alguém adiciona um acesso preguiçoso dentro do laço.

import test from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import { listarPedidosDoDia } from './pedidos.js';
import { semearPedidos } from './seed.js';

// Conta as consultas feitas pelo pool durante um trecho de codigo
const comContador = (pool) => {
  let consultas = 0;
  const original = pool.query.bind(pool);
  pool.query = (...args) => {
    consultas += 1;
    return original(...args);
  };
  return { zerar: () => (consultas = 0), total: () => consultas };
};

test('o numero de consultas nao cresce com o numero de pedidos', async () => {
  const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
  const contador = comContador(pool);
  await pool.query('TRUNCATE eventos_pedido, itens_pedido, pedidos, clientes, produtos CASCADE');

  await semearPedidos(pool, { pedidos: 5, itensPorPedido: 3 });
  contador.zerar();
  await listarPedidosDoDia(pool, { porPagina: 100 });
  const comCinco = contador.total();

  await semearPedidos(pool, { pedidos: 95, itensPorPedido: 9 });
  contador.zerar();
  const pedidos = await listarPedidosDoDia(pool, { porPagina: 100 });
  const comCem = contador.total();

  assert.equal(pedidos.length, 100);
  assert.ok(pedidos.every((p) => p.cliente && p.itens.every((i) => i.produto)));
  // A unica afirmacao que importa: mesma contagem com 5 e com 100 pedidos
  assert.equal(comCem, comCinco, `5 pedidos: ${comCinco} consultas; 100 pedidos: ${comCem}`);
  assert.ok(comCinco <= 6, `esperava no maximo 6 consultas, foram ${comCinco}`);
  await pool.end();
});

O segundo mecanismo é o seed. Um ambiente de staging com 10 pedidos de 2 itens não ensaia nada: ele é o motivo de o N+1 chegar em produção. O seed precisa ter a forma dos dados reais, em especial a cardinalidade das relações: quantos itens por pedido, quantos eventos por pedido, quantos pedidos por cliente no percentil alto. Não precisa ter o volume de produção para isso; precisa ter a distribuição. Com 200 pedidos de 9 itens e 15 eventos, a tela em staging já faria 2.400 consultas e o problema seria visto antes do deploy.

SinalOnde verLimiar sugerido
Consultas por requisiçãoHistograma por rota do middlewareAlertar acima de 30 na mesma rota; investigar qualquer rota com p99 acima de 50
Chamadas altas com tempo médio baixopg_stat_statements ordenado por callsMais de 10 mil chamadas por minuto com média abaixo de 1 ms
Tempo de banco em relação ao tempo da respostaTrace da requisiçãoMais de 70% do tempo em consultas de menos de 2 ms cada
Contagem igual com N pequeno e N grandeTeste no CIFalhar se a contagem com 100 linhas for maior que com 5

Em produção, o alerta certo é sobre a contagem por requisição, não sobre a latência. A latência só sobe quando o pool já está disputado, e a essa altura várias telas estão lentas. A contagem sobe na primeira requisição depois do deploy, para o primeiro cliente grande, antes de qualquer usuário reclamar.

06

Decidir entre lote, JOIN, carregador e cache

Nem toda relação se resolve do mesmo jeito, e a escolha errada troca um problema por outro. Um JOIN único traz tudo em uma consulta, mas em relações um-para-muitos repete as colunas do pai em cada linha do filho: 100 pedidos com 9 itens viram 900 linhas com o nome do cliente repetido 900 vezes, e com dois filhos independentes no mesmo JOIN o produto cartesiano multiplica de novo. O lote por tabela custa uma consulta a mais por relação e transfere cada linha uma vez.

AbordagemConsultasQuando usarCuidado
Consulta por linha dentro do laço1 + N × kSó quando N é limitado pelo código e pequeno, por exemplo os 3 endereços de um clienteCresce com os dados; nunca em listagem paginada por volume do cliente
Lote por tabela com WHERE id = ANY1 + kPadrão para listas com relações um-para-muitos e muitos-para-umRemover ids repetidos; dividir listas acima de cerca de mil ids
JOIN único1Relações um-para-um e muitos-para-um com poucas colunasEm um-para-muitos multiplica linhas e bytes; dois filhos no mesmo JOIN viram produto cartesiano
Carregador em lote por requisição1 + k por requisiçãoGraphQL, serializadores e getters de ORM, onde o laço não é seuCache só dentro da requisição; criar no contexto, nunca global
Cache de aplicação0 no acertoDado estável e pequeno, como catálogo de produtosNão corrige a consulta; o N+1 volta inteiro no primeiro erro de cache

Uma regra prática: para listagens, lote por tabela como padrão, JOIN para as relações muitos-para-um com poucas colunas, carregador quando o framework é quem percorre a lista. Cache entra depois da correção, nunca no lugar dela, porque um cache que esconde um N+1 transforma uma invalidação ou um reinício em incidente.

FAQ

Perguntas frequentes

O ORM não resolve isso sozinho com eager loading?

Resolve as relações que você pede explicitamente com include, with ou equivalente, e gera as consultas em lote por baixo. Mas não impede o acesso preguiçoso: qualquer relação não incluída que seja tocada dentro de um laço, em um serializador ou em um template volta a disparar uma consulta por linha. Alguns ORMs permitem desligar o carregamento preguiçoso ou fazê-lo lançar erro em produção, e essa configuração vale a pena: transforma um N+1 silencioso em uma exceção que o teste pega.

Um JOIN único não é sempre melhor do que várias consultas em lote?

Não. Em relações muitos-para-um com poucas colunas, o JOIN é ótimo e economiza uma ida ao banco. Em relações um-para-muitos, ele repete as colunas do pai em cada linha do filho e, com dois filhos independentes, multiplica as linhas pelo produto das cardinalidades. O lote por tabela custa uma consulta a mais por relação, geralmente abaixo de um milissegundo cada, e transfere cada linha exatamente uma vez. A diferença entre 5 e 1 consulta é irrelevante; a diferença entre 5 e 1.201 é o incidente.

Vale colocar cache na frente em vez de corrigir a consulta?

Cache depois da correção, não no lugar dela. Um cache que esconde um N+1 mantém a tela rápida enquanto acerta, e devolve as 1.201 consultas de uma vez em cada erro de cache, invalidação em massa ou reinício do serviço, exatamente quando o sistema está mais frágil. Corrija primeiro para que a tela faça 5 consultas, e aí decida se o catálogo de produtos, que muda pouco, merece um cache para cortar uma delas.

O N+1 se mede em consultas por requisição, não em latência

A tela rápida em teste e lenta em produção não tem bug de lógica: tem um laço cujo custo depende dos dados do cliente e da distância até o banco, duas coisas que o ambiente de teste não reproduz. A saída é medir o que o teste não mede, a contagem de consultas por requisição, corrigir trocando o laço por carregamento em lote e carregadores com escopo de requisição, e travar a correção com um teste que falha quando a contagem cresce com o número de linhas. Com um seed que tem a forma dos dados reais e um alerta sobre a contagem, o próximo N+1 aparece no CI ou no primeiro minuto depois do deploy, e não no horário de pico do maior cliente.