Проблема N+1 запросов в API и базах данных: как найти и исправить

← Вернуться к статьям

Введение

API отвечает 200 мс на списке из 10 элементов. Всё хорошо. Но стоит запросить 100 элементов — ответ растягивается до 3 секунд. Добавляем пагинацию по 50 — всё равно медленно. Профилируем — и обнаруживаем 251 SQL-запрос на один HTTP-запрос.

Это проблема N+1 — самый распространённый и самый коварный антипаттерн в работе ORM и API. Коварный потому, что код выглядит чистым, тесты проходят, а база данных тихо страдает.

📋 Содержание

Что такое проблема N+1

Проблема N+1 — ситуация, когда приложение выполняет 1 запрос для получения списка записей, а затем N дополнительных запросов для загрузки связанных данных каждой записи.

Представьте REST API эндпоинт, возвращающий список заказов с информацией о клиенте:

// Наивная реализация — выглядит чисто, работает ужасно
app.get('/api/orders', async (req, res) => {
  // 1 запрос: получить все заказы
  const orders = await Order.findAll();

  // N запросов: для КАЖДОГО заказа — запрос на пользователя
  const result = await Promise.all(
    orders.map(async (order) => ({
      ...order,
      customer: await User.findById(order.user_id)  // 1 запрос на заказ!
    }))
  );

  res.json(result);
});

Если заказов 100, то:

#1 SELECT * FROM orders;
#2 SELECT * FROM users WHERE id = 1;
#3 SELECT * FROM users WHERE id = 2;
#4 SELECT * FROM users WHERE id = 3;
... ещё 96 таких же запросов ...
#101 SELECT * FROM users WHERE id = 100;

101 запрос к БД

А должно быть:

#1 SELECT * FROM orders;
#2 SELECT * FROM users WHERE id IN (1, 2, 3, ... 100);

2 запроса к БД

Визуализация: 101 запрос vs 2

N+1 (без оптимизации)

~1200ms
101 запрос к БД

Каждый запрос: ~5ms сетевой задержки + ~7ms выполнения = ~12ms × 101

Eager Loading

~25ms
2 запроса к БД

1-й запрос: ~10ms + 2-й запрос (IN): ~15ms

Масштаб проблемы

N+1 растёт линейно. 10 записей — терпимо. 100 — заметно медленно. 1000 — API таймаутит. 10 000 — база данных под нагрузкой, остальные запросы тоже замедляются.

А если у вас N+1 на двух уровнях (заказы → товары → категории) — это уже N × M + N + 1 запросов.

Как обнаружить N+1

Laravel Обнаружение

<?php

// Включите strict mode — Laravel выбросит исключение при lazy loading
// AppServiceProvider.php → boot()
use Illuminate\Database\Eloquent\Model;

Model::preventLazyLoading(! app()->isProduction());

// Теперь этот код вызовет исключение:
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->customer->name; // Attempted to lazy load [customer]!
}
# Laravel Debugbar — визуально показывает все SQL-запросы
composer require barryvdh/laravel-debugbar --dev

# В debug-баре будет видно:
# Queries: 101 | Time: 1.2s
# ⚠ Duplicate queries detected!

Django Обнаружение

# Django Debug Toolbar
# pip install django-debug-toolbar

# settings.py
INSTALLED_APPS = [..., 'debug_toolbar']
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware', ...]

# Или программно — логирование SQL
import logging
logging.getLogger('django.db.backends').setLevel(logging.DEBUG)

# nplusone — автоматическое обнаружение
# pip install nplusone
INSTALLED_APPS = [..., 'nplusone.ext.django']
MIDDLEWARE = ['nplusone.ext.django.NPlusOneMiddleware', ...]
NPLUSONE_RAISE = True  # Исключение при N+1

Node.js Обнаружение

// TypeORM — включите логирование запросов
const dataSource = new DataSource({
  // ...
  logging: ['query'],  // логировать все запросы
  maxQueryExecutionTime: 1000  // предупреждать при > 1s
});

// Prisma — логирование
const prisma = new PrismaClient({
  log: ['query', 'warn'],
});

// Ручной счётчик запросов (любой ORM)
let queryCount = 0;
connection.on('query', () => queryCount++);

