Django ORM

Проблема N+1 в Django ORM | Курс Django ORM урок 4.3

Проблема N+1 в Django ORM | Курс Django ORM урок 4.3
Mikhail
Автор
Mikhail
Опубликовано 16.03.2026
0,0
Views 2

Цель урока

Научиться диагностировать проблему N+1 с помощью Django Debug Toolbar и логирования SQL. Увидеть реальные цифры до и после оптимизации. Разобрать сценарии где N+1 возникает незаметно.

Необходимые знания


Что такое N+1

N+1 это паттерн когда вместо одного запроса выполняется 1 + N запросов: один для загрузки основного списка, и по одному дополнительному для каждого объекта в этом списке.

# 1 запрос для загрузки продуктов
products = Product.objects.filter(is_active=True)

# N запросов - по одному для каждого product.category
for product in products:
    print(product.category.name)

При 100 продуктах: 101 запрос. При 1000: 1001. Запросы к БД дороги не только по времени выполнения SQL, но и по round-trip времени (сеть, установка соединения). 1000 быстрых запросов могут быть медленнее одного "медленного".


Диагностика с помощью логирования SQL

В уроке 0 настроено логирование SQL в консоль. Самый быстрый способ обнаружить N+1, посмотреть на вывод в терминале:

docker compose logs web -f

При запросе страницы со списком продуктов увидите:

(0.001) SELECT * FROM shop_product WHERE is_active = true ORDER BY name; args=()
(0.001) SELECT * FROM shop_category WHERE id = 3; args=(3,)
(0.001) SELECT * FROM shop_category WHERE id = 3; args=(3,)
(0.001) SELECT * FROM shop_category WHERE id = 1; args=(1,)
(0.001) SELECT * FROM shop_category WHERE id = 2; args=(2,)
...

Повторяющиеся запросы к одной таблице с разными id, явный признак N+1.


Диагностика через Django Debug Toolbar

Debug Toolbar показывает все запросы для конкретного запроса HTTP в браузере. Подключите его в urls.py (как в уроке 0) и откройте любую страницу Django.

Панель "SQL" покажет:

  • Количество запросов
  • Время каждого
  • Дублирующиеся запросы (выделены цветом)
  • Стек вызовов для каждого запроса (откуда он пришел)

Критичные метрики:

  • Больше 10 запросов на страницу, стоит разобраться
  • Дублирующиеся запросы, почти всегда N+1
  • Суммарное время SQL > 100ms на простой странице, что-то не так

Диагностика через connection.queries

Для диагностики из кода в shell или тестах:

from django.db import connection, reset_queries
from django.conf import settings

settings.DEBUG = True  # connection.queries работает только в DEBUG режиме

reset_queries()

# Код который проверяем
products = list(Product.objects.filter(is_active=True))
for p in products:
    _ = p.category.name

print(f"Количество запросов: {len(connection.queries)}")
for q in connection.queries:
    print(f"  {q['time']}s: {q['sql'][:80]}")

Полезная утилита для тестов:

def count_queries(func, *args, **kwargs):
    reset_queries()
    result = func(*args, **kwargs)
    return result, len(connection.queries)

Предупреждение: connection.queries в продакшне

connection.queries работает только при DEBUG = True. В этом режиме Django хранит в памяти все запросы SQL за время жизни процесса, это утечка памяти при долго живущих воркерах (gunicorn, uvicorn). Никогда не включай DEBUG = True в продакшне. Для продакшн профилирования используйте pg_stat_statements (урок 5.2) или инструменты APM (Sentry, Datadog).


Сценарии N+1

Сценарий 1: FK в шаблоне или сериализаторе

# view
def product_list(request):
    products = Product.objects.filter(is_active=True)  # нет select_related
    return render(request, "products.html", {"products": products})


<!-- шаблон -->
{% for product in products %}
    {{ product.name }} - {{ product.category.name }}  <!-- N запросов -->
{% endfor %}

Решение:

products = Product.objects.select_related("category").filter(is_active=True)

Сценарий 2: Обратная FK в цикле

orders = Order.objects.filter(status="processing")

for order in orders:
    items = order.items.all()  # запрос для каждого заказа
    total = sum(item.price * item.quantity for item in items)

Решение:

orders = Order.objects.prefetch_related("items").filter(status="processing")

Сценарий 3: Обратная M2M / reverse FK в цикле

products = Product.objects.filter(is_active=True)

