ORMs e o problema N+1
O que um ORM esconde, as consultas que ele gera sem avisar e como encontrá-las antes que deixem o sistema lento.
Os exemplos listam 50 pedidos com o nome do cliente, no ORM mais adotado de cada linguagem. Primeiro a versão com N+1, que funciona e passa nos testes; depois a correção.
O problema, em cada ORM
// Prisma
const pedidos = await prisma.pedido.findMany({ take: 50 });
for (const pedido of pedidos) {
// uma consulta por pedido
const cliente = await prisma.cliente.findUnique({
where: { id: pedido.clienteId },
});
console.log(pedido.id, cliente?.nome);
}# SQLAlchemy 2.0
pedidos = session.scalars(select(Pedido).limit(50)).all()
for pedido in pedidos:
# carregamento tardio: uma consulta por pedido
print(pedido.id, pedido.cliente.nome)// GORM
var pedidos []Pedido
db.Limit(50).Find(&pedidos)
for _, pedido := range pedidos {
// uma consulta por pedido
var cliente Cliente
db.First(&cliente, pedido.ClienteID)
fmt.Println(pedido.ID, cliente.Nome)
}// JPA / Hibernate, com @ManyToOne(fetch = FetchType.LAZY)
List<Pedido> pedidos = em
.createQuery("select p from Pedido p", Pedido.class)
.setMaxResults(50)
.getResultList();
for (Pedido pedido : pedidos) {
// carregamento tardio: uma consulta por pedido
System.out.println(pedido.getId() + " " + pedido.getCliente().getNome());
}// Entity Framework Core
var pedidos = await db.Pedidos.Take(50).ToListAsync();
foreach (var pedido in pedidos)
{
// uma consulta por pedido
var cliente = await db.Clientes.FindAsync(pedido.ClienteId);
Console.WriteLine($"{pedido.Id} {cliente?.Nome}");
}A correção, em cada ORM
// Prisma: o relacionamento vem junto
const pedidos = await prisma.pedido.findMany({
take: 50,
include: { cliente: { select: { nome: true } } },
});
for (const pedido of pedidos) {
console.log(pedido.id, pedido.cliente.nome);
}# SQLAlchemy: junção para o relacionamento para um
from sqlalchemy.orm import joinedload
pedidos = session.scalars(
select(Pedido).options(joinedload(Pedido.cliente)).limit(50)
).all()
for pedido in pedidos:
print(pedido.id, pedido.cliente.nome)// GORM: Joins para relacionamento para um (Preload faria uma consulta com IN)
var pedidos []Pedido
db.Joins("Cliente").Limit(50).Find(&pedidos)
for _, pedido := range pedidos {
fmt.Println(pedido.ID, pedido.Cliente.Nome)
}// JPA: join fetch traz o cliente na mesma consulta
List<Pedido> pedidos = em
.createQuery("select p from Pedido p join fetch p.cliente", Pedido.class)
.setMaxResults(50)
.getResultList();
for (Pedido pedido : pedidos) {
System.out.println(pedido.getId() + " " + pedido.getCliente().getNome());
}// EF Core: projeção com só o que a tela usa, numa consulta com JOIN
var pedidos = await db.Pedidos
.Take(50)
.Select(p => new { p.Id, NomeCliente = p.Cliente.Nome })
.ToListAsync();
foreach (var pedido in pedidos)
{
Console.WriteLine($"{pedido.Id} {pedido.NomeCliente}");
}O que muda de um ORM para outro
| ORM | Relacionamento para um | Coleções | Projeção |
|---|---|---|---|
| Prisma | include (por padrão, uma segunda consulta com IN) | include | select |
| SQLAlchemy | joinedload | selectinload (consulta com IN) | select(Pedido.id, Cliente.nome) com join |
| GORM | Joins("Cliente") | Preload("Itens") (consulta com IN) | Select(...) com Joins e Scan |
| JPA | join fetch ou @EntityGraph | join fetch, com cuidado na paginação | Consulta com new para um DTO |
| EF Core | Include | Include com AsSplitQuery() | Select para um tipo anônimo ou DTO |
Três detalhes que fazem diferença:
- JPA com coleções e paginação:
join fetchnuma coleção junto comsetMaxResultsleva o Hibernate a buscar todas as linhas e paginar em memória, com um aviso no log. Para coleções paginadas, busque os identificadores da página primeiro e os itens depois, ou use carregamento em lote (@BatchSize). - EF Core com várias coleções:
Includede duas coleções gera uma junção com explosão cartesiana.AsSplitQuery()troca por uma consulta para cada coleção. - Prisma: consultas
findUniquedisparadas ao mesmo tempo, como numPromise.all, são agrupadas automaticamente numa só. Num laço comawait, uma de cada vez, não são. O laço do primeiro exemplo continua sendo N+1.
Detectar: contar as consultas num teste
A forma mais confiável de impedir que o N+1 volte é um teste que conta as consultas de uma operação. Um exemplo com SQLAlchemy, que expõe um evento a cada consulta executada:
from contextlib import contextmanager
from sqlalchemy import event
@contextmanager
def contar_consultas(engine):
contador = {"total": 0}
def ao_executar(*_args):
contador["total"] += 1
event.listen(engine, "before_cursor_execute", ao_executar)
try:
yield contador
finally:
event.remove(engine, "before_cursor_execute", ao_executar)
def test_listagem_de_pedidos_nao_tem_n_mais_1(engine, session, criar_pedidos):
criar_pedidos(quantidade=30)
with contar_consultas(engine) as consultas:
resposta = listar_pedidos_com_cliente(session)
assert len(resposta) == 30
assert consultas["total"] == 1 # com N+1, seriam 31| Linguagem | Como contar as consultas |
|---|---|
| Node (Prisma) | Evento query do cliente, com log: [{ emit: "event", level: "query" }] |
| Python (SQLAlchemy) | Evento before_cursor_execute, como acima; no Django, assertNumQueries |
| Go (GORM) | Um callback registrado em db.Callback().Query(), ou um logger que conta |
| Java (Hibernate) | Statistics.getPrepareStatementCount(), com estatísticas ativadas |
| C# (EF Core) | Um DbCommandInterceptor que conta os comandos executados |
Atualizações em massa
O mesmo padrão aparece na escrita. Cancelar todos os pedidos abertos há mais de 30 dias:
// ERRADO: uma consulta para ler e um UPDATE para cada pedido
const antigos = await prisma.pedido.findMany({
where: { status: "aberto", criadoEm: { lt: limite } },
});
for (const pedido of antigos) {
await prisma.pedido.update({
where: { id: pedido.id },
data: { status: "cancelado" },
});
}
// CERTO: uma única instrução no banco
await prisma.pedido.updateMany({
where: { status: "aberto", criadoEm: { lt: limite } },
data: { status: "cancelado" },
});Os outros ORMs têm o equivalente: update() com where no SQLAlchemy, Where(...).Updates(...) no GORM, update ... where em JPQL e ExecuteUpdateAsync no EF Core. Atualizações em massa costumam pular eventos e validações que o ORM executa ao salvar cada objeto, o que precisa ser levado em conta.
Lista de revisão
Consultas
- Algum relacionamento é acessado dentro de um laço sem ter sido carregado antes?
- Algum laço chama uma função que faz consulta, direta ou indiretamente?
- Relacionamentos para um usam junção, e coleções usam consulta separada?
- Listagens usam projeção, sem trazer colunas que a tela não mostra?
Configuração
- O carregamento tardio está desligado ou configurado para falhar?
- No JPA, todos os relacionamentos estão marcados como tardios?
- O SQL gerado é registrado em desenvolvimento?
Testes
- As rotas mais usadas têm um teste que fixa o número de consultas?
- Os dados de teste têm vários registros com relacionamentos diferentes?
- Atualizações em massa usam uma única instrução no banco?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Corrigir um N+1 com um teste que conta consultas
Quero corrigir um N+1 do jeito certo: primeiro um teste que o comprova, depois a correção. Pergunte qual linguagem, ORM e ferramenta de testes eu uso. Vamos em etapas. Em cada uma, deixe que eu tente e depois mostre a sua versão: 1. Montar as entidades pedido, cliente e itens, e uma função que lista pedidos com o nome do cliente e a quantidade de itens. 2. Escrever um auxiliar de teste que conta as consultas executadas. 3. Escrever um teste com 20 pedidos de clientes diferentes que fixa o número de consultas esperado, e ver o teste falhar com a versão ingênua. 4. Corrigir com a estratégia de carregamento adequada e ver o teste passar. 5. Trocar a função por uma projeção e comparar o SQL gerado. No final, me ajude a medir o tempo das duas versões com 1.000 pedidos.
Paginação com coleções sem armadilhas
Quero listar pedidos paginados com os itens de cada pedido, sem N+1 e sem as armadilhas de paginar junto com coleções. Pergunte qual ORM eu uso. Explique, com código para o meu ORM: 1. Por que juntar uma coleção e paginar na mesma consulta dá resultado errado ou leva o ORM a paginar em memória. 2. A técnica de buscar primeiro os identificadores da página e depois os pedidos com os itens. 3. O carregamento em lote de coleções, se o meu ORM oferecer. 4. Como conferir no SQL gerado e no número de consultas que a solução está correta. Use paginação por cursor, e explique por que ela é melhor que OFFSET para listas longas.
Revisar código com ORM
Vou colar abaixo código que usa um ORM, com as entidades e as consultas. Revise com foco em consultas desnecessárias. Verifique, nesta ordem, e para cada item diga se está correto, incorreto ou ausente, citando a linha: 1. Algum relacionamento é acessado dentro de um laço sem ter sido carregado antes? 2. Alguma função chamada num laço faz consulta, direta ou indiretamente? 3. A estratégia de carregamento está adequada: junção para relacionamentos para um, consulta separada para coleções? 4. Há explosão cartesiana por carregar várias coleções numa junção? 5. Listagens trazem entidades inteiras quando uma projeção bastaria? 6. Há laços de atualização ou exclusão que poderiam ser uma única instrução? 7. A configuração de carregamento padrão do ORM favorece o N+1? Para cada problema, diga quantas consultas o código faz com 100 itens antes e depois da correção, e mostre a correção. Não reescreva o que já está correto. Código: [cole aqui]