Рекламный баннер: ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "ЦЕНТР НАЦИОНАЛЬНЫХ ИНТЕЛЛЕКТУАЛЬНЫХ СИСТЕМ"

N+1 запросов в ORM: как найти проблему в логах и профилировщике и исправить её без переписывания слоя данных

Эндпоинт со списком заказов на локальной базе отвечает за 80 мс. На стейдже с живыми данными он уже думает две секунды, а в профилировщике вместо одного SQL-запроса висит сотня почти одинаковых. Код при этом выглядит невинно: цикл по заказам и обращение к order.customer.name.

Это классический случай N+1 запросов ORM: один запрос забирает список, а потом на каждую запись уходит ещё по одному запросу за связанными данными. Библиотека прячет их за обычным обращением к атрибуту, поэтому на ревью они не видны, а на тестовых пяти записях не чувствуются.

Ниже разберём, как заметить проблему по симптомам, как поймать её в логах и профилировщике пяти популярных ORM, как написать тест, который не даст ей вернуться, и как выбрать способ исправления. Отдельно поговорим о побочных эффектах самих исправлений: раздутых выборках, декартовом произведении и ловушках с пагинацией.

Коротко:

  • Симптом: время ответа растёт вместе с размером страницы, а в slow query log пусто, потому что каждый отдельный запрос быстрый.
  • Поймать эффект проще всего подсчётом SQL-запросов на одном обращении к эндпоинту, а не чтением кода.
  • Чаще всего хватает одной правки в месте выборки: подгрузка связей заранее, без изменения моделей и бизнес-логики.
  • Выбор между join, отдельным запросом по списку id и батчингом зависит от типа связи и от того, нужна ли пагинация.
  • Лучшая защита после исправления: тест, который сравнивает число запросов на малом и большом наборе данных.
  • Кэш здесь не лечение. Он прячет лишние обращения, но не убирает их.

Как выглядит эффект и почему ORM его скрывает

Возьмём Django. Выбираем сто заказов и в цикле печатаем имя клиента: for o in Order.objects.all()[:100]: print(o.customer.name). Первый запрос достаёт заказы. Затем при каждом обращении к o.customer библиотека видит, что связанный объект ещё не загружен, и делает отдельный SELECT по внешнему ключу. Итого 101 запрос вместо одного или двух.

Такое поведение называется ленивой загрузкой: связанные данные подтягиваются в момент первого обращения. Само по себе оно полезно. Если связь нужна для одной записи, тащить её заранее незачем. Беда начинается, когда обращение случается внутри цикла по набору.

Вложенность усиливает эффект. Заказы, в них позиции, у позиций товары: получаем 1 запрос на заказы, N на позиции и ещё N*M на товары. На странице из 50 заказов по 4 позиции это уже 251 запрос, хотя в коде всё те же три безобидные точки.

Есть и скрытая разновидность. В JPA связь @ManyToOne по умолчанию загружается жадно, то есть сразу. Запрос на список через JPQL или Criteria не учитывает эту настройку при построении SQL и потом догружает связанные сущности по одной. Разработчик свято верит, что ленивой загрузки у него нет, а запросы всё равно сыплются.

Симптомы: как заподозрить проблему до профилировщика

Профилировщик нужен для подтверждения, но у проблемы есть узнаваемый почерк. Если нашли два-три признака из списка, можно уверенно открывать логи.

  • Время ответа линейно растёт с размером страницы: 10 элементов отдаются за 100 мс, 100 элементов за секунду.
  • Нагрузка на процессор базы низкая, зато счётчик запросов в секунду зашкаливает, а число соединений в пуле растёт.
  • В slow query log тишина. Каждый запрос по первичному ключу занимает миллисекунду, и порог медленных их не ловит. Медленным становится обработчик целиком.
  • В APM или трассировке виден «гребешок»: десятки коротких одинаковых спанов подряд в одном запросе.
  • Эндпоинт нормально работает на небольших данных и ломается после импорта или роста клиентской базы.

Если жалуются на медленный API из-за запросов к БД, а индексы на месте и планы выполнения выглядят чисто, эту гипотезу стоит проверять первой. Типичная ошибка здесь в том, чтобы тюнить сами запросы, хотя проблема не в скорости одного запроса, а в их количестве. Если же тормозит один тяжёлый запрос, разберите его план: как это делать, мы показали в статье о медленных SQL-запросах и EXPLAIN ANALYZE.

Логи и профилировщики: что включить в каждом стеке

Принцип один: включить вывод SQL, прогнать один запрос к эндпоинту и посчитать, сколько раз повторяется шаблон с разными параметрами. Инструменты отличаются только названиями настроек.