for product in products:
    reviews = list(product.reviews.all())  # N запросов, по одному на каждый продукт
    avg_rating = sum(r.rating for r in reviews) / len(reviews) if reviews else 0

Решение:

products = Product.objects.prefetch_related("reviews").filter(is_active=True)

Сценарий 4: Цепочка FK

# Нужно: item → product → category
items = OrderItem.objects.filter(order__user_id=1)

for item in items:
    print(item.product.category.name)  # 2 запроса на каждый item!


# SQL лог:
SELECT * FROM shop_orderitem WHERE order_id IN (...);
SELECT * FROM shop_product WHERE id = 2;   -- для item 1
SELECT * FROM shop_category WHERE id = 3;  -- для item 1
SELECT * FROM shop_product WHERE id = 5;   -- для item 2
SELECT * FROM shop_category WHERE id = 1;  -- для item 2
...

Решение:

items = OrderItem.objects.select_related("product__category").filter(order__user_id=1)


SELECT shop_orderitem.*, shop_product.*, shop_category.*
FROM shop_orderitem
INNER JOIN shop_product ON shop_orderitem.product_id = shop_product.id
INNER JOIN shop_category ON shop_product.category_id = shop_category.id
INNER JOIN shop_order ON shop_orderitem.order_id = shop_order.id
WHERE shop_order.user_id = 1;

Сценарий 5: N+1 в сериализаторах (DRF)

В Django REST Framework N+1 особенно коварна, запросы не видны в шаблонах, только в логах.

class OrderSerializer(serializers.ModelSerializer):
    items = OrderItemSerializer(many=True)  # N запросов для items

    class Meta:
        model = Order
        fields = ["id", "status", "items"]

Решение в get_queryset() во ViewSet:

def get_queryset(self):
    return Order.objects.prefetch_related(
        Prefetch("items", queryset=OrderItem.objects.select_related("product"))
    )

Измерение до и после

Пример реальных цифр для 15 продуктов, каждый с категорией:

До оптимизации:

Запросов: 16
Время: 0.048s (16 × ~3ms round-trip)

После select_related:

Запросов: 1
Время: 0.004s
Ускорение: 12x

Для 100 продуктов разница будет ~100x.


Когда N+1 допустима

N+1 не всегда проблема. Это нормально когда:

  • Список объектов очень маленький (< 5) и стабильный
  • Связанные объекты загружаются условно (для одного из ста объектов)
  • Это одноразовый скрипт

Не стоит добавлять select_related / prefetch_related везде превентивно, это может добавить ненужные JOIN и замедлить простые запросы.


Практическое задание

  1. Напишите код который намеренно создает N+1: загрузите все заказы и в цикле обращайтесь к order.user.username. Посчитайте запросы через connection.queries. Исправьте через select_related. Сравните цифры.

  2. Загрузите все продукты и в цикле выводите их отзывы через product.reviews.all(). Подсчитайте запросы. Исправьте через prefetch_related. Сравните.

  3. Реализуйте загрузку OrderItem с тремя уровнями: item → product → category. Сначала без оптимизации (считайте запросы), затем с select_related("product__category"). Какая разница для 10 позиций?

  4. Запустите Debug Toolbar и найдите N+1 на странице со списком (если у вас есть вьюха). Либо создайте простую тестовую вьюху только для этого урока.


Возможные ошибки

Добавить select_related/prefetch_related, но не использовать данные

# JOIN выполняется, но product.category.name не вызывается
products = Product.objects.select_related("category")
for p in products:
    print(p.name)  # category не нужна, select_related лишний

prefetch_related не помогает при filter() после загрузки

orders = list(Order.objects.prefetch_related("items"))

for order in orders:
    # Сбрасывает кеш prefetch, делает новый запрос
    expensive = order.items.filter(price__gt=500)

Вложенная N+1

# Двойная N+1: N запросов для категорий, M запросов для продуктов каждой
categories = Category.objects.all()
for category in categories:
    for product in category.products.all():  # N запросов
        print(product.reviews.count())        # N*M запросов!

Исправление:

categories = Category.objects.prefetch_related(
    Prefetch("products", queryset=Product.objects.prefetch_related("reviews"))
)

Связь со следующим уроком

В уроке 4.4 разберем модели through для M2M с дополнительными данными и Generic relations через ContentType, механизм для связи одной модели с несколькими разными моделями.


<< Урок 4.2

Урок 4.4 >>


Подписывайтесь на мой Telegram канал

Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы