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.