12k
All articles

Разбираемся с проблемой N+1 запросов

Проблема N+1 объясняется на примерах Rails, Django, Hibernate и Laravel, а также разбираются eager loading, JOIN и способы выявления.

OpenReplay Team
OpenReplay Team
Разбираемся с проблемой N+1 запросов

Проблема N+1 запросов возникает, когда приложение выполняет один запрос для получения списка из N записей, а затем — по одному дополнительному запросу на каждую запись для загрузки связанной сущности: N+1 запросов там, где хватило бы одного-двух.

Большинство разработчиков сталкиваются с ней одинаково. Страница, которая мгновенно отрабатывала на тестовой базе с сидированными данными, начинает еле шевелиться на реальных данных, а в логе запросов обнаруживается один и тот же SELECT, повторённый четыреста раз с разными id.

Это самый распространённый баг производительности в коде, использующем объектно-реляционный маппер, и проявляется он одинаково в Rails, Django, Hibernate и Laravel — потому что все они разделяют одно и то же поведение по умолчанию: ленивую загрузку. В этой статье мы определим паттерн, покажем его в четырёх стеках, разберём точное решение для каждого фреймворка, развеем устойчивое заблуждение о жадной выборке и расскажем, как отлавливать N+1 до того, как он доедет до продакшена.

Ключевые выводы

  • Проблема N+1 запросов — это один запрос для загрузки N родительских строк плюс N последующих запросов для загрузки связанной записи к каждой из них. Количество запросов растёт линейно с N.
  • Она возникает потому, что большинство ORM по умолчанию загружают ассоциации лениво, поэтому обращение к связи внутри цикла незаметно порождает запрос на каждой итерации.
  • Есть два корректных решения, и оба сводят N+1 к постоянному числу запросов: один JOIN, загружающий родителей и потомков вместе, либо второй пакетный запрос с WHERE id IN (...).
  • Установка FetchType.EAGER в JPA не устраняет N+1 при использовании JPQL. Она меняет момент, когда выполняются дополнительные запросы, а не то, будут ли они объединены в пакет.
  • Логи запросов в среде разработки отлавливают N+1 только на тех путях исполнения, которые вы случайно затронули; production-APM отлавливает те случаи, которые ваш локальный набор данных был слишком мал, чтобы выявить.

Что такое проблема N+1 запросов?

Возьмём связь posts/authors. Вы выполняете один запрос для загрузки всех постов, затем проходите по ним циклом и читаете post.author.name у каждого. Это обращение к свойству — второй запрос, повторяющийся по разу на каждый пост. Десять постов дают одиннадцать запросов; тысяча постов — тысячу один, и общее время ответа растёт линейно с числом записей.

Именно линейный рост делает N+1 опасным. Баг N+1 обычно незаметен в разработке на пяти сидированных строках и превращается в аварию на продакшене с пятью тысячами. Эндпоинт, отвечавший за 40 мс на вашем ноутбуке, отвечает за 4 секунды или отваливается по таймауту, как только приходят реальные данные.

Почему возникает N+1?

N+1 возникает потому, что большинство ORM по умолчанию загружают ассоциации лениво: связанный объект извлекается не при загрузке родителя, а при первом обращении. Связи в Eloquent ведут себя именно так. Чтение связи как свойства порождает запрос в момент обращения, а не в момент загрузки родительской модели, а жадная загрузка — это альтернатива, которую надо включать явно. То же верно для прокси ActiveRecord, related managers в Django и ленивых прокси Hibernate.

Внутри цикла такое ленивое обращение происходит незаметно и на каждой итерации. Ничто в исходном коде на это не указывает: ни ключевого слова N+1, ни предупреждения. Именно поэтому оно проходит код-ревью и всплывает только под нагрузкой.

Как это выглядит в коде

Схема «до/после» одинакова в любом стеке: цикл, обращающийся к связи, переписывается так, чтобы связь загружалась заранее.

Rails (ActiveRecord):

# N+1: 1 query for books + 1 per book for the author
Book.limit(10).each { |book| puts book.author.last_name }

# Fixed: 2 queries total
Book.includes(:author).limit(10).each { |book| puts book.author.last_name }

Django ORM:

# N+1: 1 query for books + 1 per book for the author
for book in Book.objects.all():
    print(book.title, book.author.name)

# Fixed: one JOIN
for book in Book.objects.select_related("author"):
    print(book.title, book.author.name)

JPA / Hibernate (JPQL):

// N+1: findAll() loads transports, then one SELECT per driver on access
List<Transport> all = transportRepository.findAll();

// Fixed: a single fetch join
@Query("SELECT t FROM Transport t JOIN FETCH t.driver")
List<Transport> findAllWithDriver();

Чистый SQL: замените построчный поиск одним LEFT JOIN, используя именно LEFT, чтобы сохранить родителей без потомков:

SELECT c.id, c.name, i.id AS item_id, i.name AS item_name
FROM categories c
LEFT JOIN items i ON i.category_id = c.id
ORDER BY c.name, i.name;

Как исправить проблему N+1 запросов

Есть два корректных способа устранить N+1, и оба сводят его к постоянному числу запросов: один JOIN, загружающий родителей и потомков вместе, либо второй пакетный запрос, который извлекает все связанные строки одним WHERE id IN (...). JOIN обходится одним обращением к базе, но может дублировать родительские строки (и приводить к декартову взрыву при нескольких коллекциях); пакетный запрос требует двух обращений, зато не возвращает дублирующихся данных. Каждый фреймворк предоставляет обе стратегии под разными названиями.

ФреймворкСтратегия JOIN (один запрос)Стратегия пакетного запроса (WHERE id IN)
Railseager_load(:assoc)preload(:assoc)
Rails (авто)includes(:assoc) (Rails выбирает сам)includes(:assoc)
Djangoselect_related("assoc")prefetch_related("assoc")
JPA/HibernateJOIN FETCH / @EntityGraph / QueryDSL fetchJoin()пакетная выборка (@BatchSize)
Laravelwith('assoc')
Чистый SQLLEFT JOINвторой SELECT ... WHERE fk IN (...)

Два метода Django путают чаще всего, поэтому стоит быть точным в том, что каждый из них делает. Справочник по API QuerySet в Django проводит границу по арности связи: select_related строит JOIN и подтягивает связанные строки в том же операторе, что работает только когда у родителя не более одной связанной записи, — то есть покрывает ForeignKey и OneToOneField. prefetch_related выполняет собственный запрос на каждую связь и сшивает результаты уже в Python, что и позволяет ему работать с ManyToManyField и обратными внешними ключами.

Rails распределяет то же различие между тремя методами. Руководство по интерфейсу запросов Active Record описывает preload как выполнение одного дополнительного запроса на каждую указанную ассоциацию, а eager_load — как извлечение всего через единственный LEFT OUTER JOIN. includes находится между ними: документация API описывает его как метод, по умолчанию выполняющий отдельный запрос на ассоциацию и переключающийся на join только тогда, когда условия запроса это вынуждают. Коротко: preload — это всегда отдельный запрос, eager_load — всегда JOIN, а includes оставляет выбор за ActiveRecord.

В Laravel with() — канонический способ исправления через жадную загрузку: он выполняет один пакетный запрос для связи. В Laravel 12.8 появился Model::automaticallyEagerLoadRelationships(), который автоматически жадно загружает любую связь, к которой обращается коллекция, без явного вызова with().

Почему FetchType.EAGER не решает проблему N+1

Установка FetchType.EAGER не устраняет N+1 при использовании JPQL. Жадная выборка меняет момент, когда выполняются дополнительные запросы, а не то, объединяются ли они в пакет, — поэтому вам всё равно нужен JOIN FETCH или @EntityGraph. Это самое распространённое заблуждение относительно JPA. Руководство пользователя Hibernate ORM формулирует это прямо: JPQL-запрос, не включивший жадную ассоциацию в свой план выборки, заставляет Hibernate выполнить по одному дополнительному select на каждую жадную ассоциацию, — а это тот же N+1 под другим названием; и само руководство рекомендует размечать ассоциации как ленивые и подтягивать их жадно, запрос за запросом.

Общий принцип справедлив для всех ORM: конфигурирование связи как жадной на уровне маппинга — это решение о моменте загрузки, а не о пакетировании. Именно fetch join или entity graph действительно загружает ассоциацию одним оператором, схлопывая родителя и потомков в одно обращение к базе.

Как обнаруживать N+1 запросы

Начните с чтения SQL, который порождает ваша ORM. Лог разработки в Rails печатает каждый запрос; Django показывает счётчики через django-debug-toolbar; Hibernate логирует операторы при spring.jpa.show-sql=true; Laravel выводит их через Laravel Debugbar. Повторяющиеся, почти идентичные SELECT, различающиеся только id, — характерная сигнатура.

