Реализация GraphQL DataLoader для оптимизации N+1 запросов

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация GraphQL DataLoader для оптимизации N+1 запросов
Средняя
~2-3 рабочих дня
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Решение проблемы N+1 в GraphQL с DataLoader

N+1 — классическая проблема GraphQL: при запросе списка из N объектов с вложенными отношениями выполняется N+1 запросов к БД (1 для списка + N для каждого вложенного поля). DataLoader решает это батчингом: собирает все запросы за один тик event loop и выполняет один групповой запрос.

Демонстрация проблемы

# Этот запрос без DataLoader генерирует 1 + N запросов к БД
query {
  posts {          # SELECT * FROM posts → 100 строк
    id
    title
    author {       # SELECT * FROM users WHERE id = ?  × 100 раз!
      name
    }
  }
}
// БЕЗ DataLoader — N+1
const resolvers = {
  Post: {
    author: async (post) => {
      // Вызывается отдельно для каждого из 100 постов
      return db.users.findById(post.author_id) // 100 запросов!
    }
  }
}

Базовый DataLoader

import DataLoader from 'dataloader'

// Функция батчинга: получает массив ключей, возвращает массив значений
async function batchUsers(userIds) {
  // Один запрос вместо N
  const users = await db.query(
    'SELECT * FROM users WHERE id = ANY($1)',
    [userIds]
  )

  // Важно: результат должен быть в том же порядке, что и входные ключи!
  const userMap = new Map(users.map(u => [u.id, u]))

  return userIds.map(id => userMap.get(id) || null)
}

const userLoader = new DataLoader(batchUsers)

// Использование в резолвере — выглядит как одиночный запрос, работает как батч
const resolvers = {
  Post: {
    author: async (post, args, context) => {
      return context.loaders.userById.load(post.author_id)
    }
  }
}

Реестр DataLoader'ов (per-request)

DataLoader'ы создаются для каждого запроса — иначе кеш будет общим между пользователями (уязвимость):

// dataloaders.js
import DataLoader from 'dataloader'

export class DataLoaderRegistry {
  constructor(db) {
    this.db = db

    // Автоматически батчируется за один тик event loop
    this.userById = new DataLoader(async (ids) => {
      const rows = await db.query(
        'SELECT * FROM users WHERE id = ANY($1::int[])', [ids]
      )
      const map = new Map(rows.map(r => [r.id, r]))
      return ids.map(id => map.get(id) ?? null)
    })

    this.postsByAuthorId = new DataLoader(async (authorIds) => {
      const rows = await db.query(
        'SELECT * FROM posts WHERE author_id = ANY($1::int[])', [authorIds]
      )
      // One-to-many: возвращаем массив для каждого authorId
      const map = new Map()
      for (const row of rows) {
        if (!map.has(row.author_id)) map.set(row.author_id, [])
        map.get(row.author_id).push(row)
      }
      return authorIds.map(id => map.get(id) ?? [])
    })

    this.commentsByPostId = new DataLoader(async (postIds) => {
      const rows = await db.query(
        'SELECT * FROM comments WHERE post_id = ANY($1::int[]) ORDER BY created_at',
        [postIds]
      )
      const map = new Map()
      for (const row of rows) {
        if (!map.has(row.post_id)) map.set(row.post_id, [])
        map.get(row.post_id).push(row)
      }
      return postIds.map(id => map.get(id) ?? [])
    })

    // С фильтрацией — ключ включает параметры
    this.tagsByPostId = new DataLoader(async (postIds) => {
      const rows = await db.query(`
        SELECT pt.post_id, t.* FROM tags t
        JOIN post_tags pt ON pt.tag_id = t.id
        WHERE pt.post_id = ANY($1::int[])
      `, [postIds])
      const map = new Map()
      for (const row of rows) {
        if (!map.has(row.post_id)) map.set(row.post_id, [])
        map.get(row.post_id).push(row)
      }
      return postIds.map(id => map.get(id) ?? [])
    })
  }
}

// В context factory
context: async ({ req }) => {
  const user = await authenticate(req)
  // Новый экземпляр для каждого HTTP-запроса!
  const loaders = new DataLoaderRegistry(db)
  return { user, db, loaders }
}

DataLoader с параметрами

Когда нужна фильтрация по дополнительным аргументам:

// Плохо: отдельный лоадер для каждой комбинации параметров
// Хорошо: составной ключ

this.productsByCategoryAndStatus = new DataLoader(
  async (keys) => {
    // keys = [{categoryId: 1, status: 'active'}, ...]
    const categoryIds = [...new Set(keys.map(k => k.categoryId))]
    const statuses = [...new Set(keys.map(k => k.status))]

    const rows = await db.query(`
      SELECT * FROM products
      WHERE category_id = ANY($1::int[])
      AND status = ANY($2::text[])
    `, [categoryIds, statuses])

    // Группировка по составному ключу
    const map = new Map()
    for (const row of rows) {
      const key = `${row.category_id}:${row.status}`
      if (!map.has(key)) map.set(key, [])
      map.get(key).push(row)
    }

    return keys.map(k => map.get(`${k.categoryId}:${k.status}`) ?? [])
  },
  {
    // Кастомный ключ для объектов
    cacheKeyFn: (key) => `${key.categoryId}:${key.status}`
  }
)

// Использование в резолвере
const resolvers = {
  Category: {
    activeProducts: (category, args, context) => {
      return context.loaders.productsByCategoryAndStatus.load({
        categoryId: category.id,
        status: 'active'
      })
    }
  }
}

Прайминг кеша

Избегает повторных запросов к уже загруженным данным:

const resolvers = {
  Query: {
    posts: async (parent, { limit }, context) => {
      const posts = await context.db.posts.findAll({ limit })

      // Примировать кеш userById данными, уже имеющимися в posts
      // Если posts содержат embedded author — DataLoader не будет их перезапрашивать
      for (const post of posts) {
        if (post.author) {
          context.loaders.userById.prime(post.author.id, post.author)
        }
      }

      return posts
    }
  }
}

Измерение эффективности

// Middleware для логирования количества SQL-запросов
function queryCounterPlugin() {
  return {
    async requestDidStart() {
      let queryCount = 0
      const originalQuery = db.query.bind(db)

      db.query = (...args) => {
        queryCount++
        return originalQuery(...args)
      }

      return {
        async willSendResponse({ response }) {
          console.log(`GraphQL operation executed ${queryCount} SQL queries`)
          // В продакшн — метрика в Prometheus
          response.http.headers.set('X-SQL-Count', queryCount.toString())
        }
      }
    }
  }
}

До DataLoader: запрос 100 постов → 101 SQL-запрос. После DataLoader: тот же GraphQL-запрос → 3–5 SQL-запросов (posts, users batch, comments batch).

Срок выполнения

Реализация DataLoader'ов для всех отношений в GraphQL-схеме — 1–2 рабочих дня.