app.use((req, res, next) => {
  queryCount = 0;
  res.on('finish', () => {
    if (queryCount > 10) {
      console.warn(`⚠ ${req.url}: ${queryCount} queries`);
    }
  });
  next();
});

Решение 1: Eager Loading

Eager loading — загрузка связанных данных заранее, одним дополнительным запросом вместо N.

Laravel — with()

<?php

// ❌ N+1: 101 запрос
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->customer->name; // lazy load — 1 запрос на заказ
}

// ✅ Eager loading: 2 запроса
$orders = Order::with('customer')->get();
// Запрос 1: SELECT * FROM orders
// Запрос 2: SELECT * FROM users WHERE id IN (1, 2, 3, ...)

foreach ($orders as $order) {
    echo $order->customer->name; // уже загружен, 0 запросов
}

// Вложенный eager loading
$orders = Order::with(['customer', 'items.product'])->get();
// Запрос 1: SELECT * FROM orders
// Запрос 2: SELECT * FROM users WHERE id IN (...)
// Запрос 3: SELECT * FROM order_items WHERE order_id IN (...)
// Запрос 4: SELECT * FROM products WHERE id IN (...)

// Eager loading с фильтрацией
$orders = Order::with(['items' => function ($query) {
    $query->where('quantity', '>', 0)
          ->orderBy('price', 'desc');
}])->get();

Django — select_related() / prefetch_related()

# ❌ N+1: 101 запрос
orders = Order.objects.all()
for order in orders:
    print(order.customer.name)  # lazy load

# ✅ select_related — JOIN (для ForeignKey, OneToOne)
orders = Order.objects.select_related('customer').all()
# SQL: SELECT orders.*, users.* FROM orders
#      INNER JOIN users ON orders.user_id = users.id

# ✅ prefetch_related — отдельный запрос (для ManyToMany, reverse FK)
orders = Order.objects.prefetch_related('items').all()
# Запрос 1: SELECT * FROM orders
# Запрос 2: SELECT * FROM order_items WHERE order_id IN (...)

# Комбинация
orders = (
    Order.objects
    .select_related('customer')        # JOIN для FK
    .prefetch_related('items__product') # отдельные запросы для M2M
    .filter(status='active')
)

select_related vs prefetch_related

TypeORM — relations / QueryBuilder

// ❌ N+1: lazy loading
const orders = await orderRepository.find();
for (const order of orders) {
  const customer = await order.customer; // отдельный запрос!
}

// ✅ Eager loading через relations
const orders = await orderRepository.find({
  relations: ['customer', 'items', 'items.product']
});

// ✅ QueryBuilder с JOIN
const orders = await orderRepository
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .leftJoinAndSelect('order.items', 'item')
  .leftJoinAndSelect('item.product', 'product')
  .where('order.status = :status', { status: 'active' })
  .getMany();

Prisma — include

// ❌ N+1
const orders = await prisma.order.findMany();
for (const order of orders) {
  const customer = await prisma.user.findUnique({
    where: { id: order.userId }
  });
}

// ✅ Include — eager loading
const orders = await prisma.order.findMany({
  include: {
    customer: true,
    items: {
      include: { product: true }
    }
  }
});

Решение 2: JOIN-запросы

Иногда ORM-магия не нужна — напишите SQL вручную:

-- Один запрос вместо 101
SELECT
  o.id AS order_id,
  o.total,
  o.status,
  o.created_at,
  u.id AS customer_id,
  u.name AS customer_name,
  u.email AS customer_email
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.status = 'active'
ORDER BY o.created_at DESC
LIMIT 100;

⚠️ Когда JOIN лучше eager loading

<?php
// Laravel — сырой JOIN когда нужна агрегация
$orders = DB::table('orders')
    ->join('users', 'orders.user_id', '=', 'users.id')
    ->leftJoin('order_items', 'orders.id', '=', 'order_items.order_id')
    ->select(
        'orders.id',
        'orders.status',
        'users.name as customer_name',
        DB::raw('COUNT(order_items.id) as items_count'),
        DB::raw('SUM(order_items.price * order_items.quantity) as total')
    )
    ->groupBy('orders.id', 'orders.status', 'users.name')
    ->get();

Решение 3: DataLoader (batch)

DataLoader — паттерн, который автоматически собирает одиночные запросы в batch. Изобретён в Facebook для GraphQL, но полезен в любом API.

