Fundamentos de Programação/03 - Sistemas Reais/Semana 09 - Banco de Dados e SQL7 min
Problema N+1
- Por que ele aparece quase sempre com ORM e lazy loading?
- Como identificá-lo olhando o log de queries em vez de olhando o código?
- Eager loading, join fetch, batch: qual a solução em cada caso?
- Por que ele passa despercebido em dev e mata em produção?
Conceito
N+1 é o padrão em que você faz 1 consulta para buscar uma lista e depois N consultas, uma por item, para buscar algo relacionado.
SELECT * FROM pedido WHERE cliente_id = 1; -- 1 query, devolve 50 pedidos
-- para cada pedido:
SELECT * FROM cliente WHERE id = ?; -- 50 queries
51 idas ao banco onde uma bastava.
Por que aparece quase sempre com ORM e lazy loading: o ORM oferece
pedido.Cliente.Nome, que parece acesso a campo e é uma consulta. O código não tem
nenhuma marca visual do custo — a query está escondida atrás de um ponto. Num laço, você
escreveu 50 queries sem digitar nenhuma.
Como identificar — e a chave é não olhar o código:
Olhe o log de queries, não o código. No código, N+1 é invisível por construção (é um acesso a campo). No log, é óbvio: a mesma query repetida com parâmetros diferentes, centenas de vezes seguidas.
Ferramentas: log de queries com contagem por requisição, APM, ou um contador no próprio código que estoura acima de um limite.
As soluções, por caso:
| Solução | Como | Quando |
|---|---|---|
| eager loading | trazer o relacionado junto (JOIN) |
relação 1:1 ou N:1 |
| join fetch | um JOIN explícito e agrupar em memória |
você controla o SQL |
batch / IN |
uma segunda query com WHERE id IN (...) |
relação 1:N, evita fan-out |
| dataloader | acumula os ids do "tick" e resolve em lote | GraphQL, resolvers |
Batch com IN é a solução mais subestimada: duas queries no total (1 + 1), sem fan-out
e sem duplicar dados. Para 1:N ela costuma ser melhor que o join.
Duas razões que se multiplicam:
- volume: em dev você tem 10 registros; 11 queries são imperceptíveis. Em produção são 10.000 registros e 10.001 queries.
- latência: em dev o banco é local (0,05 ms de ida e volta). Em produção há rede (1–5 ms). O custo não é o trabalho do banco — é o número de viagens.
O cálculo: 10.000 queries × 2 ms de latência = 20 segundos, com o banco praticamente ocioso. É por isso que "otimizar a query" não resolve N+1 — não há query lenta.
Em Go
Go tem uma vantagem estrutural: sem ORM na stdlib, não há lazy loading. Você escreve a consulta, então o N+1 não acontece por acidente do framework.
Mas ele acontece de duas outras formas, e as duas são fáceis de escrever:
1. Laço chamando método de repositório — o N+1 clássico, escrito à mão:
for _, p := range pedidos {
c, _ := repo.BuscaCliente(ctx, p.ClienteID) // uma query por iteração
p.Cliente = c
}
2. Preload esquecido no GORM — ou pior, Preload presente mas dentro de um laço.
A correção idiomática em Go é o batch com IN, e ela tem duas queries no total:
// 1. colher os ids únicos
ids := make([]int64, 0, len(pedidos))
visto := map[int64]bool{}
for _, p := range pedidos {
if !visto[p.ClienteID] {
visto[p.ClienteID] = true
ids = append(ids, p.ClienteID)
}
}
// 2. UMA query para todos (Postgres: = ANY)
rows, err := db.QueryContext(ctx,
`SELECT id, nome FROM cliente WHERE id = ANY($1)`, pq.Array(ids))
// 3. indexar e costurar em memória
porID := map[int64]Cliente{}
for rows.Next() { var c Cliente; rows.Scan(&c.ID, &c.Nome); porID[c.ID] = c }
for i := range pedidos { pedidos[i].Cliente = porID[pedidos[i].ClienteID] }
Note o visto map: deduplicar os ids é parte da solução. Sem isso, 10.000 pedidos de 50
clientes mandariam 10.000 ids na cláusula IN.
Um detector caseiro de N+1, que vale colocar no ambiente de teste: envolva o executor e conte as queries por requisição, falhando acima de um limite. Cinco queries por endpoint é um limite razoável; se estourou, alguém escreveu um laço.
Cuidado com IN gigante: listas com dezenas de milhares de itens degradam o plano e
podem estourar o limite de parâmetros. Divida em lotes de 1.000.
E a ligação com concorrência: a tentação em Go é resolver N+1 com goroutines —
N queries em paralelo. Isso troca latência por conexões do pool, e com MaxOpenConns mal
configurado (5. Índices) você derruba o banco. Uma query em lote é melhor que N queries
paralelas.
Respostas às perguntas-guia
1. Por que ele aparece quase sempre com ORM e lazy loading?
Porque o ORM disfarça a consulta como acesso a campo — o custo fica invisível na sintaxe. Num laço, você escreve N queries sem escrever nenhuma.
Em Go: sem ORM padrão, ele aparece explicitamente como um laço chamando o repositório — o que é mais fácil de ver em revisão de código.
2. Como identificá-lo olhando o log de queries em vez de olhando o código?
Procure a mesma query repetida com parâmetros diferentes, em sequência. No código não há nada para ver; no log é inconfundível.
Em Go: envolva o executor e conte queries por requisição, falhando o teste acima de um limite. Determinístico, e pega regressão antes do deploy.
3. Eager loading, join fetch, batch: qual a solução em cada caso?
- N:1 (pedido → cliente):
JOIN, ou batch comIN - 1:N (cliente → pedidos): batch com
IN, para evitar fan-out - muitos níveis (GraphQL): dataloader, que acumula e resolve em lote
Em Go: batch com IN/= ANY é a resposta padrão, com deduplicação dos ids.
4. Por que ele passa despercebido em dev e mata em produção?
Volume (10 contra 10.000 registros) multiplicado por latência (0,05 ms local contra 2 ms em rede). 10.000 × 2 ms = 20 s, com o banco ocioso. Não há query lenta para otimizar — o problema é o número de viagens.
Trade-offs
Do conceito:
- Join traz tudo numa viagem e cobra fan-out (1. Joins) e memória.
- Batch com
INevita fan-out e custa duas viagens e o código de costura. - Eager loading sempre-ligado elimina N+1 e traz dados que ninguém pediu (o problema inverso).
- Dataloader resolve elegantemente e adiciona uma camada de complexidade.
Em Go:
- Escrever o SQL à mão previne N+1 acidental e cobra o código de agrupamento.
- Goroutines "resolvem" N+1 trocando latência por pressão no pool — é a solução errada com aparência de otimização.
INcom lista enorme degrada o plano; lotes de ~1.000 são o meio-termo.
Comandos e consultas essenciais
-- N+1: 1 + N viagens
SELECT * FROM pedido WHERE status = 'pago'; -- 1
SELECT * FROM cliente WHERE id = ?; -- N vezes
-- solução A: join (N:1). Uma viagem, com fan-out se houver 1:N.
SELECT p.*, c.nome FROM pedido p JOIN cliente c ON c.id = p.cliente_id
WHERE p.status = 'pago';
-- solução B: batch. Duas viagens, sem fan-out.
SELECT * FROM pedido WHERE status = 'pago';
SELECT id, nome FROM cliente WHERE id = ANY($1); -- ids DEDUPLICADOS
-- solução C: agregar o filho em JSON (Postgres) — uma viagem, sem fan-out
SELECT c.id, c.nome,
COALESCE(json_agg(p.*) FILTER (WHERE p.id IS NOT NULL), '[]') AS pedidos
FROM cliente c LEFT JOIN pedido p ON p.cliente_id = c.id
GROUP BY c.id, c.nome;
-- flagrar N+1 no Postgres: a mesma query normalizada, chamada milhares de vezes
SELECT calls, mean_exec_time, query FROM pg_stat_statements
ORDER BY calls DESC LIMIT 20;
pg_stat_statements ordenado por calls é o jeito mais rápido de achar N+1 em produção: a
query do topo, com tempo médio baixíssimo e contagem altíssima, é o seu N.
Relacionado
- 1. Joins — a alternativa com fan-out
- 3. Subqueries — agregar o filho sem multiplicar linhas
- 5. Índices — índice bom não salva de N+1; o custo é viagem, não trabalho
- 6. Transações e ACID — N queries numa transação retêm locks N vezes mais tempo
- 2. Ciclo de uma Requisição — semana 10, por que latência por viagem domina
Parte de Semana 09 - Banco de Dados e SQL · 00 - MOC Fundamentos de Programação