O problema de N+1 queries é um dos bugs de performance mais antigos e mais documentados do desenvolvimento web, e ainda assim aparece em code review toda semana. Não porque os engenheiros não saibam da existência dele — pergunte a qualquer um com alguns anos de experiência e ele explica em uma frase. Ele continua aparecendo porque as ferramentas que deveriam facilitar o acesso a dados também são muito boas em esconder exatamente o padrão que causa isso.
O que acontece de fato
O formato é sempre o mesmo: uma query busca uma lista de N registros, e então um loop sobre essa lista dispara uma query adicional por registro pra buscar dados relacionados — um autor pra cada post, um cliente pra cada pedido, um gestor pra cada funcionário. São N+1 idas ao banco de dados pra algo que poderia ter sido duas queries, ou uma com join. Com dez registros isso é invisível em dev local. Com dez mil, em produção, sob carga, é o motivo de uma página que carregava em 200ms agora levar onze segundos.
Por que ORMs pioram isso, não melhoram
A proposta inteira de um ORM é você escrever post.author.name e não pensar em SQL. Esse é exatamente o problema: post.author é uma chamada de método que silenciosamente dispara uma query na primeira vez que é acessada, e não existe nada na sintaxe que diferencie "acesso barato de propriedade em memória" de "ida e volta pela rede até o banco de dados". Um loop que chama .author em cada um de mil posts se lê como mil acessos baratos. São mil queries. A abstração que deveria esconder a complexidade do banco de dados teve tanto sucesso que escondeu justamente a parte em que a complexidade continua existindo.
GraphQL não corrigiu isso, só mudou de lugar
Resolvers do GraphQL têm exatamente o mesmo modo de falha, só que com outra roupagem. Um resolver ingênuo pra Post.author roda uma query por post no conjunto de resultados, porque cada resolver de campo é escrito e executado como se fosse independente de todos os outros — o que é verdade pra correção e falso pra performance. A correção, agrupar a resolução de campos através de algo como DataLoader, existe exatamente porque essa é uma forma tão comum de reintroduzir N+1 num sistema que não se parece em nada com um ORM tradicional.
Por que isso está ficando mais comum de novo, não menos
Agentes de IA pra código são bons em produzir código localmente correto — um loop que chama um método pra cada item de uma lista é exatamente o tipo de padrão que eles geram com confiança e frequência, porque se lê com clareza e faz a coisa certa do ponto de vista funcional. O que eles não têm automaticamente é a intuição de runtime de que registro_relacionado.campo pode ser uma chamada de rede, a menos que a base de código ou o prompt torne esse custo explícito. A mesma disciplina de revisão que costumava pegar isso num pull request humano precisa pegar num de agente, e pelo mesmo motivo: o código está correto e a contagem de queries é invisível até alguém rodar EXPLAIN ou olhar o log de queries sob carga.
A correção é visibilidade, não esperteza
As ferramentas pra prevenir isso são pouco glamorosas e bem conhecidas: eager loading (includes, select_related, .with()) pra buscar dados relacionados num número fixo de queries em vez de um variável, data loaders com batching pra qualquer coisa no formato de resolver e — a parte que os times pulam — um contador de queries no CI ou um log de queries lentas em staging que falha alto quando uma única requisição dispara um número de queries fora do razoável. Pegar isso em code review exige que alguém repare num loop com um acesso a relacionamento dentro dele, que é exatamente o tipo de coisa fácil de passar batido lendo um diff e óbvia no momento em que você olha o que de fato bateu no banco de dados.
A lição
N+1 não é um bug que foi corrigido por ORMs melhores, query builders melhores, ou frameworks melhores — cada geração de ferramental reintroduziu ele numa sintaxe nova, porque o problema de fundo nunca foi a sintaxe. É que "acessar um campo relacionado" e "fazer uma chamada de rede" parecem idênticos no código e não são. Até que uma base de código torne essa distinção visível — através de eager loading por padrão, batching, ou um orçamento de queries que derruba o build — esse bug tem um lar permanente, não importa qual framework esteja na moda esse ano.
Vamos conversar?
Tem um projeto, uma vaga ou só quer trocar uma ideia sobre o assunto? Envie uma mensagem.
Entrar em contato