Как работает DataLoader

const DataLoader = require('dataloader');

// Batch-функция: получает массив ID, возвращает массив результатов
const userLoader = new DataLoader(async (userIds) => {
  // Один запрос вместо N
  const users = await User.findAll({
    where: { id: userIds }
  });

  // Важно: вернуть в том же порядке, что и входные ID
  const userMap = new Map(users.map(u => [u.id, u]));
  return userIds.map(id => userMap.get(id) || null);
});

// Использование — выглядит как одиночные запросы
app.get('/api/orders', async (req, res) => {
  const orders = await Order.findAll();

  const result = await Promise.all(
    orders.map(async (order) => ({
      ...order,
      customer: await userLoader.load(order.user_id)
      // DataLoader собирает все .load() за один tick
      // и выполняет batch-функцию ОДИН раз
    }))
  );

  res.json(result);
});

// Вместо 100 запросов — 1 запрос:
// SELECT * FROM users WHERE id IN (1, 2, 3, ... 100)

DataLoader в GraphQL

В GraphQL проблема N+1 особенно острая из-за архитектуры резолверов:

const { ApolloServer } = require('@apollo/server');
const DataLoader = require('dataloader');

const typeDefs = `
  type Order {
    id: ID!
    total: Float!
    customer: User!
    items: [OrderItem!]!
  }

  type Query {
    orders: [Order!]!
  }
`;

const resolvers = {
  Query: {
    orders: () => Order.findAll()
  },
  Order: {
    // Без DataLoader — N+1!
    // customer: (order) => User.findById(order.userId),

    // С DataLoader — batch!
    customer: (order, _, { loaders }) =>
      loaders.user.load(order.userId),

    items: (order, _, { loaders }) =>
      loaders.orderItems.load(order.id)
  }
};

// Создаём loaders для каждого запроса
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: () => ({
    loaders: {
      user: new DataLoader(batchUsers),
      orderItems: new DataLoader(batchOrderItems)
    }
  })
});

DataLoader: ключевые правила

N+1 на уровне API

N+1 бывает не только с БД. Тот же паттерн может возникнуть при вызовах внешних API:

N+1 с внешним API

// ❌ N+1 на уровне API
app.get('/api/enriched-orders', async (req, res) => {
  const orders = await Order.findAll();

  const result = await Promise.all(
    orders.map(async (order) => ({
      ...order,
      // Отдельный HTTP-запрос для каждого заказа!
      shipping: await fetch(
        `https://shipping-api.com/track/${order.trackingId}`
      ).then(r => r.json())
    }))
  );
  // 100 заказов = 100 HTTP-запросов к shipping API
});

Решения:

// ✅ Решение 1: Batch API (если поддерживается)
const trackingIds = orders.map(o => o.trackingId);
const shippingData = await fetch('https://shipping-api.com/track/batch', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ ids: trackingIds })
}).then(r => r.json());

// ✅ Решение 2: Параллельные запросы с ограничением конкурентности
const pLimit = require('p-limit');
const limit = pLimit(10); // максимум 10 одновременных запросов

const shippingData = await Promise.all(
  orders.map(order =>
    limit(() =>
      fetch(`https://shipping-api.com/track/${order.trackingId}`)
        .then(r => r.json())
    )
  )
);

// ✅ Решение 3: Кэширование (Redis)
async function getShippingCached(trackingId) {
  const cached = await redis.get(`shipping:${trackingId}`);
  if (cached) return JSON.parse(cached);

  const data = await fetch(`.../${trackingId}`).then(r => r.json());
  await redis.setex(`shipping:${trackingId}`, 300, JSON.stringify(data));
  return data;
}

Предотвращение

Лучше предотвратить N+1, чем искать потом:

1. Strict Mode в ORM

<?php
// Laravel: запрет lazy loading
Model::preventLazyLoading(! app()->isProduction());

// Это ЛУЧШИЙ способ — вы узнаете о N+1 сразу в dev-среде

2. Линтер / статический анализ

# PHP — phpstan правило для N+1
# Python — nplusone библиотека
pip install nplusone

# Ruby — Bullet gem
# bundle add bullet

3. Тесты на количество запросов