Инструменты «падать сразу» превращают N+1 в ошибку ещё на этапе разработки. Гем Bullet предупреждает о неоптимизированных ассоциациях в Rails (только в dev/test), Python-библиотека nplusone логирует нарушения, а в Laravel Model::preventLazyLoading() делает ленивое обращение «громким»: с включённой настройкой связь, разрешаемая постфактум, выбрасывает LazyLoadingViolationException вместо того, чтобы тихо выполнить ещё один запрос. Ограничьте это непродакшен-средами, чтобы пропущенная связь никогда не уронила живой запрос.

Подвох в том, что логи запросов в разработке отлавливают N+1 только на тех путях исполнения, которые вы случайно затронули; production-APM отлавливает те, которые ваш локальный набор данных был слишком мал, чтобы выявить. Системы мониторинга производительности приложений отслеживают каждый запрос в каждом HTTP-запросе и фоновой задаче, помечая повторяющиеся паттерны с точным местом вызова. Такого покрытия инструменты только для разработки — вроде Bullet и debug toolbar — обеспечить не могут.

Когда N+1 допустим?

Не каждый N+1 нужно исправлять. Когда N мало и ограничено — скажем, страница, всегда отображающая ровно три элемента, — дополнительные запросы могут обойтись дешевле, чем затраты на поддержку цепочки предзагрузок. Когда связанные записи уже отдаются из кеша запросов или кеша приложения, «лишние» запросы могут вообще не доходить до базы. И иногда явный цикл с комментарием читается понятнее, чем вложенная жадная загрузка. Это исключения; относитесь к ним как к осознанным, задокументированным решениям, потому что N со временем имеет свойство расти, даже когда вы уверены в обратном.

Паттерн — это одна концепция с разным написанием в разных фреймворках, поэтому выучите его один раз: замечайте связь, к которой обращаются внутри цикла, выбирайте JOIN или пакетный запрос и настраивайте детектирование так, чтобы следующий N+1 падал на вашей машине, а не у ваших пользователей.

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

В чём разница между жадной загрузкой через JOIN и жадной загрузкой пакетным запросом?

Решение через JOIN (eager_load в Rails, select_related в Django, JOIN FETCH в JPA, LEFT JOIN в чистом SQL) загружает родителей и потомков одним запросом, но может дублировать родительские строки и вызывать декартов взрыв при нескольких коллекциях. Решение через пакетный запрос (preload в Rails, prefetch_related в Django, with() в Laravel) выполняет второй запрос с WHERE id IN (...), добавляя одно обращение к базе, но не возвращая дублирующихся строк. Оба сводят N+1 к постоянному числу запросов.

Устраняет ли установка FetchType.EAGER проблему N+1 в Hibernate?

Нет. При JPQL-запросе FetchType.EAGER не объединяет связанные сущности в пакет; Hibernate выполняет вторичный SELECT для каждой нужной ему жадной ассоциации, что воспроизводит N+1. Жадная выборка меняет момент, когда выполняются дополнительные запросы, а не то, объединяются ли они в пакет. Чтобы действительно загрузить ассоциацию одним оператором, нужны JOIN FETCH, @EntityGraph или QueryDSL fetchJoin(). Это поведение не изменилось вплоть до Hibernate 7.

Почему баги N+1 проходят код-ревью и локальное тестирование, но ломаются в продакшене?

N+1 невидим в исходном коде, потому что ленивая загрузка незаметно порождает запрос при обращении к связи внутри цикла — без какого-либо ключевого слова или предупреждения. Количество запросов растёт линейно с N, поэтому пять сидированных строк дают быстрые шесть запросов в разработке, тогда как пять тысяч строк дают пять тысяч один в продакшене. К тому же логи запросов в разработке отлавливают N+1 только на тех путях исполнения, которые вы случайно затронули, — именно поэтому production-APM обнаруживает те случаи, которые ваш локальный набор данных был слишком мал, чтобы выявить.

Какой метод Django использовать: select_related или prefetch_related?

Используйте select_related для связей ForeignKey и OneToOneField: он выполняет SQL JOIN и загружает связанные объекты тем же запросом. Используйте prefetch_related для ManyToManyField и обратных внешних ключей: он выполняет отдельную выборку на каждую связь и соединяет результаты в Python. Неверный выбор — самая частая ошибка с N+1 в Django: prefetch_related нельзя применять к одиночным прямым связям так, как для этого предназначен select_related.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.