Проблема N+1: select_related и prefetch_related

Страница, которая делает один запрос в коде и двести в базе, — это почти всегда N+1. Лечится двумя методами: select_related и prefetch_related.

Проблема N+1 — антипаттерн, при котором один запрос за списком из N объектов влечёт ещё N запросов (по одному на объект) за их связанными данными.

Это, пожалуй, главная причина медленных страниц на Django. Код выглядит невинно — обычный цикл по статьям с выводом имени автора. Но из прошлого урока мы знаем: доступ к связанному объекту, которого нет в памяти, заставляет ORM сходить в базу. В цикле это превращается в лавину запросов.

Откуда берётся N+1

Пусть у Article есть ForeignKey на Author. Смотрим на цикл:

articles = Article.objects.all()      # запрос №1: SELECT * FROM article

for a in articles:
    print(a.author.name)              # на КАЖДОЙ итерации — отдельный SELECT author

Первый запрос принёс 100 статей. Но a.author — это связанный объект, и в выборке статей его данных нет, только author_id. Значит, на каждой из 100 итераций Django делает SELECT * FROM author WHERE id = ?. Итого 1 + 100 = 101 запрос вместо одного-двух. Это и есть N+1: один начальный плюс N дочерних.

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

select_related: один JOIN для ForeignKey и OneToOne

Если связь «к одному» (ForeignKey, OneToOneField), решение — select_related. Он добавляет JOIN и забирает связанные данные тем же запросом:

articles = Article.objects.select_related("author")  # один SELECT с JOIN author

for a in articles:
    print(a.author.name)   # данные автора уже в памяти — НИ одного доп. запроса

Теперь все 100 авторов приехали вместе со статьями в одном запросе. SQL под капотом — для чтения:

SELECT article.id, article.title, author.id, author.name
FROM article
INNER JOIN author ON article.author_id = author.id;

Можно идти и вглубь по цепочке FK через двойное подчёркивание: select_related("author__company") сделает два JOIN и подтянет ещё и компанию автора. Несколько связей сразу — select_related("author", "category").

prefetch_related: отдельный запрос для «многих»

С отношениями «ко многим» (ManyToManyField, обратный ForeignKey) JOIN не годится: он размножил бы родительские строки по числу детей. Здесь работает prefetch_related — он делает второй отдельный запрос за всеми связанными объектами и аккуратно раскладывает их по родителям в Python.

articles = Article.objects.prefetch_related("tags")  # 2 запроса всего

for a in articles:
    for t in a.tags.all():     # теги уже в памяти — доп. запросов НЕТ
        print(t.name)

Происходит ровно два запроса: первый — SELECT * FROM article, второй — SELECT * FROM tag JOIN ... WHERE article_id IN (1, 2, ..., 100) по всем id сразу. Затем Django сам сопоставляет теги их статьям. Два запроса вместо ста одного — независимо от числа строк.

То же самое для обратной связи. Если у автора много статей (author.articles — обратный FK):

authors = Author.objects.prefetch_related("articles")
for author in authors:
    print(author.name, author.articles.count())  # без N доп. запросов

Когда нужно не просто подтянуть связь, а ещё и отфильтровать или отсортировать её, используют Prefetch с вложенным QuerySet:

from django.db.models import Prefetch

Author.objects.prefetch_related(
    Prefetch("articles",
             queryset=Article.objects.filter(published=True).order_by("-created_at"))
)
# подтянет к каждому автору только опубликованные статьи, отсортированные по дате

Как выбрать: select_related или prefetch_related

СвязьМетодМеханика
ForeignKey (к одному)select_relatedодин SQL с JOIN
OneToOneFieldselect_relatedодин SQL с JOIN
ManyToManyFieldprefetch_relatedотдельный SQL + склейка в Python
обратный ForeignKeyprefetch_relatedотдельный SQL + склейка в Python

Их можно сочетать в одной цепочке: Article.objects.select_related("author").prefetch_related("tags") — автор приедет через JOIN, теги — вторым запросом.

Как поймать N+1 через подсчёт запросов

Глазами N+1 не виден — нужно мерить число запросов. В разработке помогает пакет django-debug-toolbar: он показывает на странице, сколько SQL выполнено и какие из них дублируются. В тестах есть встроенный assertNumQueries, который проваливает тест, если запросов стало больше ожидаемого:

from django.test import TestCase

class ArticleQueryTest(TestCase):
    def test_list_uses_two_queries(self):
        # ожидаем ровно 2 запроса: статьи + prefetch тегов
        with self.assertNumQueries(2):
            for a in Article.objects.prefetch_related("tags"):
                list(a.tags.all())

А вручную число выполненных запросов всегда лежит в connection.queries (при DEBUG=True):

from django.db import connection, reset_queries

reset_queries()
for a in Article.objects.select_related("author"):
    _ = a.author.name
print(len(connection.queries))   # должно быть 1, а не 1 + N

Как это работает под капотом

select_related переписывает один SQL, добавляя JOIN и колонки связанных таблиц; ORM создаёт связанные объекты прямо из этих колонок и кладёт их в кэш атрибута, поэтому повторный доступ к a.author бесплатен. prefetch_related работает иначе: он выполняет основной запрос, собирает все первичные ключи родителей, делает второй запрос с WHERE id IN (...), а затем в Python разбрасывает детей по родителям и кэширует их в менеджере связи. Поэтому prefetch — это всегда хотя бы +1 запрос (а не JOIN), зато он не раздувает выборку и одинаково хорошо работает с любым числом связанных строк. Важно: фильтровать prefetch нужно через объект Prefetch с готовым queryset — если вызвать a.tags.filter(...) внутри цикла, prefetch-кэш проигнорируется и вы снова получите N запросов.

Частые ошибки

  • Применять select_related к ManyToMany или обратному FK. Django выдаст ошибку или не сможет: для «многих» нужен prefetch_related.
  • Фильтровать связь внутри цикла после prefetch. a.tags.filter(...) или a.tags.count() с условием бьют мимо кэша и порождают новый запрос на каждой итерации; фильтр кладите в Prefetch(queryset=...).
  • Тянуть select_related всё подряд. Лишние JOIN с десятком таблиц раздувают строки и память; джойните только то, к чему реально обращаетесь.
  • Полагаться, что на локали «всё быстро». N+1 незаметен на десятке строк и убивает прод на тысячах; проверяйте число запросов в тестах через assertNumQueries.
  • Забывать про вложенные связи. Доступ к a.author.company в цикле — это тоже N+1 на уровень глубже; нужен select_related("author__company").

Итоги

  • N+1 — это 1 запрос за списком плюс по одному за связью каждого объекта; источник почти всех «медленных списков».
  • select_related решает проблему для ForeignKey/OneToOne одним SQL с JOIN.
  • prefetch_related решает её для M2M и обратных FK отдельным запросом со склейкой в Python (минимум +1 запрос вместо JOIN).
  • Фильтрацию и сортировку связанных объектов задают через объект Prefetch с вложенным queryset.
  • Ловить N+1 нужно подсчётом запросов: django-debug-toolbar, assertNumQueries в тестах, connection.queries вручную.
Проверьте себя
1. Почему цикл for a in Article.objects.all(): print(a.author.name) при ForeignKey на автора порождает проблему N+1?
AПотому что all() сам по себе делает N запросов
BПотому что в выборке статей есть только author_id, и обращение к a.author на каждой итерации делает отдельный SELECT за автором
CПотому что print() выполняет запрос
DПотому что ForeignKey всегда грузится отдельно и это нельзя изменить
2. Какой метод подходит для оптимизации доступа к ManyToManyField (например, тегам статьи)?
Aselect_related — он сделает JOIN
Bprefetch_related — он сделает отдельный запрос за всеми тегами и разложит их по статьям
Conly("tags") — он загрузит только теги
Ddefer("tags") — он отложит загрузку
3. Как надёжно убедиться в тесте, что код не страдает от N+1?
AЗамерить время выполнения секундомером
BОбернуть код в self.assertNumQueries(ожидаемое_число) — тест упадёт, если запросов стало больше
CПросто посмотреть на код глазами
DВключить DEBUG=False