Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Проблема N+1 в ORM: найти и обезвредить

N+1 в ORM: почему страница делает 1000 запросов и как это лечить

Механика N+1, eager loading в SQLAlchemy и Django ORM, подсчёт запросов в тестах и профилактика — полный разбор главной болезни ORM.

Прямой ответ

N+1 — паттерн, при котором ORM делает 1 запрос на список сущностей и по ещё одному на каждую связанную (ленивая загрузка по обращению к атрибуту): orders = session.query(Order).all() (1) → цикл order.customer.name (N) — 50 заказов = 51 запрос, 500 = 501 — латентность линейна с объёмом, БД задыхается, а в коде — невинный order.customer.name (магия ленивости — сессии/запросы — ORM проектирует лень удобством, платит производительность); лечение — eager loading (загрузка связей сразу, одним-двумя запросами): SQLAlchemy — selectinload(Order.customer) (отдельный SELECT IN — дефолтный выбор для коллекций — работает везде) / joinedload (JOIN — для to-one — осторожнее с коллекциями — дублирование строк) — select(Order).options(selectinload(Order.items)) — 2 запроса вместо 51; Django — select_related (JOIN — FK/O2O — «бесплатно» в основном запросе) / prefetch_related (отдельный запрос + склейка в Python — M2M/reverse-FK — Django-настройка — .prefetch_related('items__products') — цепочки); диагностика (без неё — лечение вслепую): (1) логирование SQL (SQLAlchemy echo=True в dev — каждый запрос в лог — «51 строка SELECT customers» — видно сразу; Django — connection.queries / django-debug-toolbar — локальная разработка); (2) лог БД (log_min_duration_statement — postgres-конфигурация — «одинаковые запросы стаями» — N+1 в проде, не виденный в dev); (3) тест-счётчик запросов (главная профилактика — см. ниже — assert «эндпоинт делает ≤5 запросов» — N+1-регрессия краснеет в CI, не в проде); (4) APM/трейсинг (OpenTelemetry — трейс страницы с 200 спанами SELECT — визуальный приговор); где прячется (неочевидные места): сериализация списков (Pydantic/DRF-сериализатор обходит атрибуты — «схема включает customer» — каждый объект тянет свой — валидация/схемы — ORM-объект в схему — связи грузятся по дороге); админка (Django-admin — list_display со связями — get_queryset с select_related — «админка тормозит» — классика); вложенные сериализаторы (items→products→category — N×M×K — 3 уровня лени — 1000 объектов — 3000 запросов — «глубина схемы = глубина проблемы»); фоновые задачи (Celery-задача с циклом по объектам — тот же паттерн, просто не в веб-слое — диагностика та же); обратная сторона eager (лечим без фанатизма): joinedload на большие коллекции — дубликаты строк/память (10 заказов × 1000 позиций — JOIN вернёт 10 000 строк — selectinload честнее); «загрузить всё везде» — лишние данные (список заказов не нужен customer.address — eager только под конкретный use case — разные опции на разные эндпоинты — «универсальный запрос на все случаи» — вторая крайность); subqueryload для вложенных коллекций (items + items.products — selectinload цепочкой — SQLAlchemy строит вложенные IN — документация-паттерны).

Тест-счётчик: профилактика навсегда

Единственная защита, которая работает системно — assertion на число запросов в тестах (N+1 — регрессия кода, ловится как баг): SQLAlchemy/pytest — контекст-менеджер-счётчик (хук на before_cursor_execute — считаем SQL — with count_queries() as c: api.get('/orders') → assert c.n <= 5 — pytest-фикстура на все списковые эндпоинты); Django — assertNumQueries(5) (встроенный — with self.assertNumQueries(4): — django-test-стандарт — тестирование); правила команды (проверенные): каждый списковый эндпоинт — тест со счётчиком (порог — из текущего+1 — «было 4, стало 8 — объясни»); фикстуры с несколькими объектами (тест с одним заказом N+1 не покажет — 1+1=2 — «норма» — минимум 3–5 связанных — фикстуры — масштаб имеет значение); нагрузочные данные в интеграционных тестах (50+ объектов — тайминг-assert «список < 200 мс» — грубая, но честная ловушка); ревью-маркеры (доступ к связи в цикле — «где здесь eager?» — чеклист ревью — вопрос по умолчанию для любого for с атрибутами ORM-объекта); CI-джоба с логом запросов на ключевых путях (структурированные логи — diff числа запросов между main и PR — «бюджет запросов» как метрика — CI-практика); что делать с легаси (N+1 уже в проде, рефакторить некогда): кэш атрибутов в рамках запроса (Redis-кэш — костыль, но останавливает кровотечение), батчинг вручную (собрать id — один WHERE id IN — словарь — подставить — ручной selectinload — 30 строк), постепенный перенос критичных путей на eager-запросы (техдолг-подход — список путей по латентности — сверху вниз).

Практический сценарий: страница заказов с 1800 запросами

