← назад к разделу

Самая дорогая ошибка ORM выглядит безобидно: цикл по заказам, в котором читают order.customer.name. Локально на десяти заказах всё мгновенно, на проде на тысяче заказов обработчик делает тысячу и один запрос и укладывает базу. Разберём, откуда берётся запрос при обращении к атрибуту, какие стратегии загрузки есть у SQLAlchemy и как заметить N+1 до того, как его заметят пользователи.

Обязательно

Откуда берётся запрос при обращении к атрибуту

У relationship есть параметр lazy, и по умолчанию он равен select. Это значит: при загрузке заказа связь customer не загружается, а при первом обращении order.customer сессия выполняет отдельный SELECT ... FROM customer WHERE id = ?. Так устроено потому, что ORM не знает, понадобится ли вам клиент, а грузить всё дерево связей при каждом запросе ещё дороже.

orders = session.scalars(select(Order).limit(100)).all()     # 1 запрос
for order in orders:
    print(order.customer.name)                                 # до 100 запросов

Строго говоря, запросов будет меньше ста: identity map помнит уже загруженных клиентов, и если у десяти заказов один клиент, второй раз за ним в базу не пойдут. Но это утешение слабое: на разных клиентах получается ровно N+1.

Коллекции хуже одиночных ссылок. order.items для ста заказов это сто запросов, и каждый возвращает список, а на вложенных связях (order.items[i].product) рост квадратичный.

Три стратегии, которые нужно знать

Стратегию задают в запросе через options(), и это правильное место: один и тот же Order в списке грузят без позиций, а в карточке с позициями.

selectinload: сначала основной запрос, потом один дополнительный SELECT ... WHERE order_id IN (...) на всю коллекцию. Два запроса вместо N+1, строки не дублируются, подходит для коллекций любого размера. Выбор по умолчанию.

from sqlalchemy.orm import selectinload, joinedload, raiseload

stmt = select(Order).options(selectinload(Order.items)).limit(100)
orders = session.scalars(stmt).all()          # ровно 2 запроса

joinedload: один запрос с LEFT OUTER JOIN. Хорош для одиночных ссылок (Order.customer), где строк не прибавляется. Для коллекций даёт декартово умножение: заказ с десятью позициями приходит десятью строками, а LIMIT 100 ограничивает строки, а не заказы. Поэтому при joinedload на коллекцию SQLAlchemy требует .unique() на результате, и без него падает с ошибкой.

stmt = select(Order).options(joinedload(Order.customer))          # 1 запрос, одиночная ссылка
stmt = select(Order).options(joinedload(Order.items))
orders = session.scalars(stmt).unique().all()                      # коллекция: .unique() обязателен

raiseload: не грузить и запрещать. Обращение к связи выбрасывает исключение вместо запроса. Это страховка в репозитории: загрузили то, что нужно сценарию, а всё остальное закрыли, и случайный order.customer в шаблоне ответа упадёт в тесте, а не сделает тысячу запросов в проде.

stmt = select(Order).options(selectinload(Order.items), raiseload("*"))

Стратегии вкладываются: selectinload(Order.items).selectinload(OrderItem.product) загрузит и позиции, и товары тремя запросами на любое число заказов.

Как увидеть N+1

Три способа, от грубого к точному.

echo=True у движка локально: прогоняете обработчик и считаете строки SELECT в консоли. Если их число растёт с размером выборки, это N+1.

Счётчик запросов в тесте. SQLAlchemy даёт событие before_cursor_execute, на него вешают счётчик и проверяют, что обработчик списка делает не больше, скажем, трёх запросов независимо от числа строк:

from sqlalchemy import event

def count_queries(engine):
    counter = {"n": 0}
    def before(conn, cursor, statement, parameters, context, executemany):
        counter["n"] += 1
    event.listen(engine, "before_cursor_execute", before)
    return counter

