DevGuideTK
← Trilha Backend

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.

O que um ORM faz

O código trabalha com objetos: um Pedido tem um Cliente e uma lista de Itens. O banco relacional trabalha com tabelas e chaves. Um ORM (Object-Relational Mapper) faz a ponte entre os dois mundos.

Em vez de escrever SQL e montar os objetos à mão, você declara as classes e os relacionamentos, e o ORM gera as consultas:

const pedido = await orm.pedido.buscarPorId(981);
console.log(pedido.cliente.nome);

Por baixo, ele escreve algo como SELECT ... FROM pedidos WHERE id = 981, transforma a linha num objeto e, quando o código pede pedido.cliente, busca o cliente também.

Os ORMs mais usados: Prisma e TypeORM em TypeScript, SQLAlchemy e o ORM do Django em Python, GORM em Go, Hibernate e JPA em Java, Entity Framework Core em C#.

O ORM dáO ORM esconde
Menos código repetitivoQuantas consultas são feitas
Tipos e autocompletar no editorQual SQL é gerado
Proteção contra injeção de SQL por padrãoQuando cada consulta é disparada
Migrações de esquemaO custo de cada acesso a um relacionamento

A coluna da direita é a origem do problema deste tópico.

Carregar agora ou carregar depois

Quando o código busca um pedido, o ORM precisa decidir o que fazer com os relacionamentos dele. Há duas estratégias:

  • Carregamento antecipado (eager loading): busca o pedido e o cliente juntos, de uma vez.
  • Carregamento tardio (lazy loading): busca só o pedido. O cliente é buscado no momento em que o código acessa pedido.cliente, com uma nova consulta.

O carregamento tardio parece econômico: só busca o que for usado. O problema aparece quando o acesso acontece dentro de um laço.

O problema N+1

A tela de pedidos mostra os 100 pedidos mais recentes, com o nome do cliente de cada um:

const pedidos = await orm.pedido.listar({ limite: 100 });

for (const pedido of pedidos) {
  console.log(pedido.id, pedido.cliente.nome);
}

O código tem uma consulta visível. O banco recebe 101:

SELECT * FROM pedidos ORDER BY id DESC LIMIT 100;   -- 1 consulta
SELECT * FROM clientes WHERE id = 4242;             -- e mais uma
SELECT * FROM clientes WHERE id = 117;              -- para cada
SELECT * FROM clientes WHERE id = 8810;             -- pedido...
-- ... 97 consultas iguais

É daí que vem o nome: 1 consulta para a lista e N consultas para os itens dela. O mesmo resultado sai de uma única consulta com junção:

SELECT p.id, c.nome
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
ORDER BY p.id DESC
LIMIT 100;

Por que ninguém percebe

O N+1 passa pelos testes e pela revisão por três motivos:

  • Em desenvolvimento, a lista é pequena. Com cinco pedidos, são seis consultas, e a tela abre na hora.
  • O banco local responde rápido. Na mesma máquina, cada ida e volta leva uma fração de milissegundo.
  • O código parece correto. Não há SQL à vista, e o acesso a pedido.cliente parece só ler um campo.

Medimos a diferença no PostgreSQL, com 100 pedidos:

FormaConsultasBanco na mesma máquinaCom atraso de rede simulado
N+110115,9 ms227 ms
Duas consultas, com IN21,0 ms5,4 ms
Uma consulta, com JOIN10,8 ms2,1 ms

Na mesma máquina, a diferença já é de quase 20 vezes, mas 16 milissegundos ainda parecem aceitáveis. Em produção, o banco fica em outra máquina, e cada consulta paga a ida e a volta pela rede. A coluna da direita acrescenta um pequeno atraso a cada consulta para simular isso: a tela passa de 2 para 227 milissegundos. Com várias listas na mesma página, ou com uma lista dentro de outra, o tempo chega a segundos.

O N+1 não é exclusivo de ORMs

