N+1 проблема, explain, query profiling, bulk операции, iterator
Оптимизация ORM критична для production-приложений. Этот туториал охватывает выявление и устранение узких мест в работе с базой данных.
💡 Правило: Профилируйте запросы перед оптимизацией. Не оптимизируйте то, что не измеряли.
Установка и настройка:
pip install django-debug-toolbar# settings.py
INSTALLED_APPS = [
# ...
'debug_toolbar',
]
MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware', # Как можно выше
# ...
]
INTERNAL_IPS = ['127.0.0.1']
# urls.py
from django.conf import settings
from django.conf.urls import include
if settings.DEBUG:
import debug_toolbar
urlpatterns = [
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatternsЧто показывает:
# settings.py
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.db.backends': {
'handlers': ['console'],
'level': 'DEBUG',
},
},
}Вывод:
(0.001) SELECT * FROM blog_post WHERE is_published = TRUE; args=()
(0.002) SELECT * FROM auth_user WHERE id = 5; args=(5,)
from django.db import connection
# В конце запроса
print(f"Query count: {len(connection.queries)}")
for query in connection.queries:
print(f"Time: {query['time']}ms, SQL: {query['sql']}")
# Сбросить логи
connection.queries_log.clear()# ❌ N+1 проблема
posts = Post.objects.all()
for post in posts:
print(post.author.name) # N запросов к author
for tag in post.tags.all(): # N запросов к tags
print(tag.name)Симптомы:
# ✅ select_related для ForeignKey/OneToOne
posts = Post.objects.select_related('author')
# ✅ prefetch_related для ManyToMany/reverse FK
posts = Post.objects.prefetch_related('tags')
# ✅ Комбинирование
posts = Post.objects.select_related('author').prefetch_related('tags')# Глубокая связь
Post.objects.select_related('author__profile')
# Несколько связей
Post.objects.select_related('author', 'category').prefetch_related('tags', 'comments')
# prefetch с фильтрацией
from django.db.models import Prefetch
Post.objects.prefetch_related(
Prefetch(
'comments',
queryset=Comment.objects.filter(is_approved=True).select_related('author'),
to_attr='approved_comments'
)
)# Вывод плана выполнения
queryset = Post.objects.filter(is_published=True, views__gt=100)
print(queryset.explain())Вывод PostgreSQL:
Seq Scan on blog_post (cost=0.00..35.50 rows=10 width=100)
Filter: ((is_published = TRUE) AND (views > 100))
# PostgreSQL специфичные опции
queryset.explain(
analyze=True, # Выполнить запрос, показать реальное время
buffers=True, # Показать использование буферов
verbose=True, # Детальная информация
timing=True, # Показать время выполнения
)Вывод с ANALYZE:
Index Scan using blog_post_is_published_idx on blog_post
(cost=0.29..8.31 rows=1 width=100) (actual time=0.050..0.052 rows=0 loops=1)
Index Cond: (is_published = TRUE)
Filter: (views > 100)
Planning Time: 0.200 ms
Execution Time: 0.100 ms
| Термин | Описание |
|---|---|
| Seq Scan | Полное сканирование таблицы (медленно для больших таблиц) |
| Index Scan | Сканирование индекса (быстро) |
| Index Only Scan | Только индекс, без доступа к таблице (очень быстро) |
| Nested Loop | Вложенные циклы (может быть медленно для больших данных) |
| Hash Join | Хэш соединение (эффективно для больших таблиц) |
| cost=0.00..35.50 | Оценка стоимости (первое — запуск, второе — полное выполнение) |
| rows=10 | Оценка количества строк |
| actual time=0.050..0.052 | Реальное время в мс |
# ❌ Медленно: N INSERT запросов
posts = []
for i in range(1000):
post = Post(title=f'Post {i}', author=author)
post.save() # INSERT каждый раз
# ✅ Быстро: 1 INSERT запрос
posts = [Post(title=f'Post {i}', author=author) for i in range(1000)]
Post.objects.bulk_create(posts)Ограничения bulk_create():
save() на моделяхpre_save/post_savepk не заполняется в SQLiteManyToMany связямиauto_now и auto_now_addОпции:
Post.objects.bulk_create(
posts,
batch_size=100, # Разбить на пакеты по 100
ignore_conflicts=True, # Игнорировать конфликты уникальности
update_conflicts=False, # Или обновлять при конфликте (PostgreSQL)
update_fields=['title'], # Поля для обновления при конфликте
)# ❌ Медленно: N UPDATE запросов
for post in posts:
post.views += 1
post.save()
# ✅ Быстро: 1 UPDATE запрос
for post in posts:
post.views += 1
Post.objects.bulk_update(posts, ['views'])Опции:
Post.objects.bulk_update(
posts,
['views', 'title'], # Поля для обновления
batch_size=100, # Пакеты по 100
)# Разбиение на пакеты для избежания переполнения памяти
objects = [Model(field=value) for value in range(100000)]
# Пакеты по 1000 объектов
for i in range(0, len(objects), 1000):
batch = objects[i:i+1000]
Model.objects.bulk_create(batch)# ❌ Загружает все объекты в память
for post in Post.objects.all():
process(post) # 100,000 объектов в памяти
# ✅ Загружает порциями по 2000
for post in Post.objects.iterator(chunk_size=2000):
process(post) # Только 2000 объектов в памяти# Маленький chunk_size — больше запросов, меньше памяти
for post in Post.objects.iterator(chunk_size=100):
process(post)
# Большой chunk_size — меньше запросов, больше памяти
for post in Post.objects.iterator(chunk_size=10000):
process(post)
# По умолчанию: 2000
for post in Post.objects.iterator():
process(post)# ✅ Можно комбинировать
for post in Post.objects.select_related('author').iterator():
print(post.author.name) # select_related работает
# ⚠️ prefetch_related не работает с iterator()
# prefetch игнорируется при использовании iterator()class Post(models.Model):
title = models.CharField(max_length=200, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
indexes = [
models.Index(fields=['-created_at']),
models.Index(fields=['is_published', '-created_at']),
models.Index(fields=['author', '-created_at']),
]
constraints = [
models.UniqueConstraint(fields=['slug'], name='unique_slug'),
]| Сценарий | Индекс Помогает? |
|---|---|
filter(field=value) | ✅ Да |
order_by('field') | ✅ Да |
filter(field__gt=value) | ✅ Да |
filter(field__icontains='text') | ❌ Нет (нужен GIN индекс) |
filter(field__startswith='text') | ✅ Да (B-tree) |
filter(field__endswith='text') | ❌ Нет |
| Малые таблицы (< 1000 строк) | ❌ Нет (Seq Scan быстрее) |
| Поля с низкой селективностью (boolean) | ❌ Нет |
# explain() покажет Index Scan если индекс используется
Post.objects.filter(created_at__gte='2026-01-01').explain()
# Если Seq Scan — индекс не используется
# Проверьте статистику: ANALYZE blog_post;# blog/views.py
def post_list(request):
posts = Post.objects.filter(is_published=True)
data = []
for post in posts:
# N+1: запрос к author для каждого поста
author_name = post.author.name
# N+1: запрос к tags для каждого поста
tags = list(post.tags.all())
# N+1: запрос к comments для каждого поста
comment_count = post.comments.count()
data.append({
'title': post.title,
'author': author_name,
'tags': [tag.name for tag in tags],
'comments': comment_count,
})
return render(request, 'posts.html', {'data': data})Проблемы:
author (select_related)tags (prefetch_related)comments.count() (annotate)from django.db.models import Count
def post_list(request):
posts = (
Post.objects
.filter(is_published=True)
.select_related('author') # Один JOIN для author
.prefetch_related('tags') # Два запроса для tags
.annotate(comment_count=Count('comments')) # Одно поле с COUNT
)
data = []
for post in posts:
data.append({
'title': post.title,
'author': post.author.name, # Из JOIN
'tags': [tag.name for tag in post.tags.all()], # Из prefetch
'comments': post.comment_count, # Из annotate
})
return render(request, 'posts.html', {'data': data})Результат:
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.