СтекЧто включитьНа что смотреть
DjangoЛоггер django.db.backends на уровне DEBUG, django-debug-toolbar, connection.queriesПанель SQL показывает дубли и подсвечивает похожие запросы
Hibernate / JPAЛоггер org.hibernate.SQL, hibernate.generate_statistics=trueВ статистике растёт число подготовленных запросов и загрузок сущностей
SQLAlchemycreate_engine(..., echo=True) или событие before_cursor_executeСерия одинаковых SELECT ... WHERE id = ? после основного запроса
EloquentDB::listen, Laravel Debugbar, TelescopeКоличество запросов на страницу и дубли шаблонов
ActiveRecordЛог разработки, gem Bullet, подписка на sql.active_recordBullet прямо пишет, какую связь пора подгрузить заранее

Читать логи удобнее не глазами, а через группировку. Нормализуйте текст запроса, заменив параметры на плейсхолдеры, и посчитайте частоты: запрос, который встречается 100 раз на один вызов эндпоинта, и есть подозреваемый. Эту работу часто делают сами APM-системы. Распределённая трассировка тоже поможет, но только как один из способов поиска, и локально её хватает не всегда.

Чтобы такие проблемы были видны без ручного поиска, нужны метрики, логи и трейсы с числом запросов на вызов. Как это выстроить, мы разобрали в статье про observability в backend.

Тест, который считает запросы и не даёт проблеме вернуться

Исправление без теста живёт до первого рефакторинга. Кто-то уберёт select_related или добавит в сериализатор новое поле, и всё вернётся. Тест на количество запросов стоит нескольких строк.

  • Django: assertNumQueries(2) в тесте или CaptureQueriesContext для просмотра самих запросов.
  • Hibernate: сбросить статистику перед вызовом и проверить getPrepareStatementCount(). Есть и готовые прокси-библиотеки, которые умеют падать при превышении лимита.
  • SQLAlchemy: слушатель события before_cursor_execute, который складывает запросы в список, и проверка длины списка.
  • Eloquent: DB::enableQueryLog() в начале теста и count(DB::getQueryLog()) в конце.
  • ActiveRecord: подписка на sql.active_record через ActiveSupport::Notifications, исключая служебные запросы схемы и транзакций.

Самый сильный вариант проверки не требует знать точную цифру. Создайте в фикстурах 3 записи, замерьте число запросов, потом создайте 30 и замерьте снова. Если числа различаются, где-то в цикле прячется лишнее обращение к базе. Такой тест не ломается, когда вы добавляете новое необходимое поле, и сразу ловит регрессию.

Фиксируйте в тесте точное число запросов только для горячих эндпоинтов. На остальных лучше сравнение малого и большого набора, иначе тесты начнут падать при каждой безобидной правке.

Исправление без переписывания: что включить в каждом ORM

Лечение почти всегда локально: меняется место, где формируется выборка, а модели, сервисы и шаблоны остаются нетронутыми. Идея одна: сказать библиотеке заранее, какие связи понадобятся.

Django: select_related и prefetch_related

Связи вида «многие к одному» и «один к одному» закрывает select_related('customer'): библиотека делает один запрос с JOIN. Для обратных внешних ключей и «многие ко многим» подходит prefetch_related('items'): выполняются два запроса, второй с условием IN по списку id из первого. Если нужна фильтрация вложенного набора, передайте объект Prefetch('items', queryset=Item.objects.filter(active=True)). Правило выбора простое: JOIN для одиночных связей, отдельный запрос для коллекций.

Hibernate и JPA: join fetch, EntityGraph, BatchSize

Самый прямой путь в Hibernate: переписать запрос как select o from Order o join fetch o.customer. В Spring Data то же достигается аннотацией @EntityGraph(attributePaths = {"customer"}) на методе репозитория, и текст запроса менять не придётся. Если связей много и join становится тяжёлым, включите батчинг: @BatchSize(size = 50) на связи или глобальное hibernate.default_batch_fetch_size. Тогда вместо N запросов будет N/50, и модель менять не нужно. Заодно пересмотрите @ManyToOne, который по умолчанию жадный: переведите его в FetchType.LAZY, а нужные связи подтягивайте явно.

SQLAlchemy: joinedload и selectinload

Для одиночных связей берите joinedload(Order.customer), для коллекций selectinload(Order.items): он делает второй запрос с IN по первичным ключам и не размножает строки. Для защиты укажите в связи lazy='raise' или добавьте в запрос raiseload('*'): любое незаявленное ленивое обращение тогда падает с ошибкой, и проблема всплывает на тестах, а не в продакшене.

Eloquent: with и preventLazyLoading

Заранее загрузить связи можно через Order::with(['customer', 'items']), а для уже полученной коллекции через $orders->load('customer'). Если нужны только счётчики, используйте withCount('items'), и сами позиции в память не попадут. Чтобы ловить промахи в разработке, вызовите Model::preventLazyLoading(! app()->isProduction()): в локальной среде ленивое обращение к связи бросит исключение.