O ORM facilita o problema, porque esconde as consultas, mas ele aparece em qualquer lugar onde um laço faz uma chamada por item:

  • código sem ORM que chama buscarCliente(id) dentro de um for;
  • resolvedores GraphQL, em que cada campo é resolvido separadamente (o tópico de estilos de API mostra esse caso);
  • chamadas a outro serviço por HTTP, uma por item da lista;
  • leituras de cache, uma chave por vez em vez de várias de uma vez.

A forma de resolver é a mesma em todos: juntar as buscas numa só, ou em poucas.

Por que isso importa quando a IA escreve o código

Assistentes escrevem código com ORM fluente e idiomático, e é exatamente esse estilo que esconde o N+1:

  • O relacionamento é acessado dentro do laço de montagem da resposta, com carregamento tardio.
  • Um laço chama uma função auxiliar que, sem estar à vista, faz uma consulta.
  • Para "corrigir" um erro de relacionamento não carregado, o carregamento tardio é ligado globalmente.
  • A correção traz todas as colunas de todas as tabelas relacionadas, quando a tela usa duas.

O próximo nível compara as formas de carregar relacionamentos e as ferramentas para detectar o problema.

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.

  • N+1 em cinco minutos

    Explique em no máximo cinco minutos de leitura o que é um ORM e o que é o problema N+1, para alguém que já usou um ORM mas nunca se preocupou com as consultas que ele gera.
    
    Cubra só o essencial: o que o ORM faz por mim, a diferença entre carregar relacionamentos agora ou depois, como um laço inocente vira centenas de consultas e por que isso só aparece em produção.
    
    Use o exemplo de uma lista de pedidos com o nome do cliente. No final, me faça três perguntas curtas para eu verificar se entendi, e corrija as minhas respostas.
  • Ver o SQL que o ORM gera

    Quero entender o que o ORM faz por baixo, olhando as consultas que ele gera.
    
    Pergunte primeiro qual linguagem e qual ORM eu uso.
    
    Depois, conduza assim, uma etapa por vez:
    1. Me mostre como ligar o registro das consultas SQL no meu ORM.
    2. Monte comigo duas entidades, pedido e cliente, com o relacionamento entre elas.
    3. Escreva uma busca de um pedido e o acesso ao cliente, e me peça para prever quantas consultas serão feitas antes de mostrar o SQL.
    4. Faça o mesmo com uma lista de pedidos percorrida num laço.
    5. Explique a diferença entre carregamento antecipado e tardio no meu ORM, e qual é o padrão dele.
    
    Em cada etapa, espere a minha previsão antes de mostrar o resultado. No final, peça que eu explique com minhas palavras por que o N+1 não aparece em desenvolvimento.
  • Encontrar o N+1 escondido

    Quero treinar o olho para reconhecer o problema N+1 em código que não parece ter consultas.
    
    Pergunte qual linguagem e qual ORM eu uso.
    
    Depois me mostre oito trechos de código, um por vez, e pergunte se cada um tem N+1 e quantas consultas ele faz com uma lista de 100 itens. Inclua:
    - um acesso a relacionamento dentro de um laço;
    - uma função auxiliar que faz consulta, chamada num laço;
    - um serializador que acessa relacionamentos ao montar o JSON;
    - um N+1 aninhado com dois níveis;
    - um laço de atualizações que poderia ser uma instrução só;
    - dois trechos corretos, para eu não achar problema em tudo.
    
    Corrija cada resposta e mostre a versão corrigida quando houver problema.
  • O N+1 fora dos ORMs

    Quero entender o problema N+1 como um padrão geral, e não só como um defeito de ORM.
    
    Explique como o mesmo problema aparece em:
    1. Código sem ORM que faz uma consulta por item num laço.
    2. Resolvedores GraphQL.
    3. Chamadas HTTP a outro serviço, uma por item.
    4. Leituras de cache, uma chave por vez.
    
    Para cada caso, mostre o código com o problema, estime o custo com uma lista de 100 itens e uma latência de rede realista, e mostre a correção (junção, IN, DataLoader, endpoint de lote ou leitura de várias chaves de uma vez).
    
    Faça uma pergunta de verificação depois de cada caso.