Шаг 06. ORM правильно: N+1 закрыт
⚡ Кратко: что делаем на этом шаге
Цель: Отработать QuerySet API в Django shell и написать view с оптимизированными запросами. Закрыть N+1 через select_related/prefetch_related.
- Q-объекты:
Q(status="a") | Q(status="r") - F-объекты: атомарное обновление на уровне БД без загрузки в Python
- annotate/aggregate: подсчёт, средние значения без дополнительных запросов
- select_related: JOIN для FK/O2O — устраняет N+1 при доступе к book.publisher
- prefetch_related: отдельный SELECT + Python-join для M2M/обратных FK — устраняет N+1 при book.authors.all()
🎯 Цель этапа
На этом шаге мы закрываем callout-verify из урока 22 — самое распространённое узкое место Django-приложений: N+1 проблема. Разбираем весь QuerySet API на примерах нашего каталога, пишем view-функцию с правильными запросами.
for book in Book.objects.all(): print(book.publisher.name)
— Django выполняет 1 запрос за книги + 100 запросов за каждое издательство = 101 запрос.
С select_related("publisher") — это 1 запрос с JOIN. Разница на 10 000 книгах
— между 10 001 запросом и 1 запросом.
После этого шага у нас будет
- Опыт работы с filter/exclude/Q/F в Django shell
- Понимание разницы select_related vs prefetch_related
- View-функция со списком книг без N+1
- Аннотации (количество экземпляров, доступных) в запросах
📄 Затрагиваемые файлы
| Файл | Действие | Описание |
|---|---|---|
catalog/views.py | Заполнить | View-функции со списком книг и деталями |
catalog/urls.py | Создать | URL-маршруты приложения |
config/urls.py | Изменить | Подключить catalog.urls |
catalog/templates/catalog/book_list.html | Создать | Простой шаблон списка книг |
🔨 Шаги
1. Django shell — изучаем QuerySet API
python manage.py shell
from catalog.models import Author, Book, BookCopy, Genre
from django.db.models import Q, F, Count, Avg
# ── filter и exclude ──────────────────────────────────────────────────────────
# Книги, изданные после 2000 года
books_2000 = Book.objects.filter(publish_year__gte=2000)
print(books_2000.count())
# Книги НЕ относящиеся к жанру "Классика"
non_classic = Book.objects.exclude(genres__name="Классика")
# Цепочка фильтров
available_copies = BookCopy.objects.filter(
status=BookCopy.Status.AVAILABLE
).exclude(book__publish_year__lt=1900)
# ── Q-объекты: OR, NOT ────────────────────────────────────────────────────────
# Экземпляры, которые доступны ИЛИ зарезервированы
ready_copies = BookCopy.objects.filter(
Q(status=BookCopy.Status.AVAILABLE) | Q(status=BookCopy.Status.RESERVED)
)
# Книги Толстого ИЛИ изданные после 2000
tolstoy_or_recent = Book.objects.filter(
Q(authors__last_name="Толстой") | Q(publish_year__gte=2000)
).distinct() # distinct() нужен при join-ах, чтобы избежать дублей
# ── F-объекты: поля в выражениях ──────────────────────────────────────────────
# F позволяет использовать значение поля в фильтре (сравнение полей друг с другом)
# Например: BookCopy у которых due_back совпадает с полем другой модели
# (искусственный пример, но паттерн важен)
# Атомарное обновление без загрузки в Python (важно!)
# Book.objects.filter(...).update(publish_year=F("publish_year") + 1)
# Это ОДИН SQL-запрос UPDATE, без race condition
# ── annotate и aggregate ─────────────────────────────────────────────────────
# Количество экземпляров для каждой книги
books_with_copies = Book.objects.annotate(
total_copies=Count("copies"),
available_copies=Count(
"copies",
filter=Q(copies__status=BookCopy.Status.AVAILABLE)
)
)
for book in books_with_copies:
print(f"{book.title}: {book.available_copies}/{book.total_copies} доступно")
# Агрегация по всему queryset
stats = BookCopy.objects.aggregate(
total=Count("id"),
available=Count("id", filter=Q(status=BookCopy.Status.AVAILABLE)),
)
print(stats) # {"total": 15, "available": 8}
# Среднее количество книг на автора
avg_books = Author.objects.annotate(
books_count=Count("books")
).aggregate(avg=Avg("books_count"))
print(avg_books)
2. N+1: проблема и решение
# ── N+1 ПРОБЛЕМА (НЕ делайте так) ───────────────────────────────────────────
# Этот код выполняет 1 + N + M запросов:
# 1 — SELECT * FROM catalog_book
# N — SELECT * FROM catalog_publisher WHERE id=X (для каждой книги)
# M — SELECT ... FROM catalog_author JOIN ... WHERE book_id=Y (для каждой книги)
books = Book.objects.all()
for book in books:
# Каждое обращение — отдельный SQL!
print(book.publisher.name) # N лишних запросов
print([str(a) for a in book.authors.all()]) # M лишних запросов
# ── select_related — решение для FK/O2O (JOIN) ───────────────────────────────
# select_related добавляет JOIN к исходному SELECT.
# Один SQL вместо 1+N.
# Используйте для ForeignKey и OneToOneField.
books = Book.objects.select_related("publisher").all()
for book in books:
print(book.publisher.name) # Нет доп. запросов! Данные уже загружены.
# ── prefetch_related — решение для M2M и обратных FK ─────────────────────────
# prefetch_related делает ОТДЕЛЬНЫЙ SELECT для связанной таблицы
# и объединяет данные в Python.
# Используйте для ManyToMany и обратных ForeignKey (copies, и т.п.)
books = Book.objects.prefetch_related("authors", "genres", "copies").all()
for book in books:
print([str(a) for a in book.authors.all()]) # Python-кэш, нет SQL!
print([str(g) for g in book.genres.all()]) # Python-кэш, нет SQL!
# ── Комбинация: и FK, и M2M ──────────────────────────────────────────────────
# Правило: select_related для FK/O2O, prefetch_related для M2M и обратных FK
books = Book.objects.select_related(
"publisher" # FK → JOIN
).prefetch_related(
"authors", # M2M → отдельный SELECT
"genres", # M2M → отдельный SELECT
"copies", # обратный FK → отдельный SELECT
).annotate(
total_copies=Count("copies"),
available_copies=Count(
"copies",
filter=Q(copies__status=BookCopy.Status.AVAILABLE)
)
).order_by("title")
# Итого: 4 запроса вместо 1 + N*3
# При 1000 книг разница: 3001 запрос → 4 запроса
select_related— ForeignKey, OneToOneField (JOIN, один SQL)prefetch_related— ManyToManyField, обратные FK (book.copies.all()) (отдельный SELECT + Python-merge)- Вложенные FK:
select_related("publisher__country")— двойной JOIN - Prefetch с фильтром:
Prefetch("copies", queryset=BookCopy.objects.filter(status="a"))
3. Пишем view с правильным queryset
# catalog/views.py
from django.shortcuts import render, get_object_or_404
from django.db.models import Count, Q
from .models import Book, BookCopy
def book_list(request):
"""
Список книг с правильным queryset — без N+1.
select_related для publisher (FK).
prefetch_related для authors и genres (M2M).
annotate для подсчёта экземпляров (без дополнительных запросов в шаблоне).
"""
books = (
Book.objects
.select_related("publisher")
.prefetch_related("authors", "genres")
.annotate(
total_copies=Count("copies"),
available_copies=Count(
"copies",
filter=Q(copies__status=BookCopy.Status.AVAILABLE)
),
)
.order_by("title")
)
return render(request, "catalog/book_list.html", {"books": books})
def book_detail(request, pk: int):
"""
Детальная страница книги с экземплярами.
prefetch_related("copies") — загружаем все экземпляры одним запросом.
"""
book = get_object_or_404(
Book.objects.select_related("publisher").prefetch_related(
"authors", "genres", "copies"
),
pk=pk,
)
return render(request, "catalog/book_detail.html", {"book": book})
4. Подключаем URL-маршруты
# catalog/urls.py
from django.urls import path
from . import views
app_name = "catalog"
urlpatterns = [
path("", views.book_list, name="book-list"),
path("book//", views.book_detail, name="book-detail"),
]
# config/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("catalog/", include("catalog.urls")), # ← добавляем
]
5. Создаём простой шаблон
mkdir -p catalog\templates\catalog
<!-- catalog/templates/catalog/book_list.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Каталог книг</title>
</head>
<body>
<h1>Каталог книг ({{ books.count }} записей)</h1>
<ul>
{% for book in books %}
<li>
<strong>{{ book.title }}</strong>
— {{ book.publisher.name|default:"Издательство не указано" }}
| Авторы: {{ book.authors.all|join:", " }}
| Доступно: {{ book.available_copies }}/{{ book.total_copies }}
</li>
{% empty %}
<li>Книг в каталоге нет.</li>
{% endfor %}
</ul>
</body>
</html>
book.publisher.name, book.authors.all,
book.available_copies в шаблоне НЕ делают SQL-запросов,
потому что данные уже загружены через select_related,
prefetch_related и annotate в view.
Это и есть правильная работа с Django ORM.
🧠 Объяснение логики
Как Django лениво выполняет запросы
QuerySet в Django — ленивый: Book.objects.filter(...) не выполняет SQL.
Запрос происходит только при итерации (for book in books),
слайсинге (books[0:10]), вызове list(), len(),
bool() или repr().
Это позволяет строить сложные запросы цепочкой методов без промежуточных SQL.
Когда prefetch_related эффективнее select_related
select_related использует SQL JOIN — один большой запрос.
При M2M или обратных FK это приводит к дублированию строк (cartesian product).
prefetch_related делает второй SELECT и объединяет в Python — нет дублей,
нет разрастания результата. При 100 книгах и 5 авторах у каждой:
JOIN вернёт 500 строк (100×5), prefetch — 100 + 500 = два компактных результата.
book.copies.filter(status="a") в шаблоне после prefetch_related("copies")
СБРАСЫВАЕТ кэш и делает новый SQL-запрос!
Решение: Prefetch("copies", queryset=BookCopy.objects.filter(status="a"))
в prefetch_related() на уровне view.
✅ Проверка
1. Запускаем сервер и проверяем страницу
python manage.py runserver
http://127.0.0.1:8000/catalog/
2. Проверяем количество SQL-запросов (опционально)
from django.test.utils import override_settings
from django.db import reset_queries, connection
# Включаем логирование запросов
import django.db
django.db.reset_queries()
from catalog.models import Book, BookCopy
from django.db.models import Count, Q
books = list(
Book.objects.select_related("publisher")
.prefetch_related("authors", "genres", "copies")
.annotate(total_copies=Count("copies"))
)
print(f"Запросов: {len(django.db.connection.queries)}")
# Ожидаем: 4 (books + publishers JOIN, authors, genres, copies)
Диагностика
- TemplateDoesNotExist: catalog/book_list.html — убедитесь, что папка
catalog/templates/catalog/создана и в settings.pyAPP_DIRS: True - NoReverseMatch — проверьте
app_name = "catalog"в catalog/urls.py и include в config/urls.py - OperationalError: no such table — не применена миграция:
python manage.py migrate
➡️ Что дальше
На последнем шаге мы завершаем проект: создаём фикстуры с тестовыми данными, пишем management-команду для импорта книг, проходим финальный чеклист и намечаем пути развития проекта.
- Готово: QuerySet API, N+1 устранён, view с правильными запросами
- Далее (шаг 07):
dumpdata/loaddata, management-команды, финальный чеклист