kastor0.1.0
Começar

GUIA 03Consultas N+1

Problema N+1: como detectar e evitar consultas N+1 em TypeScript

O problema N+1 é a causa mais comum de páginas lentas em aplicações com ORM: uma consulta para a lista e mais uma para cada item dela. Este guia mostra como reconhecer o padrão, como medir com log: true e como eliminá-lo com include, listas in e _count.

O que é o problema N+1

O problema N+1 aparece quando o código busca uma lista e, para cada item dessa lista, faz mais uma consulta ao banco. Uma lista de 50 posts com o nome do autor vira 51 consultas: uma para os posts e 50 para os autores.

Em desenvolvimento, com o banco na mesma máquina, cada consulta leva menos de um milissegundo e ninguém percebe. Em produção, cada ida ao banco paga a latência de rede. Com o banco em outra zona ou em um serviço gerenciado, alguns milissegundos por consulta multiplicados por 50, ou por 500, viram uma página lenta. Pior: o custo cresce junto com os dados, então o problema piora sozinho com o tempo.

O padrão é fácil de criar sem querer, porque cada linha de código parece inocente:

src/feed.ts
import { db } from './db/client'

// N+1: one query for the posts, then one query per post
const posts = await db.posts.findMany({ where: { published: true }, take: 50 })

const feed = []
for (const post of posts) {
  const author = await db.users.findUnique({ where: { id: post.authorId } })
  feed.push({ ...post, authorName: author?.name ?? 'anon' })
}

A mesma coisa acontece de formas menos óbvias: um Promise.all com um map que consulta o banco, um resolver de GraphQL que busca o autor de cada post, uma função auxiliar chamada dentro de um componente que renderiza uma lista. O Promise.all até dispara as consultas em paralelo, mas continua ocupando N conexões do pool e fazendo N idas ao banco.

Como detectar consultas N+1

A forma mais direta de achar consultas N+1 é olhar o que realmente chega ao banco. No kastor, ative o log no cliente durante o desenvolvimento:

src/db/client.ts
import pg from 'pg'
import { kastor, one, many } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { users, posts } from './schema'

export const db = kastor({
  adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })),
  schema: { users, posts },
  relations: ({ users, posts }) => ({
    users: { posts: many(posts) },
    posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
  }),
  // Print every statement in development only
  log: process.env.NODE_ENV !== 'production',
})

Em vez de true, a opção log também aceita uma função, que recebe { sql, params, durationMs, error } para cada instrução. Com ela, um teste pode contar as consultas de uma requisição. Agora carregue a página suspeita e leia o terminal. O sinal de N+1 é inconfundível: a mesma instrução repetida muitas vezes seguidas, mudando apenas o valor do parâmetro. Se uma lista de 20 itens gera 21 linhas de log, você encontrou o culpado.

Algumas dicas para não deixar passar:

  • Teste com volumes realistas. Com dois registros no seed, um N+1 gera só três consultas e passa despercebido. Use factory e db seed para criar algumas centenas de linhas.
  • Olhe o número de consultas por requisição, não só o tempo. O tempo local engana; a contagem não.
  • Use query.toSQL() para ver o que uma consulta específica vai gerar sem executá-la, e o playground para ver o SQL exato em cada banco.

Em produção, o lugar para olhar é a observabilidade do próprio banco. No PostgreSQL, a extensão pg_stat_statements mostra quantas vezes cada instrução rodou. Uma busca simples por chave primária com número de chamadas muito acima do número de requisições da aplicação é um forte indício de N+1 escondido em algum laço.

A correção principal: include em uma consulta

A solução é pedir os dados relacionados na própria consulta, em vez de buscá-los depois. Com as relações declaradas no cliente, basta um include:

src/feed.ts
import { db } from './db/client'

const feed = await db.posts.findMany({
  where: { published: true },
  orderBy: { createdAt: 'desc' },
  take: 50,
  include: {
    author: { select: { id: true, name: true } },
  },
})

// feed[0].author.name is typed, no extra query happened

Nos bancos com agregação JSON (PostgreSQL, CockroachDB, MySQL, MariaDB, a família SQLite, SQL Server e Oracle), o kastor monta uma única consulta por chamada raiz. Os dados relacionados voltam agregados em JSON e são convertidos para os tipos certos, como Date para colunas datetime. Isso vale para includes aninhados também: autores com posts com comentários continuam sendo uma ida ao banco.

No MongoDB, cada leitura é uma única chamada aggregate, e cada include, com seu próprio where, orderBy, take e includes aninhados, vira um estágio $lookup dentro dela. O compilador SQL compartilhado também conhece uma segunda estratégia, para bancos sem agregação JSON: uma consulta extra por nível de include, com uma lista IN contendo as chaves do nível anterior, em que um include de dois níveis vira três consultas, sejam 10 ou 10 mil linhas. Todos os adaptadores SQL do kastor usam agregação JSON hoje, então esse caminho só aparece em dialetos próprios. Em qualquer caso, o número de consultas é fixo, nunca proporcional aos dados.

