Каким образом оптимизируешь запросы к связанным моделям?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Оптимизация запросов к связанным моделям (N+1 Problem)
Проблема N+1 Query
N+1 Problem — это когда для получения основных данных делается 1 запрос, а потом для каждого результата делается ещё 1 запрос. Результат: 1 + N запросов вместо 1.
# ❌ Плохо: N+1 запросы
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
class Book(models.Model):
title = models.CharField(max_length=100)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
# В view
books = Book.objects.all() # 1 запрос
for book in books:
print(book.author.name) # N запросов! (один на каждую книгу)
# SELECT * FROM authors WHERE id = ...
Решение 1: select_related() — для ForeignKey
select_related() использует SQL JOIN для получения связанных данных в одном запросе.
# ✅ Хорошо: 1 запрос с JOIN
books = Book.objects.select_related('author') # JOIN с authors
for book in books:
print(book.author.name) # Нет дополнительных запросов!
# SQL, который выполняется:
# SELECT books.*, authors.* FROM books
# JOIN authors ON books.author_id = authors.id
# Глубокая связь
comments = Comment.objects.select_related(
'post', # ForeignKey
'post__author' # ForeignKey от ForeignKey
)
for comment in comments:
print(comment.post.author.name) # Все в памяти
# Несколько связей
posts = Post.objects.select_related('author', 'category')
Когда использовать: ForeignKey и OneToOneField (один-к-одному).
Решение 2: prefetch_related() — для ManyToMany и Reverse FK
prefetch_related() делает отдельный запрос для каждого related set, но минимизирует их количество.
class Author(models.Model):
name = models.CharField(max_length=100)
class Book(models.Model):
title = models.CharField(max_length=100)
authors = models.ManyToManyField(Author) # Many-to-many
# ❌ Плохо: N+1
books = Book.objects.all() # 1 запрос
for book in books:
for author in book.authors.all(): # N запросов!
print(author.name)
# ✅ Хорошо: 2 запроса
books = Book.objects.prefetch_related('authors')
# Query 1: SELECT * FROM books
# Query 2: SELECT * FROM authors WHERE id IN (...)
for book in books:
for author in book.authors.all(): # Из кеша!
print(author.name)
# Обратные связи (Reverse FK)
authors = Author.objects.prefetch_related('book_set')
for author in authors:
for book in author.book_set.all(): # Из кеша
print(book.title)
Когда использовать: ManyToMany и обратные ForeignKey.
Решение 3: Prefetch Objects — более сложные сценарии
from django.db.models import Prefetch
# Фильтр при prefetch
recent_comments = Comment.objects.filter(
created_at__gte=timezone.now() - timedelta(days=7)
)
posts = Post.objects.prefetch_related(
Prefetch('comments', queryset=recent_comments)
)
for post in posts:
for comment in post.comments.all(): # Только последние комментарии
print(comment.text)
Решение 4: only() и defer() — выборочное получение полей
only() — получить только нужные поля.
# ❌ Плохо: загружаем всё
books = Book.objects.all()
# ✅ Хорошо: только нужные поля
books = Book.objects.only('id', 'title') # Исключить heavy поля
# SQL:
# SELECT id, title FROM books
# Работает с select_related
books = Book.objects.select_related('author').only(
'id', 'title', 'author__name' # Только эти поля из author
)
defer() — исключить тяжёлые поля.
# Исключить большие текстовые поля
posts = Post.objects.defer('content', 'html_content')
for post in posts:
print(post.title) # OK
print(post.content) # Дополнительный запрос!
Решение 5: Agregation и Annotation
from django.db.models import Count, Avg, Sum
# ❌ Плохо: N запросов для подсчёта
authors = Author.objects.all()
for author in authors:
book_count = author.book_set.count() # N запросов
print(f'{author.name}: {book_count}')
# ✅ Хорошо: 1 запрос с aggregation
authors = Author.objects.annotate(
book_count=Count('book')
)
for author in authors:
print(f'{author.name}: {author.book_count}') # Уже вычислено
# SQL:
# SELECT authors.*, COUNT(books.id) as book_count
# FROM authors
# LEFT JOIN books ON authors.id = books.author_id
# GROUP BY authors.id
# Более сложный пример
authors = Author.objects.annotate(
book_count=Count('book'),
avg_rating=Avg('book__rating'),
total_sales=Sum('book__sales')
).filter(
book_count__gte=5 # Авторы с 5+ книгами
).order_by('-total_sales')
Решение 6: Raw SQL для очень сложных запросов
# Когда ORM недостаточно
from django.db import connection
def get_author_stats():
with connection.cursor() as cursor:
cursor.execute("""
SELECT
a.id,
a.name,
COUNT(b.id) as book_count,
AVG(b.rating) as avg_rating
FROM authors a
LEFT JOIN books b ON a.id = b.author_id
WHERE b.published_year >= %s
GROUP BY a.id, a.name
ORDER BY avg_rating DESC
LIMIT 10
""", [2020])
columns = [col[0] for col in cursor.description]
return [
dict(zip(columns, row))
for row in cursor.fetchall()
]
Решение 7: Кеширование результатов
from django.core.cache import cache
from django.views.decorators.cache import cache_page
# На уровне вью
@cache_page(60) # Кешировать на 60 сек
def book_list(request):
books = Book.objects.select_related('author')
return render(request, 'books.html', {'books': books})
# Ручное кеширование
def get_author_with_books(author_id):
cache_key = f'author:{author_id}:with_books'
data = cache.get(cache_key)
if data is None:
author = Author.objects.get(id=author_id)
author.books = author.book_set.all()
data = {'author': author, 'books': author.books}
cache.set(cache_key, data, 3600) # Кешировать на 1 час
return data
Решение 8: Database Query Logs и Profiling
# Видеть все запросы (DEBUG mode)
from django.conf import settings
from django.test.utils import override_settings
if settings.DEBUG:
from django.db import connection
from django.test import TestCase
class OptimizationTest(TestCase):
def test_book_list_queries(self):
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as context:
books = Book.objects.select_related('author')
list(books) # Выполнить запрос
print(f'Количество запросов: {len(context)}')
assert len(context) == 1, f'Expected 1 query, got {len(context)}'
# Инструмент django-debug-toolbar
# pip install django-debug-toolbar
# Добавить в INSTALLED_APPS и MIDDLEWARE
# Показывает все запросы, время выполнения, profile
Решение 9: SQLAlchemy (для non-Django проектов)
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, joinedload, selectinload
from sqlalchemy.orm import Session
Base = declarative_base()
class Author(Base):
__tablename__ = 'authors'
id = Column(Integer, primary_key=True)
name = Column(String)
books = relationship('Book', back_populates='author')
class Book(Base):
__tablename__ = 'books'
id = Column(Integer, primary_key=True)
title = Column(String)
author_id = Column(Integer, ForeignKey('authors.id'))
author = relationship('Author', back_populates='books')
engine = create_engine('sqlite:///library.db')
session = Session(engine)
# ❌ Плохо: N+1
books = session.query(Book).all()
for book in books:
print(book.author.name) # N запросов
# ✅ Хорошо: joinedload (JOIN)
books = session.query(Book).options(joinedload(Book.author)).all()
for book in books:
print(book.author.name) # 1 запрос
# selectinload для ManyToMany
authors = session.query(Author).options(
selectinload(Author.books)
).all()
for author in authors:
for book in author.books:
print(book.title) # 2 запроса: 1 для авторов, 1 для книг
Практический чеклист оптимизации
-
Выявить N+1:
- Запустить с
django-debug-toolbarили логами - Посчитать количество запросов
- Запустить с
-
Применить select_related:
- Для ForeignKey и OneToOneField
- Когда всегда нужны связанные данные
-
Применить prefetch_related:
- Для ManyToMany
- Для обратных ForeignKey
-
Использовать only/defer:
- Исключить тяжёлые поля (text, JSON)
- Когда нужны только определённые колонки
-
Agregation вместо Count:
- Использовать Count(), Sum(), Avg() в базе
- Не загружать в Python для подсчёта
-
Кеширование:
- Часто читаемые данные
- Дорогие вычисления
-
Индексы:
- Индексировать внешние ключи
- Индексировать часто фильтруемые поля
-
Мониторинг:
- Постоянно профилировать
- Настраивать по результатам
Реальный пример оптимизации
# ❌ До оптимизации: 100+ запросов
def get_posts_feed():
posts = Post.objects.all()[:20]
return posts
# ✅ После оптимизации: 4 запроса
def get_posts_feed():
posts = Post.objects.select_related(
'author',
'category'
).prefetch_related(
'comments__author',
'tags'
).annotate(
comment_count=Count('comments'),
like_count=Count('likes')
).only(
'id', 'title', 'author_id', 'category_id',
'author__name', 'author__avatar_url',
'category__name'
)[:20]
return posts
Вывод: Правильная оптимизация запросов может снизить их количество с сотен до единиц, что критично для performance.