Проблема 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 |
OneToOneField | select_related | один SQL с JOIN |
ManyToManyField | prefetch_related | отдельный SQL + склейка в Python |
обратный ForeignKey | prefetch_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вручную.