Como usar DataLoader para resolver o problema N+1 em GraphQL

1. Compreendendo o Problema N+1 em GraphQL

O problema N+1 é uma das dores mais comuns em aplicações GraphQL. Ele ocorre quando uma consulta inicial gera N consultas adicionais ao banco de dados para resolver relacionamentos aninhados. Vamos entender com um exemplo clássico.

Suponha que você queira listar todos os autores com seus respectivos posts. Uma query GraphQL típica seria:

query {
  autores {
    id
    nome
    posts {
      titulo
      conteudo
    }
  }
}

Sem otimização, o resolver de autores executaria:

-- Primeira query: buscar todos os autores (1 consulta)
SELECT * FROM autores;

-- Para cada autor, uma consulta adicional (N consultas)
SELECT * FROM posts WHERE autor_id = 1;
SELECT * FROM posts WHERE autor_id = 2;
SELECT * FROM posts WHERE autor_id = 3;
-- ... e assim por diante

Isso resulta em 1 + N consultas ao banco. Com 100 autores, você teria 101 consultas. O impacto é devastador: latência elevada, sobrecarga no banco de dados e degradação severa de performance.

2. O que é DataLoader e Como Funciona

DataLoader é uma biblioteca criada pelo Facebook que resolve o problema N+1 através de dois mecanismos principais: batching e caching.

  • Batching: Agrupa múltiplas requisições individuais em uma única chamada em lote. Em vez de fazer N consultas separadas, faz apenas uma consulta com todos os IDs.
  • Caching: Armazena em memória os resultados já carregados durante uma requisição, evitando buscas repetidas para o mesmo recurso.

O mecanismo interno funciona assim: quando você chama loader.load(id), o DataLoader não executa imediatamente a consulta. Em vez disso, ele acumula todos os IDs em uma fila. No próximo ciclo do event loop (microtask), ele executa a função de batch com todos os IDs acumulados de uma só vez.

3. Instalação e Configuração Básica do DataLoader

Primeiro, instale a biblioteca:

npm install dataloader

Agora, crie um DataLoader básico para a entidade "Autor":

const DataLoader = require('dataloader');
const db = require('./database');

// Função de batch: recebe um array de IDs e retorna um array de resultados na mesma ordem
const batchAutores = async (ids) => {
  const autores = await db.query(
    `SELECT * FROM autores WHERE id IN (${ids.join(',')})`
  );

  // Mapear resultados mantendo a ordem original dos IDs
  const autorMap = {};
  autores.forEach(autor => {
    autorMap[autor.id] = autor;
  });

  return ids.map(id => autorMap[id] || null);
};

const autorLoader = new DataLoader(batchAutores);

A estrutura é simples: uma função que recebe um array de chaves e retorna um array de valores na mesma ordem. Se um ID não for encontrado, retorna null na posição correspondente.

4. Integração do DataLoader com Resolvers GraphQL

Para usar DataLoader nos resolvers, precisamos injetá-lo no contexto da requisição. Isso garante que cada requisição tenha seu próprio cache isolado.

const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const DataLoader = require('dataloader');
const db = require('./database');

const app = express();

// Context factory: cria DataLoaders frescos para cada requisição
app.use('/graphql', graphqlHTTP((req) => ({
  schema,
  context: {
    autorLoader: new DataLoader(async (ids) => {
      const autores = await db.query(
        `SELECT * FROM autores WHERE id IN (${ids.join(',')})`
      );
      const autorMap = {};
      autores.forEach(a => { autorMap[a.id] = a; });
      return ids.map(id => autorMap[id] || null);
    }),
    postLoader: new DataLoader(async (ids) => {
      const posts = await db.query(
        `SELECT * FROM posts WHERE id IN (${ids.join(',')})`
      );
      const postMap = {};
      posts.forEach(p => { postMap[p.id] = p; });
      return ids.map(id => postMap[id] || null);
    }),
    postsPorAutorLoader: new DataLoader(async (autorIds) => {
      const posts = await db.query(
        `SELECT * FROM posts WHERE autor_id IN (${autorIds.join(',')})`
      );
      // Agrupar posts por autor_id
      const postsPorAutor = {};
      posts.forEach(post => {
        if (!postsPorAutor[post.autor_id]) {
          postsPorAutor[post.autor_id] = [];
        }
        postsPorAutor[post.autor_id].push(post);
      });
      return autorIds.map(id => postsPorAutor[id] || []);
    })
  }
})));