ActiveRecord: includes, preload, eager_load

Тут три варианта. preload всегда делает отдельные запросы, eager_load всегда строит LEFT OUTER JOIN, а includes сам выбирает между ними и переключается на JOIN, если в условиях участвует таблица связи. Для защиты есть strict_loading: при включении ленивая загрузка вызывает ошибку или запись в лог.

Join, отдельный запрос по списку id или батчинг: как выбрать

Главный вопрос в том, что вы забираете: одиночную связь или коллекцию, и нужна ли пагинация. От этого зависит, какая цена окажется ниже.

СпособКогда подходитЧем рискуем
Join в одном запросеСвязь «многие к одному», нужны поля соседней таблицы, страница небольшаяДублирование строк при коллекциях, широкие строки
Второй запрос с IN по idКоллекции, несколько связей одновременно, пагинация родителейДлинный список id при очень больших выборках, лишний сетевой заход
Батчинг ленивой загрузкиНельзя заранее знать, какие связи понадобятся, правок в коде нужно минимумЗапросы всё равно остаются, просто группируются
Отдельный запрос с агрегатомНужен счётчик или сумма, а не сами записиНужно аккуратно написать группировку, чтобы не потерять родителей без детей

Рабочее правило: одиночные связи через join, коллекции через второй запрос. Так вы получаете постоянное число обращений и не раздуваете результат. Батчинг хорош как страховочная сетка на уровне конфигурации: он не заменяет явную подгрузку на горячих путях, но смягчает последствия забытых мест.

Слепая гонка за минимальным числом запросов тоже не цель. Три запроса на экран, которые отдают то, что нужно, лучше одного гигантского с JOIN на пять таблиц, который тащит мегабайты лишних колонок.

Побочные эффекты исправления: декартово произведение, раздутые выборки, пагинация

Исправление по принципу «подгрузим всё сразу» создаёт собственные проблемы. Вот три самых частых.

Декартово произведение. Если в одном запросе сделать join по двум коллекциям, например заказ с 10 позициями и 5 платежами, база вернёт 50 строк на заказ. Для 100 заказов это 5000 строк, и все они придут с дублями колонок заказа. В Hibernate при попытке жадно загрузить две коллекции типа List вы вообще получите MultipleBagFetchException. Лекарство: одну коллекцию тянуть join-ом, другую отдельным запросом, либо использовать Set и батчинг, либо в SQLAlchemy и Django брать второй запрос для каждой коллекции.

Пагинация с коллекциями. Join по коллекции плюс LIMIT режет не родителей, а строки результата. Hibernate в такой ситуации пишет предупреждение про применение firstResult и maxResults в памяти и грузит всю выборку в приложение, а потом обрезает. Безопасная схема из двух шагов: сначала постранично выбрать id родителей, затем одним запросом достать родителей вместе со связями по этим id. Prefetch в Django и selectinload в SQLAlchemy делают то же самое автоматически.

Раздутые выборки. Подгруженная связь приносит все колонки, включая тяжёлые: тексты, JSON, блобы. Если в ответе нужны только имя и статус клиента, ограничьте поля: only('id', 'name') в Django, проекцию или DTO-запрос в JPA, load_only в SQLAlchemy, select() внутри with() в Eloquent. Заодно уменьшится и расход памяти приложения.

Представим экран заказов на 100 строк. У каждого заказа 8 позиций и 3 платежа. Join по обеим коллекциям вернёт 2400 строк. Два отдельных запроса с IN вернут 100 заказов, 800 позиций и 300 платежей, то есть 1200 строк суммарно, без дублирования колонок заказа.

Сериализаторы и GraphQL-резолверы: где проблема прячется глубже

В ревью выборку обычно смотрят внимательно, а вот сериализацию нет. Именно там чаще всего и рождаются скрытые обращения к базе: вложенный сериализатор в Django REST Framework, поле SerializerMethodField, которое что-то считает по связи, Jackson, обходящий ленивые коллекции сущности, as_json с include в Rails, ресурс Laravel, который читает $this->author->name для каждого элемента.

Практическое правило: перечень связей, нужных ответу, должен жить рядом с сериализатором. В DRF это метод get_queryset вьюсета, где подгружаются поля, вложенные в сериализатор. В Laravel это whenLoaded в ресурсе вместе с with в контроллере. И тест, который считает запросы на уровне HTTP, а не на уровне менеджера, поймает рассинхрон между ними.

В GraphQL ситуация хитрее, потому что сервер не знает заранее, какие поля запросит клиент. Резолвер автора для каждого поста в списке превращается в тот самый цикл. Стандартное решение называется DataLoader: резолверы не ходят в базу сразу, а складывают ключи в очередь, а загрузчик в конце такта собирает их в один пакет и делает один запрос с IN. Оригинальную реализацию выпустила команда Facebook в виде библиотеки graphql/dataloader. Аналоги есть везде: встроенный загрузчик в Strawberry для Python, java-dataloader вместе с graphql-java и аннотация @BatchMapping в Spring for GraphQL.

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

