trilha

Fundamentos de Programação/03 - Sistemas Reais/Semana 09 - Banco de Dados e SQL7 min

Problema N+1

Perguntas-guia
  • 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.

Por que passa em dev e mata em produção

Duas razões que se multiplicam:

  1. 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.
  2. 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 com IN
  • 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 IN evita 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.
  • IN com 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


Parte de Semana 09 - Banco de Dados e SQL · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas