O problema de consultas N+1 é um padrão em que buscar N registros dispara uma query extra por registro — N+1 idas ao banco em vez de uma ou duas. É o bug sério de performance mais comum em aplicações apoiadas em dados, e o mais camuflado: cada query individual é rápida, o código lê perfeitamente, e a página funciona — até o dia em que os volumes reais de dados chegam.
Principais pontos
| Pergunta | Resposta |
|---|---|
| O formato | 1 query para a lista + N queries para relações = N+1 idas ao banco |
| A causa | Lazy loading tocado dentro de um loop — queries invisíveis por iteração |
| A assinatura | Muitas queries rápidas e idênticas — logs de queries lentas nunca a veem |
| As saídas | Eager loading · joins · IN em batch · loaders por requisição |
| O multiplicador | Latência de ida e volta × tamanho da página — brutal sobre a rede |
O bug, às claras
// 1 query: busca 100 posts
const posts = await postRepo.findRecent(100);
for (const post of posts) {
// +1 query POR POST — post.author parece uma propriedade,
// mas o lazy loading roda: SELECT * FROM users WHERE id = ?
render(post.title, (await post.author).name);
}
// Log de queries: 1 + 100 = 101 idas ao banco para renderizar uma página
A correção, como os SDKs a expressam — declare a relação de antemão, e a plataforma a busca na mesma requisição:
// JavaScript / Node.js — Back4app JS SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
const query = new Parse.Query('Comment');
query.equalTo('post', post);
query.include('author'); // eager-load the pointer
const comments = await query.find(); // one round trip, total
comments.forEach((c) =>
render(c.get('text'), c.get('author').get('username')) // already loaded
); // Flutter / Dart — Back4app Flutter SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
final query = QueryBuilder<ParseObject>(ParseObject('Comment'))
..whereEqualTo('post', post.toPointer())
..includeObject(['author']); // eager-load the pointer
final response = await query.query(); // one round trip, total // iOS / Swift — Back4app Swift SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
let query = Comment.query("post" == post)
.include("author") // eager-load the pointer
query.find { result in
if case .success(let comments) = result { // one round trip, total
comments.forEach { render($0.text, $0.author?.username) }
}
} // Android / Kotlin — Back4app Android SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
val query = ParseQuery.getQuery<ParseObject>("Comment")
query.whereEqualTo("post", post)
query.include("author") // eager-load the pointer
query.findInBackground { comments, e -> // one round trip, total
if (e == null) comments.forEach {
render(it.getString("text"), it.getParseObject("author")?.getString("username"))
}
} O custo, na ponta do lápis
A aritmética que os explicadores pulam — tempo total ≈ query da lista + (N × latência de ida e volta):
| Tamanho da página N | @1 ms/query | @5 ms/query | Em batch (1–2 queries) |
|---|---|---|---|
| 10 | ~11 ms | ~55 ms | ~10 ms |
| 100 | ~101 ms | ~505 ms | ~12 ms |
| 1.000 | ~1,0 s | ~5,0 s | ~20 ms |
Correções publicadas batem com a matemática: páginas caindo de 1,4 s para 0,16 s, endpoints acelerando 30× ou mais. Dois corolários que valem fixar: paginação limita N — um tamanho de página limitado limita o raio da explosão antes mesmo da correção de verdade; e a versão em rede é pior — quando cada uma das N chamadas é uma requisição HTTP em vez de uma query local, multiplique por dezenas de milissegundos em vez de um dígito.
As quatro saídas
| Correção | Como | Escolha quando | Cuidado com |
|---|---|---|---|
| Eager loading / include | Declare as relações na query | A tela sempre precisa da relação | Incluir demais incha os payloads |
| Join | Uma instrução busca os dois | Motor relacional, leituras em formato de relatório | Explosão de linhas em joins largos |
| IN em batch | Colete as chaves, busque uma vez | Qualquer stack, até feito à mão | Uma ida extra ao banco (aceitável) |
| Loader por requisição | Batch automático na execução | Resolvers GraphQL, código em camadas | Precisa ser por requisição, não global |
A última linha é o caso famoso do GraphQL: resolvers disparam por objeto pai, recriando o loop no servidor, e o padrão loader — coletar chaves num tick, buscar uma vez, memoizar por requisição — é a cura padrão da indústria. Uma regra sutil vem junto: loaders têm escopo de requisição; um global vira um cache velho com bugs de autorização.
Detecção: cace as queries rápidas
A assinatura do N+1 é o inverso do trabalho normal de performance: você procura muitas queries rápidas e idênticas, não uma lenta — motivo pelo qual logs de queries lentas, a ferramenta usual, nunca a veem. Os métodos que veem: log de debug do ORM (a mesma instrução, N parâmetros diferentes, uma renderização); as visões de spans do APM, onde a cascata de spans curtos idênticos é inconfundível; e o mais barato de todos — conte as queries de um carregamento de página em desenvolvimento, com um limiar na cabeça: uma página de lista deve custar queries em um dígito, não em múltiplos das suas linhas. O gêmeo do lado da escrita merece a mesma auditoria: um loop de insert por item é o N+1 das escritas, corrigido com operações em bulk.
Casos de uso comuns
- Listas com autores, donos ou status — o lar canônico: todo feed, inbox e tabela que junta pessoas a itens.
- APIs GraphQL — campos de lista aninhados são estruturalmente N+1 até existirem loaders.
- Bancos de documentos — um find por documento referenciado; corrigido com include, IN em batch ou embutindo, conforme as regras de modelagem.
- Fan-outs de microsserviços — uma lista do serviço A, uma chamada HTTP ao serviço B por item; endpoints compostos e BFFs existem para acabar com isso.
- Jobs em background — o loop que processa 10.000 registros com duas queries cada, custando horas em silêncio.
Lazy vs. eager loading: matriz de decisão
| Carregue eager quando… | Fique no lazy quando… |
|---|---|
| A relação renderiza em toda linha | A relação está atrás de um clique |
| O loop é o padrão de acesso | O acesso é um registro por vez |
| N é do tamanho da página ou maior | N é garantidamente minúsculo |
| A latência é visível ao usuário | Uma tarefa em background tolera a espera |
| Você acabou de corrigir este bug aqui | Você mediu, em vez de supor |
E a regra de auditoria permanente que sobrevive a qualquer matriz: toda relação tocada dentro de um loop é culpada até o log de queries provar o contrário.
Limitações e trade-offs
- Eager loading pode corrigir demais. Incluir relações pesadas em todo lugar troca N+1 por payloads inchados e joins largos — inclua o que a tela renderiza, não o grafo inteiro.
- Joins têm seu próprio penhasco. Joins um-para-muitos duplicam as linhas do pai por filho; com fan-out alto, a busca em batch de duas queries vence o join único.
- Loaders adicionam maquinário. Escopo por requisição, invalidação de cache dentro da requisição e janelas de batching são código de verdade — o preço do batch automático.
- Frameworks o reintroduzem em silêncio. Serializers, helpers de template e reviews de “só mais um campo” são como páginas corrigidas regridem; a checagem de contagem de queries pertence ao CI, não à memória.
- A correção é por caminho, não global. N+1 é um bug de padrão de acesso; cada tela nova refaz a pergunta.
O N+1 no Back4app
O Back4app é uma plataforma open-source de Backend as a Service (BaaS) que combina banco de dados gerenciado, APIs REST e GraphQL geradas automaticamente, autenticação, armazenamento de arquivos e funções serverless com Cloud Code. Seus SDKs fazem da correção o idioma, não a remediação: relações são Pointers tipados, e o include() — as abas de código acima — as busca na mesma requisição, agrupadas no servidor. A API GraphQL resolve queries aninhadas sem fan-out por campo, e os padrões de paginação limitam N antes de ele criar dentes. O loop que causa o N+1 não tem forma natural de ser escrito — que é o melhor tipo de correção: a que ninguém precisa lembrar.
Perguntas frequentes
O que é o problema de consultas N+1, em termos simples?
Você busca uma lista de N registros com uma query, e aí o seu código faz mais uma query por registro para os dados relacionados — cem posts viram cento e uma queries. Cada query é individualmente rápida, e é exatamente por isso que o problema se esconde: nada é lento o bastante para alarmar alguém, até a página fazer centenas de idas ao banco.
O que causa queries N+1?
Lazy loading como padrão. ORMs e SDKs deixam você navegar relações como propriedades de objeto — post.author — e rodam uma query de forma transparente quando você toca numa delas. Coloque esse acesso dentro de um loop sobre N resultados e você escreveu N queries em silêncio. A abstração que tornou o acesso a dados agradável também tornou as idas ao banco invisíveis.
Como corrigir o problema N+1?
Quatro saídas padrão, escolhidas caso a caso: eager loading — avisar à query, de antemão, para incluir a relação; um join que busca os dois numa instrução só; batching — coletar as N chaves estrangeiras e buscá-las numa única query de contido-em; e loaders com escopo de requisição, que agrupam automaticamente. As quatro transformam N+1 idas ao banco em uma ou duas.
Quão mais lento o N+1 é de verdade?
Faça a conta: cada ida ao banco custa de um a cinco milissegundos antes de a query sequer rodar, então 100 linhas a 5 ms somam meio segundo, contra uma query em batch de ~10 ms. Correções publicadas do mundo real relatam páginas indo de cerca de 1,4 segundo para 0,16, e endpoints de API acelerando trinta vezes. A matemática piora linearmente com o tamanho da página — e catastroficamente sobre a rede.
Por que o problema N+1 é tão comum em GraphQL?
Porque resolvers rodam por campo, por objeto. Uma query de posts com seus autores roda o resolver de posts uma vez e o resolver de autor N vezes — o loop de ORM renascido no servidor. A cura canônica é o padrão loader: um agrupador com escopo de requisição que coleta os IDs de autor durante a execução e dispara uma busca em batch, memoizada para a requisição.
Como detectar queries N+1?
Procure muitas queries idênticas e rápidas, não uma lenta — essa é a assinatura. O log de debug do ORM mostra a mesma instrução repetida com parâmetros diferentes; logs de queries lentas erram o alvo por completo, porque cada query é rápida; ferramentas de APM sinalizam o padrão de spans explicitamente. O hábito que pega cedo: leia o log de queries de uma renderização de página, e conte.
O problema N+1 acontece em bancos de documentos e APIs REST?
Em todo lugar onde dados têm relações. Em bancos de documentos, é um find por documento referenciado — resolvido com mecanismos de include, queries de contido-em em batch, ou embutindo. Sobre REST, é um endpoint de lista mais uma chamada HTTP por item — pior que a versão de banco, porque a latência de rede engole a latência de query; endpoints compostos, endpoints de batch e linguagens de consulta existem em boa parte para matar isso.
Lazy loading é sempre errado?
Não — ele é errado dentro de loops. Lazy loading é exatamente certo quando os dados relacionados raramente são necessários: pague por eles no único registro que precisa, em vez de pagar de forma eager por todos os N. A disciplina é saber o padrão de acesso de cada tela: relações sempre necessárias carregam eager, as raramente necessárias carregam lazy, e qualquer coisa dentro de um loop passa por auditoria.