Контекст: B2B-портал, страница «Мои заказы» (50 заказов, у каждого 5–20 позиций, у позиций — товар) — загрузка 9 секунд, в логах БД — «стая» одинаковых SELECT; диагностика: echo=True на staging-сессии — 1 (orders) + 50 (customers) + 50 (items) + ~700 (products позиций) + ~1000 (category каждого товара) = 1801 запрос — «вложенная лень» трёх уровней (схема ответа: order→customer, items→product→category — Pydantic-модель обходит всё — схемы); лечение (по уровням): (1) корневой запрос — select(Order).options(selectinload(Order.customer), selectinload(Order.items).selectinload(Item.product).selectinload(Product.category)) — 5 запросов (orders + 4 IN-батча — SQLAlchemy собирает IN по уровням); (2) сверка плана (EXPLAIN — IN-батчи по 50/700 id — индексы PK — ок; 700-element IN — читаем план — при больших страницах — пагинация ограничивает N — пагинация — «N+1 лечится и размером страницы»); (3) тест-счётчик в CI (assert n_queries <= 6 — фикстура 5 заказов × 8 позиций × category — тесты); (4) ревью-находки попутно: соседний эндпоинт «экспорт заказов» делал то же на 500 объектах (18k запросов — таймаут — ёмкость — лечили вместе), админка заказов (list_display customer — select_related в get_queryset — Django-паттерн — если админка Django); (5) результат: 1801 → 5 запросов, 9 с → 180 мс (×50 — латентность была чисто сетевой/очередной — закон Литтла в действии — каждый запрос — round trip ~5 мс — 1800×5 мс = 9 с — арифметика сошлась до миллисекунд — уверенность, что нашли всё); (6) профилактика: счётчик-фикстура на все списковые эндпоинты (12 штук — три нашли «тихий N+1» второго уровня — починены до инцидентов); вывод: N+1 — не «тормозит БД» (БД каждый запрос делает за миллисекунды), а тысячи round trip'ов — лечится формой запроса, не железом — профилирование до, индексы после (частая ошибка — «добавили индексов» — стая из 1800 запросов по индексу всё так же 1800 round trip'ов).

FAQ

Почему ORM вообще допускает N+1 — это баг фреймворка? Это осознанный компромисс: ленивая загрузка экономит, когда связи не нужны (загрузили 1000 заказов, у одного посмотрели customer — eager загрузил бы 1000 customer'ов зря — лень правильнее); ORM не знает заранее, какие атрибуты тронет код (магия order.customer.name — прозрачный доступ — цена прозрачности); «баг» — в отсутствии дисциплины: фреймворки дают инструменты (eager options, счётчики, debug-toolbar — Django/SQLAlchemy) — команда обязана их включить (тесты-счётчики/логирование); альтернативы-школы: явный SQL (N+1 невозможен — вы пишете запросы руками — цена — бойлерплейт маппинга), дата-лоадеры (GraphQL-мир — батчинг связей автоматически — тот же selectinload, но фреймворковый); вывод: N+1 — налог на удобство ORM — платится один раз настройкой eager+тестов, не каждый запрос.

selectinload или joinedload — что выбирать? selectinload (SELECT ... WHERE fk IN (...)): два+ запроса, нет дублирования строк, работает с любыми связями (включая коллекции и M2M) — дефолт; цена — отдельный round trip (микросекунды против пользы); joinedload (LEFT JOIN в основной запрос): один запрос — идеально для to-one (customer у order'а — JOIN дешёв); проблема с коллекциями — JOIN дублирует родительские строки (50 заказов × 20 позиций = 1000 строк результата — ORM дедуплицирует в памяти — трафик/память растут квадратично при вложенности); правило: to-one → joinedload, коллекции → selectinload, вложенные коллекции → selectinload-цепочки; Django-аналог: select_related = joinedload (только FK/O2O — JOIN), prefetch_related = selectinload (отдельные запросы) — симметрия школ; проверка — EXPLAIN обоих вариантов на реальных данных — иногда JOIN на малой таблице честно быстрее двух round trip'ов — профили решают спор.

Как отловить N+1 в проде, если в dev всё было тихо? Инструменты: лог медленных/частых запросов БД (log_min_duration_statement=100ms + анализ «стаи одинаковых» — pg_stat_statements (count × mean_time — «запрос выполнен 40k раз за час» — N+1-маркер — статистика); APM-трейсинг (OpenTelemetry — трейс с сотнями child-спанов SELECT — визуализация — Tempo/Jaeger); метрики: запросов-на-HTTP-запрос (гистограмма — p99 = 300 запросов — алерт — метрики); почему dev молчит: фикстуры мелкие (3 заказа — 1+3+3=7 — незаметно), данные — один пользователь (реальный — 50 заказов × вложенность); практика: синтетика «большого пользователя» в staging-тестах (нагрузочная — профиль «крупный клиент с 500 заказами» — N+1 проявляется объёмом — тесты-счётчики с реалистичным N — профилактика из прошлого вопроса).

Помогают ли индексы/кэш против N+1 или только eager loading? Индексы — нет по сути проблемы (каждый из 1800 запросов и так по PK — миллисекунда — боль в числе round trip'ов — «индексы добавили, стало 1750 мс вместо 1800» — смешно, но бывает); кэш — частично (Redis-кэш связанных объектов — «customer закэширован — 50 обращений → 1 попадание + 49 мимо» — помогает при горячих повторах, не при холодном списке — костыль, не лечение); eager loading — прямое лечение (1801 → 5 — форма запроса); смежное: батчинг руками (WHERE IN — тот же selectinload вручную — запросы), денормализация частых пар (customer_name в заказе — «связь-атрибут» — оправдано для списков/экспортов — jsonb-атрибуты — осознанная денормализация с обновлением по триггеру/коду); порядок: eager (лечение) → тест-счётчик (профилактика) → кэш/денормализация (только если остались узкие места — профили покажут).

Источники: документация SQLAlchemy (relationship loading), Django QuerySet API, PostgreSQL logging — ссылки в шапке.