Blog

Query N+1: como detectar e matar.

Você lista cinquenta pedidos e a tela demora quatro segundos. Ninguém entende, porque a query parece simples e a tabela tem índice.

Abre o log do banco e encontra 51 queries. Uma pra buscar os pedidos, e uma por pedido pra buscar o cliente.

Esse é o N+1, e ele é responsável por mais lentidão em aplicação com ORM do que qualquer outra causa que eu consiga listar.

Por que ele é invisível

Em desenvolvimento você tem doze pedidos no banco local. Treze queries de meio milissegundo somam sete milissegundos. Ninguém percebe.

Em produção são quinhentos pedidos por página, o banco está em outra máquina, e cada ida e volta custa dois milissegundos de rede. Quinhentas idas viram um segundo só de latência de rede, sem contar o trabalho do banco.

O custo do N+1 não está no banco, está no número de viagens. É por isso que otimizar a query não resolve: o problema é a quantidade delas.

Como ele aparece no código

const orders = await orderRepo.find({ take: 50 });

for (const order of orders) {
  // cada acesso a uma relação lazy dispara uma query
  console.log(order.customer.name);
}

A versão mais traiçoeira é a que acontece no serializador, longe do lugar onde o dado foi buscado:

// no controller: uma query, tudo lindo
const orders = await this.service.findAll();
return orders;

// no DTO de resposta, três camadas abaixo:
@Expose()
get customerName() {
  return this.customer.name;   // e aqui saem 50 queries
}

Quem escreveu o controller não tem como saber. É por isso que revisão de código sozinha não pega N+1 de forma confiável.

Detecção que funciona

Não confie em leitura de código. Instrumente.

O jeito mais direto é contar queries por requisição e falhar quando passar de um limite:

// interceptor em ambiente de teste e homologação
let count = 0;
dataSource.logger = {
  logQuery: () => { count++; },
};

// no fim da requisição
if (count > 20) {
  logger.warn(`Possível N+1: ${count} queries em ${req.method} ${req.url}`);
}

Vinte é arbitrário. O valor certo é o que gera pouco ruído no seu sistema. O importante é que exista um número, porque sem número ninguém percebe a degradação gradual.

Em teste automatizado, dá pra ser mais rigoroso:

it("lista pedidos sem N+1", async () => {
  await seedOrders(50);
  const { queryCount } = await countQueries(() =>
    request(app).get("/orders?limit=50")
  );
  expect(queryCount).toBeLessThan(5);
});

Esse teste é o que impede a regressão. Alguém adiciona um campo no DTO daqui a oito meses, o teste quebra, e o problema morre antes do deploy.

As correções

Eager loading explícito. Diga quais relações você precisa.

const orders = await orderRepo.find({
  take: 50,
  relations: ["customer", "items"],
});

Cuidado com o efeito colateral: carregar muitas relações OneToMany no mesmo find produz um produto cartesiano. Cinquenta pedidos com dez itens e cinco pagamentos viram 2.500 linhas transportadas pra montar 50 objetos. O N+1 sumiu e apareceu um problema pior.

Quando tiver mais de uma coleção, prefira consultas separadas.

Duas queries e um mapa. Simples, previsível, e quase sempre a melhor:

const orders = await orderRepo.find({ take: 50 });
const customerIds = [...new Set(orders.map((o) => o.customerId))];
const customers = await customerRepo.findBy({ id: In(customerIds) });
const byId = new Map(customers.map((c) => [c.id, c]));

Duas viagens em vez de 51. Sem produto cartesiano. É o padrão que o DataLoader do GraphQL automatiza, e que dá pra fazer na mão em qualquer lugar.

Projeção. Se você só precisa do nome do cliente, não carregue o cliente inteiro:

const rows = await orderRepo
  .createQueryBuilder("o")
  .leftJoin("o.customer", "c")
  .select(["o.id", "o.total", "c.name"])
  .take(50)
  .getRawMany();

Menos dado no fio, menos objeto instanciado, menos memória.

O caso especial do GraphQL

GraphQL transforma o N+1 no comportamento padrão, porque cada resolver de campo roda independente.

Sem DataLoader, uma consulta pedindo cinquenta pedidos com cliente e endereço do cliente gera 101 queries. Com DataLoader, que agrupa as chamadas do mesmo tick do event loop em uma consulta só, gera três.

Se o projeto tem GraphQL e não tem DataLoader, esse é o item mais rentável do backlog, com folga.

Quando o N+1 é aceitável

Existe caso.

Se N é sempre pequeno e limitado por regra de negócio, uma organização tem no máximo três unidades, cinco queries podem ser mais legíveis que um join complicado. Otimização prematura também custa.

O que não é aceitável é não saber. A pergunta que vale fazer em toda listagem: quantas queries essa página dispara quando tem mil registros? Se ninguém no time sabe responder, é o momento de instrumentar.

Sobre o outro lado do problema, quando a query é uma só e mesmo assim está lenta, escrevi em índices no Postgres.

← Voltar pro blog