Урок по треку Python для веба: Django и FastAPI 8 мин

Django ORM: ленивые QuerySet, N+1, select_related и prefetch_related

Ленивые QuerySet, select_related и prefetch_related, F и Q, annotate, транзакции и блокировки.

ORM экономит время, но легко превращает одну страницу в 500 SQL-запросов. На собесе Django-разработчика почти всегда есть вопрос про N+1 и разницу select_related и prefetch_related. Senior-уровень добавляет транзакции, блокировки и гонки.

QuerySet ленивый

qs = Order.objects.filter(status='paid')   # запроса ещё нет
qs = qs.exclude(total=0).order_by('-created')
for order in qs:                           # запрос выполняется здесь
    ...

QuerySet выполняется при итерации, list(), len(), bool(), срезе с шагом, repr. Результат кешируется в самом объекте QuerySet: повторный обход того же объекта не делает запрос, а новый .filter() создаёт новый QuerySet и новый запрос.

  • exists() вместо if qs: (не грузит строки), count() вместо len(qs), если строки не нужны.
  • values() и values_list() возвращают словари и кортежи без создания моделей.
  • only() и defer() ограничивают колонки.
  • iterator() для огромных выборок без кеширования.

Проблема N+1

for order in Order.objects.all():          # 1 запрос
    print(order.customer.name)              # +1 запрос на каждый заказ

Каждое обращение к связанному объекту, которого нет в памяти, делает отдельный запрос. 100 заказов = 101 запрос.

select_related и prefetch_related

Order.objects.select_related('customer')         # JOIN, один запрос
Order.objects.prefetch_related('items')          # 2 запроса: заказы + все позиции
Order.objects.prefetch_related(
    Prefetch('items', queryset=Item.objects.select_related('product'))
)
  • select_related для ForeignKey и OneToOne («к одному»): делает SQL JOIN.
  • prefetch_related для «ко многим» (обратный FK, ManyToMany): отдельный запрос WHERE id IN (...) и склейка в Python.
  • Prefetch позволяет фильтровать и оптимизировать вложенную выборку.

Найти N+1 помогают django-debug-toolbar, assertNumQueries в тестах, логирование SQL, nplusone и аналоги.

F и Q

from django.db.models import F, Q

Product.objects.filter(stock__lt=F('reserved'))         # сравнение колонок
Product.objects.filter(pk=pk).update(stock=F('stock') - 1)  # атомарно в БД
Order.objects.filter(Q(status='new') | Q(priority__gte=5))

F ссылается на колонку и позволяет обновлять значения на стороне БД без гонки «прочитал, изменил, записал». Q собирает условия с |, &, ~.

Агрегация и аннотации

from django.db.models import Count, Sum

Customer.objects.annotate(orders_count=Count('orders')).filter(orders_count__gt=5)
Order.objects.aggregate(total=Sum('amount'))     # словарь

Осторожно с несколькими annotate по разным связям «ко многим»: JOIN размножает строки, и суммы задваиваются. Решение: подзапросы (Subquery, OuterRef) или Count(..., distinct=True).

Транзакции и блокировки

По умолчанию Django в режиме autocommit: каждый запрос сам по себе транзакция. Для атомарности нескольких операций:

from django.db import transaction

with transaction.atomic():
    account = Account.objects.select_for_update().get(pk=pk)
    account.balance -= amount
    account.save(update_fields=['balance'])
    transaction.on_commit(lambda: notify.delay(account.id))
  • atomic откатывает всё при исключении, вложенные atomic создают savepoint.
  • select_for_update() блокирует строки до конца транзакции: защита от гонок при списании.
  • on_commit выполняет побочные эффекты (письма, задачи Celery) только после успешного коммита.
  • ATOMIC_REQUESTS = True оборачивает каждый запрос в транзакцию: просто, но держит транзакцию всё время запроса.

Async ORM

Есть асинхронные методы (aget, acreate, acount, async for по QuerySet), но внутри запросы пока выполняются синхронно через поток. Транзакции в async-коде всё ещё требуют синхронной обёртки (sync_to_async).

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

  • N+1 в циклах, шаблонах и сериализаторах.
  • len(qs) или if qs там, где хватит count() и exists().
  • Обновление счётчиков через Python (obj.x += 1; obj.save()) вместо F.
  • save() без update_fields: перезапись всех полей, гонка с параллельными изменениями.
  • Задача в Celery, отправленная внутри транзакции до коммита: воркер не видит новые данные.

Что спросят на собесе

  • Что такое ленивость QuerySet, когда выполняется запрос.
  • Что такое N+1 и как его найти и исправить.
  • Разница select_related и prefetch_related.
  • Зачем F и Q, как сделать атомарное обновление счётчика.
  • Как работают transaction.atomic, select_for_update, on_commit.

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.