Agora, nos resolvers, substituímos chamadas diretas ao banco por loader.load(id):

const resolvers = {
  Query: {
    autores: async (_, __, { autorLoader }) => {
      const autores = await db.query('SELECT * FROM autores');
      return autores;
    }
  },
  Autor: {
    posts: async (autor, _, { postsPorAutorLoader }) => {
      return postsPorAutorLoader.load(autor.id);
    }
  }
};

Com essa abordagem, a consulta anterior que gerava N+1 consultas agora executa apenas duas consultas SQL: uma para autores e outra para posts.

5. Estratégias de Cache e Controle de Ciclo de Vida

O DataLoader oferece métodos para controle fino do cache:

const loader = new DataLoader(batchFn, {
  cache: true, // Habilitar cache (padrão)
  maxBatchSize: 100 // Limitar tamanho do lote
});

// Limpar cache de um item específico
loader.clear(id);

// Limpar cache completo
loader.clearAll();

// Pré-popular cache com dados conhecidos
loader.prime(id, valor);

Cache por requisição (recomendado): Cria um novo DataLoader para cada requisição HTTP. Ideal para dados que podem mudar entre requisições.

Cache global: Útil para dados estáticos ou que raramente mudam. Cuidado com dados obsoletos.

Para mutações, use clear() para invalidar o cache:

Mutation: {
  criarPost: async (_, { autorId, titulo, conteudo }, { postLoader, postsPorAutorLoader }) => {
    const novoPost = await db.query(
      'INSERT INTO posts (autor_id, titulo, conteudo) VALUES ($1, $2, $3) RETURNING *',
      [autorId, titulo, conteudo]
    );

    // Limpar cache do post e dos posts do autor
    postLoader.clear(novoPost.id);
    postsPorAutorLoader.clear(autorId);

    return novoPost;
  }
}

6. Boas Práticas e Otimizações Avançadas

DataLoaders específicos por entidade: Crie um DataLoader para cada tipo de relacionamento. Exemplo: postLoader, commentLoader, postsPorAutorLoader.

Com bancos relacionais (SQL): Use IN clauses para batching eficiente. Cuidado com limites de parâmetros em alguns bancos (ex: SQLite tem limite de 999 parâmetros).

Com bancos não relacionais (MongoDB): Use $in para agrupar buscas:

const batchPosts = async (ids) => {
  const posts = await Post.find({ _id: { $in: ids } });
  const postMap = {};
  posts.forEach(p => { postMap[p._id] = p; });
  return ids.map(id => postMap[id] || null);
};

Combinação com paginação: DataLoader funciona bem com paginação offset/limit. Apenas garanta que o batch inclua todos os IDs necessários.

7. Monitoramento e Debug do Problema N+1

Para detectar queries não agrupadas, implemente logging:

const batchAutores = async (ids) => {
  console.log(`[BATCH] Carregando ${ids.length} autores: ${ids}`);
  const autores = await db.query(
    `SELECT * FROM autores WHERE id IN (${ids.join(',')})`
  );
  return ids.map(id => autores.find(a => a.id === id) || null);
};

Use Apollo Tracing ou GraphQL Metrics para identificar gargalos:

const { createApolloTracing } = require('apollo-tracing');
app.use('/graphql', graphqlHTTP({
  schema,
  extensions: [createApolloTracing()]
}));

Teste de performance comparativo:

// Sem DataLoader: 101 consultas para 100 autores
// Com DataLoader: 2 consultas para 100 autores
// Ganho: 98% menos consultas ao banco

Conclusão

DataLoader é uma ferramenta indispensável para qualquer aplicação GraphQL em produção. Ele elimina o problema N+1 de forma elegante, agrupando requisições e aplicando cache inteligente. Com a configuração correta e boas práticas, você pode reduzir drasticamente a carga no banco de dados e melhorar a performance da sua API.

Lembre-se: crie DataLoaders frescos por requisição, use clear() em mutações e monitore constantemente o comportamento das suas queries. Com essas práticas, seu GraphQL será rápido e escalável.


Referências