Проблема N+1 в Django ORM | Курс Django ORM урок 4.3
Цель урока
Научиться диагностировать проблему 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 и замедлить простые запросы.
Практическое задание
-
Напишите код который намеренно создает N+1: загрузите все заказы и в цикле обращайтесь к
order.user.username. Посчитайте запросы черезconnection.queries. Исправьте черезselect_related. Сравните цифры. -
Загрузите все продукты и в цикле выводите их отзывы через
product.reviews.all(). Подсчитайте запросы. Исправьте черезprefetch_related. Сравните. -
Реализуйте загрузку OrderItem с тремя уровнями:
item → product → category. Сначала без оптимизации (считайте запросы), затем сselect_related("product__category"). Какая разница для 10 позиций? -
Запустите 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, механизм для связи одной модели с несколькими разными моделями.
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru