Введение
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, то:
101 запрос к БД
А должно быть:
2 запроса к БД
Визуализация: 101 запрос vs 2
N+1 (без оптимизации)
Каждый запрос: ~5ms сетевой задержки + ~7ms выполнения = ~12ms × 101
Eager Loading
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
select_related— использует SQL JOIN. Подходит для ForeignKey и OneToOneField. Один запрос.prefetch_related— выполняет отдельный запрос с WHERE IN. Подходит для ManyToManyField и обратных FK. Два запроса.
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
- Простые связи — один FK, не нужна вложенность
- Нужна фильтрация по связанным данным — WHERE на полях другой таблицы
- Нужна агрегация — COUNT, SUM по связанным записям
- Критичная производительность — один запрос всегда быстрее двух
<?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: ключевые правила
- Создавайте новый DataLoader на каждый HTTP-запрос — иначе кэш будет устаревшим
- Batch-функция должна возвращать результаты в том же порядке, что и входные ключи
- Batch-функция должна возвращать массив той же длины (используйте
nullдля отсутствующих записей)
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-запрос.
Заключение
📝 Ключевые выводы
- N+1 — самый распространённый антипаттерн производительности ORM. 1 + N запросов вместо 2.
- Eager loading (
with(),select_related(),include) — решает 90% случаев N+1. - JOIN — один запрос для простых связей и агрегации. Самый быстрый вариант.
- DataLoader — автоматический batch для GraphQL и сложных сценариев.
- preventLazyLoading() — включите в dev-среде, чтобы ловить N+1 сразу.
- Тесты на количество запросов — единственная гарантия, что N+1 не вернётся.
N+1 — проблема, которая легко прячется за чистым кодом. ORM делают жизнь удобной, но за удобство нужно платить осознанностью. Включайте strict mode, пишите тесты, профилируйте — и ваш API будет отвечать за миллисекунды, а не секунды.
🎯 Создавайте быстрые API
LightBox API помогает проектировать и тестировать REST API с учётом производительности, идемпотентности и обратной совместимости.
- ✓ Mock API для тестирования фронтенда
- ✓ Мониторинг времени ответа
- ✓ Swagger/OpenAPI документация
- ✓ Логирование запросов
- ✓ Бесплатный план для старта
Статья опубликована: 23 февраля 2026
Автор: LightBox API Team