<?php
// Laravel: тест что эндпоинт не превышает лимит запросов
public function test_orders_endpoint_query_count()
{
    // Создаём 50 заказов
    Order::factory()->count(50)->create();

    // Считаем запросы
    DB::enableQueryLog();

    $response = $this->getJson('/api/orders');
    $response->assertOk();

    $queryCount = count(DB::getQueryLog());

    // Максимум 5 запросов (не 51!)
    $this->assertLessThanOrEqual(5, $queryCount,
        "Endpoint executed {$queryCount} queries. Possible N+1!"
    );
}
# Django: тест количества запросов
from django.test import TestCase
from django.test.utils import override_settings

class OrderAPITest(TestCase):
    def test_orders_query_count(self):
        # Создаём 50 заказов
        for i in range(50):
            Order.objects.create(customer=self.user, total=100)

        # assertNumQueries — встроенный в Django
        with self.assertNumQueries(2):  # ровно 2 запроса
            response = self.client.get('/api/orders/')

        self.assertEqual(response.status_code, 200)

Бенчмарки

Реальные замеры на PostgreSQL 16, 10 000 записей, сервер на localhost:

Подход SQL-запросов Время Ускорение
N+1 (lazy loading) 10 001 12.4s 1x (базовая)
Eager loading (WHERE IN) 2 85ms ~145x
JOIN 1 62ms ~200x
DataLoader (batch) 2 90ms ~138x

⚠️ В production разница ещё больше

На localhost сетевая задержка до БД ≈ 0ms. В production (БД на другом хосте) каждый запрос добавляет 1-5ms сетевой задержки. 10 000 запросов × 3ms = 30 секунд только на сеть.

Шпаргалка по ORM

ORM Eager loading Обнаружение N+1
Laravel Eloquent with('relation') preventLazyLoading(), Debugbar
Django ORM select_related(), prefetch_related() nplusone, Debug Toolbar
SQLAlchemy joinedload(), subqueryload() Logging, raise_on_lazy
TypeORM relations: [...], leftJoinAndSelect Logging, query counter
Prisma include: { ... } log: ['query']

FAQ

❓ Что такое проблема N+1 запросов?

Ответ: Когда приложение выполняет 1 запрос для списка и N запросов для связанных данных каждой записи. 100 заказов + 100 запросов на клиентов = 101 запрос вместо 2. Растёт линейно и убивает производительность API.

❓ Как обнаружить N+1 запросы?

Ответ: 1) Включите логирование SQL и ищите повторяющиеся SELECT. 2) Используйте инструменты: Laravel Debugbar, Django Debug Toolbar. 3) Включите strict mode: Laravel preventLazyLoading(), nplusone для Django. 4) Напишите тесты на количество запросов — assertNumQueries().

❓ Чем отличается eager loading от lazy loading?

Ответ: Lazy loading загружает связанные данные при обращении — удобно, но вызывает N+1 в циклах. Eager loading загружает всё заранее одним запросом (JOIN или WHERE IN). В Laravel — with(), в Django — select_related()/prefetch_related().

❓ Что такое DataLoader и зачем он нужен?

Ответ: DataLoader (от Facebook) собирает одиночные запросы за один tick event loop в batch-запрос. 100 вызовов load(id) → один WHERE id IN (...). Критичен для GraphQL, полезен в любом API. Создавайте новый на каждый HTTP-запрос.

Заключение

📝 Ключевые выводы

  1. N+1 — самый распространённый антипаттерн производительности ORM. 1 + N запросов вместо 2.
  2. Eager loading (with(), select_related(), include) — решает 90% случаев N+1.
  3. JOIN — один запрос для простых связей и агрегации. Самый быстрый вариант.
  4. DataLoader — автоматический batch для GraphQL и сложных сценариев.
  5. preventLazyLoading() — включите в dev-среде, чтобы ловить N+1 сразу.
  6. Тесты на количество запросов — единственная гарантия, что N+1 не вернётся.

N+1 — проблема, которая легко прячется за чистым кодом. ORM делают жизнь удобной, но за удобство нужно платить осознанностью. Включайте strict mode, пишите тесты, профилируйте — и ваш API будет отвечать за миллисекунды, а не секунды.

🎯 Создавайте быстрые API

LightBox API помогает проектировать и тестировать REST API с учётом производительности, идемпотентности и обратной совместимости.

Попробовать бесплатно →

Статья опубликована: 23 февраля 2026
Автор: LightBox API Team