Когда лечить не нужно и где обычно ошибаются

Не каждое повторение запроса требует исправления. Если на странице всегда три элемента, лишние два запроса за связями ничего не стоят, а усложнённый код стоит. Не трогайте и редкие админские выгрузки, где время не критично. Оптимизация оправдана там, где число повторов зависит от данных, а эндпоинт горячий.

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

Типичные ошибки при исправлении выглядят так:

  • Включить жадную загрузку на уровне модели для всех сценариев. Редкие запросы начнут тащить связи, которые им не нужны, и общая нагрузка вырастет.
  • Чинить симптом в сериализаторе, оставляя выборку прежней: загруженные в сериализаторе объекты снова уходят в ленивые запросы по каждой строке.
  • Вызвать .count() или .exists() по связи внутри цикла, хотя связь уже подгружена. Это порождает новый запрос на каждую строку, хотя данные лежат в памяти.
  • Фильтровать подгруженную коллекцию в Python или применять filter() к менеджеру связи, из-за чего предзагруженный набор игнорируется и запрос уходит заново.
  • Обложиться join fetch на всех коллекциях, не проверив число строк в результате.
  • Считать работу законченной, не добавив тест. Через месяц новое поле в ответе снова запустит цикл запросов.

Чеклист

  1. Включите вывод SQL в локальной среде и откройте проблемный эндпоинт с реалистичным набором данных.
  2. Сгруппируйте запросы по шаблону и найдите тот, что повторяется десятки раз.
  3. Определите по стеку вызовов, где именно происходит обращение: в представлении, шаблоне, сериализаторе или резолвере.
  4. Для одиночных связей подключите join-загрузку, для коллекций загрузку вторым запросом с IN.
  5. Если есть пагинация и коллекции, сначала выбирайте id родителей, потом подгружайте детей.
  6. Ограничьте набор полей там, где связь нужна ради пары колонок.
  7. Проверьте число строк в результате на запросах с несколькими коллекциями.
  8. Добавьте тест, который сравнивает число запросов на 3 и на 30 записях.
  9. Включите в разработке запрет или предупреждение на ленивую загрузку, где ORM это поддерживает.
  10. Для GraphQL перейдите на загрузчики с пакетной выборкой и создавайте их на каждый запрос.

часто задаваемые вопросы

Это ситуация, когда приложение делает один запрос за списком и затем по одному запросу за связанными данными для каждой записи. Вместо двух запросов получается сотня. Растёт задержка, нагружаются сеть и пул соединений, хотя каждый запрос по отдельности быстрый.

При ленивой загрузке связанные данные подтягиваются в момент обращения, при жадной берутся заранее вместе с основной выборкой. Универсального выигрышного варианта нет. Ленивая подходит для одиночных обращений, жадная для списков, где связь нужна каждой строке. Поэтому настраивать загрузку лучше на уровне конкретного запроса, а не модели.

Первый строит JOIN и подходит для связей «многие к одному» и «один к одному». Второй делает отдельный запрос с условием по списку id и подходит для обратных ключей и «многие ко многим». Если сомневаетесь, для коллекций выбирайте prefetch_related.

Когда в одном запросе грузятся две коллекции, число строк умножается, а при пагинации Hibernate режет результат уже в памяти. Лечится разделением на два запроса, батчингом или двухшаговой выборкой: сначала id, затем сущности по ним.

Смотрите на APM или трассировку: ищите запросы, где в одном вызове эндпоинта десятки одинаковых SQL-спанов. Помогает и метрика числа запросов на обращение, на которую можно поставить алерт при росте.

Нет, он лишь скрывает её, пока данные находятся в нём. После сброса или промаха нагрузка возвращается целиком. Лучше сначала исправить выборку, а кэш использовать как отдельный слой поверх.

Итог

Лишние запросы появляются там, где ORM делает за разработчика слишком много неявной работы, а находятся они одним способом: подсчётом SQL на один вызов эндпоинта. Включите вывод запросов, сгруппируйте повторы и найдите место, где связь читается в цикле.

Исправление почти всегда локальное: одиночные связи через join, коллекции вторым запросом, лишние колонки обрезать. После этого закрепите результат тестом, который сравнивает число запросов на малом и большом наборе, и включите в разработке запрет на ленивые обращения. Тогда проблема не вернётся вместе с очередным новым полем в ответе.

Вопросы про N+1 и ленивую загрузку часто звучат на собеседованиях backend-разработчиков. Примеры таких вопросов есть в подборках для Python-разработчика и для Java-разработчика, а свежие вакансии backend-разработчиков собраны на HireHi.