def test_list_orders_has_no_n_plus_one(engine, session, orders_factory):
    orders_factory(50)
    counter = count_queries(engine)
    list_orders(session, limit=50)
    assert counter["n"] <= 3

Такой тест ловит N+1 навсегда: добавили поле в ответ, забыли стратегию, тест красный.

В проде это видно по метрикам: число запросов к базе на один HTTP-запрос. Если на GET /orders приходится сто обращений к базе, причину искать не нужно. Как снимать такие метрики, рассказывает статья про наблюдаемость.

Где задавать стратегию

Соблазн записать lazy="selectin" прямо в relationship велик: один раз и навсегда. Иногда это верно, для частей агрегата, которые всегда нужны вместе с корнем: позиции заказа без заказа не читают, и lazy="selectin" у Order.items честно отражает модель. Но для связей между агрегатами (Order.customer) стратегия по умолчанию в модели заставит грузить клиента везде, где нужен только заказ.

Правило: в модели стратегию задают только для связей внутри агрегата, всё остальное решает запрос в репозитории. Репозиторий знает сценарий (list_for_customer, get_with_items), а модель нет.

Когда ленивая загрузка уместна

Ленивая загрузка не ошибка сама по себе. В обработчике команды, который загружает один заказ, проверяет инвариант и сохраняет, обращение к order.items сделает один запрос, и это нормально. Проблема начинается в циклах и в коде, который отдаёт объекты наружу, где к связям обращаются бесконтрольно: сериализатор, шаблон, слой API.

Поэтому граница сессии и есть граница ленивой загрузки. Объект, который покинул сессию (ответ репозитория, который сериализуют после commit и close), не должен иметь незагруженных связей: либо загрузили стратегией, либо закрыли raiseload, либо отдали наружу не объект модели, а собранный DTO. Иначе снаружи сессии обращение падает с DetachedInstanceError, а внутри тихо делает запросы.

Дополнительно: при первом чтении можно пропустить

Глубже: что ещё умеют стратегиирасширенное

subqueryload делает то же, что selectinload, но вторым запросом с подзапросом исходного SELECT вместо IN (...). Исторически он был первым, сейчас уступает selectinload по читаемости плана и нужен редко: когда основной запрос с LIMIT и OFFSET такой, что список идентификаторов для IN слишком длинный.

lazy="write_only" для огромных коллекций: атрибут нельзя прочитать как список вовсе, только добавлять, удалять и строить по нему запрос через select(). Это честный ответ на «у клиента миллион событий»: коллекция существует как связь, но никто случайно её не загрузит.

defer() и undefer() это ленивая загрузка на уровне колонок, а не связей: тяжёлый JSONB или текст описания не читают в списках и дочитывают в карточке. С raiseload они не связаны, но решают ту же задачу: не тащить лишнее. Подробнее об этом в статье про производительность.

И про идентичность: selectinload опирается на первичный ключ и identity map, поэтому для одного и того же клиента у разных заказов будет один объект. Если вы меняете order.customer.name в цикле, меняете один объект, а не копии, и это именно то, чего ждут от ORM.

Коротко

  • lazy="select" по умолчанию: связь грузится отдельным запросом при первом обращении, в цикле это N+1; identity map уменьшает число запросов, но не спасает.
  • selectinload даёт два запроса на любую коллекцию и выбирается по умолчанию; joinedload для одиночных ссылок, на коллекциях требует .unique() и ломает LIMIT.
  • raiseload("*") закрывает незагруженные связи: случайное обращение падает в тесте, а не делает тысячу запросов в проде.
  • N+1 видно по echo=True, по счётчику на before_cursor_execute в тесте и по метрике «запросов к базе на один HTTP-запрос».
  • Стратегию задают в запросе репозитория под сценарий; в модели только для связей внутри агрегата.
  • Граница сессии равна границе ленивой загрузки: наружу уходят объекты с загруженными связями или собранные DTO.
  • Для огромных коллекций lazy="write_only", для тяжёлых колонок defer.

Что почитать дальше