Прямой ответ
N+1 — паттерн, при котором список из N объектов порождает N дополнительных запросов на ленивую подгрузку связей (1 запрос списка + N запросов деталей). Лечится явной жадной загрузкой там, где связи реально используются: в SQLAlchemy — selectinload/joinedload в options запроса, в Django — select_related (FK/O2O, JOIN) и prefetch_related (M2M/обратные, отдельный запрос + склейка). Профилактика важнее лечения: логирование SQL в разработке, тест-ассерт на число запросов (assertNumQueries/LazyloadError), и lazy='raise' на связях, которые нельзя подгружать лениво. Понимание N+1 — базовый навык «ORM-грамотности»: без него приложение деградирует линейно с ростом данных, причём на тестах всё быстро.
Анатомия проблемы: почему ORM так делает
Ленивая загрузка — удобство: order.user.name работает «само», ORM подгрузит пользователя при обращении. В единичном случае это один лишний запрос — незаметно; в цикле по странице из 50 заказов — 51 запрос вместо 2. Механика: у каждого объекта связь загружается отдельно, потому что ORM не знает заранее, к каким атрибутам вы обратитесь. Следствия: latency растёт линейно (round-trip на объект), БД тонет в однотипных мелких запросах, профилировщик показывает «всё в БД», хотя каждый запрос по 1 мс. Классические места возникновения: сериализация списков (для каждого объекта — поля связи), вложенные сериализаторы, агрегаты «по строке отчёта», админки и списки в дашбордах.
SQLAlchemy: selectinload против joinedload
Два основных инструмента (детали сессий — в статье про SQLAlchemy): selectinload — один дополнительный запрос WHERE id IN (...) на всю коллекцию; универсален, предсказуем, работает для коллекций любой мощности — дефолтный выбор. joinedload — LEFT JOIN в основном запросе; хорош для many-to-one/one-to-one (одна строка на объект), опасен для коллекций (декартово раздувание строк результата — SQLAlchemy дедуплицирует объекты, но трафик и память растут; для коллекций есть subqueryload/selectinload). Практика: eager-загрузка добавляется под конкретный запрос (в options), а не «глобально в модели» — связь, подгружаемая всегда и везде, нагружает запросы, которым она не нужна. Дисциплина: lazy='raise' (или raise_on_sql) на связях превращает случайную ленивую подгрузку в ошибку — лучше упасть в тесте, чем молча плодить запросы в проде.
Django: select_related и prefetch_related
Пара эквивалентов: select_related('user') — JOIN для FK/OneToOne (аналог joinedload; цепочки через __); prefetch_related('items') — отдельный запрос и склейка в Python (аналог selectinload; для M2M и обратных FK). Правила те же: применять в queryset конкретного представления/сериализатора, проверять вложенности (prefetch_related(Prefetch(...)) для кастомных запросов внутри). Диагностика в Django удобнее всего через debug-toolbar (список SQL страницы — N+1 виден глазами; локальная настройка — в статье про Django) и assertNumQueries(2) в тестах — ассерт на число запросов ловит регрессии N+1 автоматически.
Диагностика: находить до прода
Инструменты по возрастанию формальности: echo=True/лог SQL в dev (виден каждый запрос; в PostgreSQL — log_min_duration_statement на сервере покажет частые мелкие запросы — настройка БД); debug-toolbar/django-debug-toolbar (SQL по страницам); тесты на число запросов (assertNumQueries, для SQLAlchemy — событие after_cursor_execute со счётчиком в фикстуре); профилировщик APM в проде (распределение запросов по эндпоинтам). Эвристика поиска: любой список объектов, который сериализуется с вложенными данными, подозрителен по умолчанию; эндпоинт, latency которого растёт с размером страницы, — почти наверняка N+1.
Пределы: когда жадная загрузка — не ответ
Жадность тоже стоит денег: joinedload пяти связей с коллекциями — запрос-монстр; selectinload коллекции из 100k элементов — огромный IN. Альтернативы по ситуации: сериализация запросом, а не ORM (для read-only списков — Core-запрос/SQL, возвращающий ровно нужные колонки — без материализации объектов); денормализация (часто читаемое поле копии на родителе — цена: обновление при записи); агрегация на стороне БД (GROUP BY вместо «загрузить и посчитать в Python»); пагинация + частичные данные (не показывать 500 связанных объектов там, где нужен счётчик — func.count подзапросом). Правило баланса: N+1 чинится жадной загрузкой; «жадная загрузка всего всегда» — новый антипаттерн.
Практический сценарий: ускорение списка заказов в десять раз
Эндпоинт «последние 50 заказов с клиентом и позициями» отвечает полторы секунды — при том, что каждый запрос в логах занимает пару миллисекунд. Включаем echo в dev и видим картину: SELECT заказов, затем 50 SELECT клиентов, затем 50 SELECT позиций — классические 101 запрос, latency упирается не в БД, а в round-trip'и. Правка — одна строка запроса: select(Order).options(joinedload(Order.customer), selectinload(Order.items)).order_by(Order.created_at.desc()).limit(50) — joinedload для клиента (many-to-one, одна строка на заказ), selectinload для позиций (коллекция — без декартова раздувания). Было 101 запрос — стало 2, время ответа — 120 мс из них сеть. Дальше закрепляем результат тестом: фикстура создаёт 50 заказов, тест оборачивает вызов эндпоинта счётчиком after_cursor_execute-событий и ассертит <= 4 запроса — случайный будущий рефакторинг, вернувший ленивую подгрузку, упадёт в CI, а не в проде. Тот же паттерн проверяем на сериализаторе: вложенные поля, которых нет в eager-наборе, — будущий N+1; они либо добавляются в options, либо убираются из схемы ответа. Через неделю по логам БД проверяем остальные горячие эндпоинты — обычно на первых трёх находится тот же паттерн, и каждый чинится одной строкой.
FAQ
Как обнаружить N+1 в существующем проекте быстро? Включите лог SQL в dev и пройдитесь по основным страницам/эндпоинтам: повторяющийся одинаковый запрос десятки раз — он. Далее — тесты на число запросов на горячие пути, чтобы не вернулось.
Почему selectinload лучше для коллекций, чем JOIN? JOIN с коллекцией умножает строки результата (заказ × 10 позиций = 10 строк заказа) — трафик и парсинг растут мультипликативно; selectinload делает один компактный IN-запрос и собирает объекты в памяти без дублей.
Помогут ли кеши запросов? Симптоматически — да (повторные запросы из кеша), системно — нет: N+1 остаётся в коде и всплывёт на холодном кеше/других данных. Сначала жадная загрузка, кеш — потом, если нужно (паттерны кеширования).
Бывает ли N+1 при загрузке одного объекта? Да, «лестничный»: объект → связь → связь связи (лениво подгружается по цепочке при сериализации вложенности). Лечится явными цепочками загрузки (joinedload('a.b') / select_related('a__b')) под конкретный сценарий.
Источники: документация SQLAlchemy (loading relationships) и Django (select_related/prefetch_related) — ссылки в шапке.