PostgreSQLMiddleTechnical

Что такое 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.
  • Уточняет метрики до/после.

Sources

Related topics