Самая дорогая ошибка 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.
Что почитать дальше
- Запросы —
select()в стиле 2.0, соединения, агрегаты и постраничная выборка. - Производительность — пул, пакетные операции,
yield_perи отложенные колонки. - Связи между таблицами — как объявлены связи, к которым применяются стратегии.