Что такое N+1 query problem? Как ловить, как чинить?
N+1 — когда за списком из N строк приложение делает ещё N отдельных запросов; обнаруживают через pg_stat_statements/APM, исправляют JOIN, batch-загрузкой (ANY) или ORM selectinload.
Что такое N+1 query problem
N+1 возникает, когда приложение сначала делает один запрос за списком записей, а потом для каждой записи из списка выполняет ещё один отдельный запрос. Итого: 1 + N запросов. Классический пример — ORM загружает 100 заказов, а потом делает 100 отдельных SELECT для получения данных о клиенте каждого заказа.
Как обнаружить
- pg_stat_statements — смотреть на запросы с очень высоким
callsи идентичным текстом, различающимся только bind-параметром. - Логи PostgreSQL — включить
log_min_duration_statement = 10и искать повторяющиеся короткие запросы подряд. - APM/tracing — DataDog, Jaeger, OpenTelemetry покажут «waterfall» с десятками одинаковых span-ов внутри одного HTTP-запроса.
- Django Debug Toolbar / SQLAlchemy event logging — счётчики SQL на уровне фреймворка.
-- Найти часто вызываемые короткие запросы
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
WHERE calls > 1000
AND mean_exec_time < 5
ORDER BY calls DESC
LIMIT 20;
Как исправить
1. JOIN — для one-to-one / many-to-one:
-- Было: SELECT * FROM orders; + для каждого: SELECT * FROM users WHERE id = ?
-- Стало:
SELECT o.order_id, o.total, u.email
FROM orders AS o
JOIN users AS u ON u.user_id = o.user_id
WHERE o.created_at > now() - interval '7 days';
2. Batch load с WHERE id = ANY(...):
-- Собрать все user_id из результата первого запроса
SELECT user_id, email, name
FROM users
WHERE user_id = ANY(ARRAY[1, 2, 3, 4, 5]::bigint[]);
3. ORM prefetch / eager loading:
# SQLAlchemy: joinedload или selectinload
from sqlalchemy.orm import selectinload
stmt = (
select(Order)
.options(selectinload(Order.user))
.where(Order.created_at > datetime.now() - timedelta(days=7))
)
result = await session.execute(stmt)
orders = result.scalars().all()
4. Агрегация вместо individual-запросов:
SELECT u.user_id, u.email, COUNT(o.order_id) AS order_count
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.user_id
GROUP BY u.user_id, u.email;
Проверка результата
После фикса сравниваем: число SQL-запросов на эндпоинт (через APM), calls в pg_stat_statements, total latency p50/p99. Убеждаемся, что JOIN не породил дубли строк из-за one-to-many (нужен DISTINCT или агрегация).
Подводные камни
- Механический eager load всех связей заменяет N+1 на огромный JOIN с декартовым произведением и лишними данными — смотреть на
rowsв EXPLAIN. - Pagination теряется: попытка одним запросом загрузить всё дерево при наличии LIMIT/OFFSET даёт неверные результаты из-за дублей строк.
- Кэш скрывает N+1 на dev/staging, при cache miss в production нагрузка возвращается в полном объёме.
- Слишком большой batch (тысячи id в ANY(...)) может вызвать проблемы с планировщиком и выделением памяти.
selectinloadделает второй SELECT с IN — при очень большом списке id это снова может стать узким местом.- ORM по умолчанию часто lazy-load: новый разработчик добавляет обращение к связи в шаблоне/сериализаторе и незаметно вводит N+1 снова.
- Трассировка только p50 маскирует проблему: N+1 бьёт сильнее на пользователях с большим числом связей (хвост распределения).
Common mistakes
- Чинить только добавлением индекса.
- Включать eager loading всех отношений.
- Не измерять количество SQL на request.
What the interviewer is testing
- Просит пример ORM-сценария.
- Проверяет batch loading.
- Уточняет метрики до/после.