Uma consulta maior não é automaticamente uma consulta lenta. O banco planeja a junção uma vez e percorre os dados relacionados usando índices. Por isso, garanta que toda chave estrangeira usada em includes tenha índice, como o index(p.authorId) declarado nas opções da tabela posts. Sem ele, cada busca de relação pode virar uma leitura completa da tabela, e o ganho de eliminar o N+1 diminui bastante.

Quando os IDs vêm de fora: lista IN manual

Às vezes você não tem uma relação para incluir. Os IDs vieram de uma fila, de uma API externa ou de um cache. A tentação é fazer um findUnique por ID. Em vez disso, busque todos de uma vez com in e organize na memória:

src/authors-by-id.ts
import { db } from './db/client'

export async function loadAuthors(ids: number[]) {
  const unique = [...new Set(ids)]
  const rows = await db.users.findMany({
    where: { id: { in: unique } },
    select: { id: true, name: true },
  })
  // Map for O(1) lookups when stitching results back together
  return new Map(rows.map((u) => [u.id, u]))
}

É a mesma ideia de um DataLoader: juntar chaves, fazer uma consulta, distribuir os resultados. Para listas muito grandes, divida em blocos de algumas centenas ou milhares de IDs, porque os bancos têm limites de parâmetros por instrução.

_count, select e take: carregue só o necessário

Eliminar o N+1 é o primeiro passo. O segundo é não trazer mais dados do que a tela usa. Três ferramentas cobrem quase todos os casos.

_count para números

Se a tela mostra "12 posts" ao lado do nome do autor, não carregue os 12 posts. Peça só a contagem:

src/authors.ts
import { db } from './db/client'

const authors = await db.users.findMany({
  select: {
    id: true,
    name: true,
    _count: { select: { posts: true } },
    posts: {
      where: { published: true },
      orderBy: { createdAt: 'desc' },
      take: 3,
      select: { id: true, title: true },
    },
  },
  take: 20,
})

select para colunas

Um include sem select traz todas as colunas da tabela relacionada, inclusive campos text grandes. Dentro do include, use select para listar só o que a interface mostra. O tipo do resultado encolhe junto, então o compilador avisa se algum componente dependia de um campo que você removeu.

take dentro do include

Relações para muitos podem crescer sem limite. Um autor ativo pode ter milhares de posts. Cada include aceita seu próprio where, orderBy, take e skip, como no exemplo acima, que traz só os três posts publicados mais recentes. Para listas paginadas na raiz, prefira findPage, que faz paginação por cursor: passe o tamanho da página em first e o pageInfo.endCursor da página anterior em after, e o resultado chega como items mais pageInfo.

Checklist para revisão de código

Ao revisar um pull request que mexe com acesso a dados, procure estes sinais:

  • Qualquer await db. dentro de for, forEach ou map. Quase sempre dá para trocar por um include ou por um in.
  • Funções que recebem um único ID e são chamadas a partir de uma lista. Ofereça uma versão que recebe vários IDs.
  • Includes sem take em relações para muitos que podem crescer.
  • Carregar uma relação inteira só para usar o length. Troque por _count.

Se você usa Next.js, cada componente de servidor que consulta o banco dentro de uma lista é um candidato a N+1. O guia de Next.js com PostgreSQL mostra como concentrar a busca no componente pai. E para entender o resto da API de consultas, veja o guia de ORM para TypeScript.

Perguntas frequentes

Q1O que é o problema N+1 em banco de dados?

É quando o código faz uma consulta para buscar uma lista de N registros e depois mais uma consulta para cada registro, para carregar dados relacionados. O total é N+1 idas ao banco, e o tempo cresce junto com o tamanho da lista.

Q2Como saber se minha aplicação tem consultas N+1?

Ative o log de consultas passando log: true ao criar o cliente com kastor() e carregue a página. Se você vir a mesma instrução repetida dezenas de vezes mudando só o parâmetro, é N+1. Contar consultas por requisição em testes também ajuda.

Q3O include do kastor pode gerar N+1?

Não. Em todos os adaptadores SQL, os includes aninhados são carregados em uma única consulta por chamada raiz, montada com agregação JSON. No MongoDB, eles viram estágios $lookup de uma única agregação. Nos dois casos o número de consultas é fixo e não depende de quantas linhas voltam.

Q4Como contar registros relacionados sem carregar todos?

Use _count dentro de include ou select, por exemplo include: { _count: { select: { posts: true } } }. O banco devolve só o número, sem trazer as linhas para a memória.

Q5Usar SQL puro resolve o problema N+1?

Só se a consulta for escrita para buscar tudo de uma vez. Um db.$sql dentro de um laço continua sendo N+1. A correção está no formato do acesso, não na ferramenta: busque em lote e agrupe na memória.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.