Реалізація 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'и створюються per-request—інакше кеш спільний між користувачами (вразливість):

// 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: 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 робочих дні.