GraphQL en Producción: Eliminando el N+1 con DataLoader y Límites de Complejidad

Desplegar GraphQL sin una estrategia de caché es un error costoso. Descubrí esto de la peor manera cuando nuestra base de datos alcanzó el 100% de CPU tras migrar desde REST. El culpable no fue el tráfico, fue el infame problema GraphQL N+1 ejecutando miles de consultas redundantes por segundo. Aquí explicamos cómo lo solucionamos utilizando el Patrón DataLoader y cómo protegimos el sistema contra ataques de profundidad.

El Diagnóstico: Por qué GraphQL mata tu DB

En una arquitectura REST tradicional, optimizamos las consultas SQL manualmente. En GraphQL, los resolvers son independientes. Si solicitas una lista de 50 Posts y cada post tiene un Author, el servidor ejecuta:

  1. 1 consulta para obtener los 50 Posts.
  2. 50 consultas individuales para obtener el Autor de cada Post.

Esto resulta en 51 peticiones a la base de datos para una sola llamada de API. A escala, esto destruye el Rendimiento Backend.

Síntoma Crítico: Logs de base de datos inundados con consultas idénticas (e.g., SELECT * FROM authors WHERE id = ?) repetidas cientos de veces por milisegundo.

Solución: Batching con DataLoader

Para resolver esto, implementamos el Patrón DataLoader. Esta utilidad agrupa (batching) todas las solicitudes de un mismo tick del event loop en una sola consulta a la base de datos.

Aquí está la implementación lista para producción en Node.js/TypeScript:

import DataLoader from 'dataloader';
import { User } from './models'; // Tu ORM (TypeORM, Prisma, Mongoose)

// 1. Definir la función de Batching
// Recibe un array de IDs y DEBE retornar un array de resultados del mismo tamaño y orden.
const batchUsers = async (userIds: readonly string[]) => {
  // Ejecuta UNA sola consulta: SELECT * FROM users WHERE id IN (1, 2, 3...)
  const users = await User.find({ _id: { $in: userIds } });

  // Mapear los resultados al orden original de los IDs (CRUCIAL)
  const userMap = new Map(users.map(user => [user.id.toString(), user]));
  return userIds.map(id => userMap.get(id) || null);
};

// 2. Crear la instancia (Hacer esto por Request, no globalmente para evitar caché sucio)
export const createLoaders = () => ({
  userLoader: new DataLoader(batchUsers),
});

// 3. Uso en el Resolver
const resolvers = {
  Post: {
    author: (post, args, { loaders }) => {
      // En lugar de llamar a la DB directamente, usamos .load()
      return loaders.userLoader.load(post.authorId);
    },
  },
};

Con esta Optimización API, pasamos de 51 consultas a solo 2, independientemente del tamaño de la lista.

Protección: Límites de Complejidad de Consultas

Una vez solucionado el N+1, surge otro problema: clientes maliciosos o ignorantes que envían consultas profundamente anidadas (e.g., author { posts { author { posts ... } } }). Esto puede agotar la memoria del servidor.

Utilizamos graphql-query-complexity para calcular el coste antes de la ejecución.

import { getComplexity, simpleEstimator } from 'graphql-query-complexity';

const server = new ApolloServer({
  schema,
  plugins: [{
    requestDidStart: async () => ({
      async didResolveOperation({ request, document }) {
        const complexity = getComplexity({
          schema,
          operationName: request.operationName,
          query: document,
          variables: request.variables,
          estimators: [
            // Coste base de 1 por campo
            simpleEstimator({ defaultComplexity: 1 }),
          ],
        });

        // Umbral máximo permitido
        const MAX_COMPLEXITY = 100;

        if (complexity > MAX_COMPLEXITY) {
          throw new Error(
            `Consulta demasiado compleja: ${complexity}. Máximo permitido: ${MAX_COMPLEXITY}`
          );
        }
        console.log(`Complejidad de consulta actual: ${complexity}`);
      },
    }),
  }],
});

Al definir la Complejidad de consultas, establecemos un "presupuesto" de recursos que el cliente puede gastar, protegiendo nuestra infraestructura de denegación de servicio (DoS).

Impacto en Producción (Benchmark)

Métrica Sin Optimización Con DataLoader + Complexity
DB Queries (Lista de 100 items) 101 Queries 2 Queries
Latencia Media 450ms 45ms
Uso de CPU (DB) 85% - 100% 15% - 20%

Conclusión

GraphQL ofrece una flexibilidad increíble en el frontend, pero transfiere la complejidad al backend. No lance a producción sin resolver el problema GraphQL N+1 mediante el Patrón DataLoader y sin establecer límites estrictos de complejidad. Estas dos implementaciones transformaron nuestra API de un punto de fallo constante a un sistema robusto y escalable.

Post a Comment