Короткий ответ
Проблема N+1 возникает, когда для получения связанных данных по N строкам основного запроса ORM выполняет один дополнительный SQL-запрос на каждую строку (всего N+1 запросов) вместо одного объединённого запроса; решается через явную загрузку связей — select_related/prefetch_related в Django или joinedload/selectinload в SQLAlchemy.
Как это работает подробнее
Ленивая загрузка связанных объектов по умолчанию откладывает запрос к связанной таблице до момента обращения к атрибуту; в цикле по списку объектов это превращается в отдельный запрос на каждую итерацию.
Пример кода
example.py
# Плохо: N+1 запрос — по одному SELECT authors на каждую книгу
books = Book.objects.all()
for book in books:
print(book.author.name)
# Хорошо: один дополнительный JOIN
books = Book.objects.select_related("author").all()
for book in books:
print(book.author.name)
# Проверка: django-debug-toolbar покажет 1 запрос вместо N+1Что использовать на практике
- Практика. Проблему обнаруживают через логирование SQL (django-debug-toolbar, echo=True в SQLAlchemy) или APM; select_related (JOIN, для ForeignKey) и prefetch_related (отдельный IN-запрос, для ManyToMany) в Django, joinedload/selectinload в SQLAlchemy решают проблему заранее указанной стратегией.
Подводные камни
- Ошибка. Добавляют select_related на ManyToMany-связь — не работает, для таких связей нужен prefetch_related; забывают, что prefetch_related всё равно выполняет отдельный запрос, который для очень больших выборок нужно ограничивать.