EXPLAIN и анализ запросов в Django ORM | Курс Django ORM урок 5.2
Цель урока
Научиться читать план выполнения запроса PostgreSQL. Понять что такое Seq Scan, Index Scan, Hash Join, Nested Loop. Использовать EXPLAIN ANALYZE для диагностики медленных запросов в Django ORM.
Необходимые знания
- Урок 5.1: индексы
- Базовое понимание SQL запросов
EXPLAIN и EXPLAIN ANALYZE
EXPLAIN показывает план: что PostgreSQL собирается делать, с оценками стоимости и количества строк. Запрос при этом не выполняется.
EXPLAIN ANALYZE выполняет запрос и показывает и план, и реальные цифры.
docker compose exec db psql -U postgres -d ormcourse
EXPLAIN SELECT * FROM shop_product WHERE slug = 'iphone-15';
EXPLAIN ANALYZE SELECT * FROM shop_product WHERE is_active = true ORDER BY price DESC LIMIT 10;
Структура вывода EXPLAIN
EXPLAIN ANALYZE
SELECT shop_product.*, shop_category.name AS category_name
FROM shop_product
INNER JOIN shop_category ON shop_product.category_id = shop_category.id
WHERE shop_product.is_active = true
ORDER BY shop_product.price DESC
LIMIT 10;
Вывод:
Limit (cost=0.44..1.96 rows=10 width=488) (actual time=0.045..0.060 rows=10 loops=1)
-> Nested Loop (cost=0.44..768.85 rows=5044 width=488) (actual time=0.044..0.058 rows=10 loops=1)
-> Index Scan Backward using product_active_price_idx on shop_product (cost=0.29..642.56 rows=5044 width=70) (actual time=0.010..0.018 rows=10 loops=1)
Index Cond: (is_active = true)
-> Memoize (cost=0.15..0.18 rows=1 width=426) (actual time=0.003..0.004 rows=1 loops=10)
Cache Key: shop_product.category_id
Cache Mode: logical
Hits: 8 Misses: 2 Evictions: 0 Overflows: 0 Memory Usage: 1kB
-> Index Scan using shop_category_pkey on shop_category (cost=0.14..0.17 rows=1 width=426) (actual time=0.010..0.010 rows=1 loops=2)
Index Cond: (id = shop_product.category_id)
Planning Time: 4.025 ms
Execution Time: 0.099 ms
(12 rows)
Разберем что здесь происходит:
- Limit - берет первые 10 строк и останавливается, не дожидаясь остальных
- Nested Loop - JOIN двух таблиц: для каждой строки shop_product ищет строку shop_category
- Index Scan Backward using product_active_price_idx - читает составной индекс
(is_active, price)в обратном порядке (DESC), получая строки уже отсортированными по убыванию цены. Отдельной операции Sort не нужно - Memoize (PostgreSQL 14+) - кеширует результаты поиска по shop_category. В примере 10 продуктов принадлежат 2 разным категориям: 8 обращений нашлись в кеше (Hits: 8), только 2 потребовали реального поиска (Misses: 2)
- Index Scan using shop_category_pkey - поиск категории по первичному ключу.
loops=2соответствует Misses у Memoize: реальное обращение к таблице произошло ровно 2 раза, остальные 8 отдал кеш
Как читать строку плана
Seq Scan on shop_product (cost=0.00..50.00 rows=150 width=200) (actual time=0.013..0.310 rows=150 loops=1)
- Seq Scan - тип операции
- cost=0.00..50.00 - оценочная стоимость: старт..финиш (в условных единицах PostgreSQL)
- rows=150 - оценочное количество строк
- width=200 - средний размер строки в байтах
- actual time=0.013..0.310 - реальное время: старт..финиш (в мс)
- rows=150 - реальное количество строк
- loops=1 - сколько раз выполнялся этот узел
Типы операций
Seq Scan - последовательное чтение
Seq Scan on shop_product (cost=0.00..50.00 rows=150)
Filter: is_active
Читает всю таблицу построчно. Нормально для маленьких таблиц или когда нужна большая часть строк. Плохо когда нужна одна строка из миллиона.
Index Scan - чтение через индекс
Index Scan using shop_product_slug_30bd2d5d_like on shop_product (cost=0.15..8.17 rows=1)
Index Cond: ((slug)::text = 'iphone-15'::text)
PostgreSQL использует B-tree индекс для поиска нужной строки. Эффективно для поиска конкретных строк. Обратите внимание на суффикс _like в названии индекса и приведение (slug)::text - это особенность PostgreSQL для строковых полей, подробнее разбирали в уроке 5.1.
Index Only Scan - только из индекса
Index Only Scan using product_category_active_idx on shop_product (cost=0.28..4.50 rows=10)
Index Cond: (category_id = 1 AND is_active = true)
Heap Fetches: 0
Все нужные данные есть в индексе, таблица не читается совсем. Heap Fetches: 0 означает ноль обращений к таблице. Самый быстрый вариант.
Bitmap Index Scan - через битовую карту
Bitmap Heap Scan on shop_product (cost=4.50..25.00 rows=150)
Recheck Cond: (is_active = true)
-> Bitmap Index Scan on shop_product_is_active (cost=0.00..4.46 rows=150)
Index Cond: (is_active = true)
PostgreSQL сначала строит битовую карту строк удовлетворяющих условию, затем читает их группами. Эффективно когда нужно много строк через индекс.
Типы JOIN
Hash Join
Hash Join (cost=1.09..52.50 rows=150)
Hash Cond: (shop_product.category_id = shop_category.id)
-> Seq Scan on shop_product
-> Hash
-> Seq Scan on shop_category
Строит хеш-таблицу для меньшей таблицы, затем проходит по большей. Эффективно для JOIN больших таблиц.
Nested Loop
Nested Loop (cost=0.28..25.00 rows=10)
-> Index Scan on shop_product
-> Index Scan on shop_category using shop_category_pkey
Index Cond: (id = shop_product.category_id)
Для каждой строки внешней таблицы ищет строки во внутренней. Эффективно когда внешняя таблица маленькая или хорошо отфильтрована.
Merge Join
Merge Join (cost=10.00..15.00 rows=50)
Merge Cond: (shop_product.category_id = shop_category.id)
Объединяет две отсортированные последовательности. PostgreSQL выбирает автоматически.
Диагностика проблем
Большое расхождение оценки и реальности
Seq Scan on shop_product (cost=0.00..50.00 rows=1) (actual rows=1500)
rows=1 (оценка) против rows=1500 (реальность), статистика устарела. PostgreSQL строит планы на основе статистики (распределение значений, количество строк). Если статистика неактуальна, план плохой.
-- Обновить статистику таблицы
ANALYZE shop_product;
-- Или полный vacuum + analyze
VACUUM ANALYZE shop_product;
Django не вызывает ANALYZE автоматически после bulk_create. После массовой загрузки данных стоит запустить вручную.
Медленный узел в плане
Sort (cost=1500.00..1600.00 rows=50000) (actual time=850.000..900.000 rows=50000)
Sort Key: price DESC
Sort Method: external merge Disk: 8192kB
Sort Method: external merge Disk, данные не поместились в память, PostgreSQL писал на диск. Решение, увеличить work_mem или добавить индекс для сортировки.
Много Nested Loop итераций
Nested Loop (actual time=0.100..5000.000 loops=10000)
loops=10000 означает что узел выполнился 10000 раз внутри одного запроса. Это характерно для Nested Loop JOIN с большой внешней таблицей или коррелированного подзапроса.
N+1 в Django - это другое, не высокий loops в одном запросе, а сотни отдельных SQL запросов. Его не видно в EXPLAIN ANALYZE отдельного запроса, его видно через connection.queries или Django Debug Toolbar. Если убрать select_related там где он нужен, EXPLAIN покажет план одного запроса без JOIN, а N+1 будет снаружи.
Вызов EXPLAIN из Django
QuerySet.explain() - встроенный метод
Django имеет встроенный метод explain() доступный на любом QuerySet:
# Простой EXPLAIN (без ANALYZE)
print(Product.objects.filter(is_active=True).explain())
Seq Scan on shop_product (cost=0.00..234.17 rows=5044 width=70)
Filter: is_active
# EXPLAIN ANALYZE - реальное выполнение с замерами
print(Product.objects.filter(is_active=True).explain(analyze=True))
Seq Scan on shop_product (cost=0.00..234.17 rows=5044 width=70) (actual time=0.012..1.120 rows=5044 loops=1)
Filter: is_active
Rows Removed by Filter: 4973
Planning Time: 0.095 ms
Execution Time: 10.470 ms
Дополнительные опции (PostgreSQL):
# Подробный вывод
print(Product.objects.filter(is_active=True).explain(analyze=True, verbose=True))
# Формат JSON для программной обработки
import json
plan_json = Product.objects.filter(is_active=True).explain(analyze=True, format="json")
# PostgreSQL 17+: статистика памяти и сериализации
print(Product.objects.filter(is_active=True).explain(analyze=True, memory=True, serialize="text"))
# Применимо к любому QuerySet включая сложные
qs = (
Product.objects
.filter(is_active=True)
.select_related("category")
.order_by("-price")[:10]
)
print(qs.explain(analyze=True))
explain() через cursor для нестандартных случаев
Если нужен EXPLAIN ANALYZE с параметрами которые explain() не поддерживает или для UPDATE/DELETE:
from django.db import connection
def explain_query(queryset):
sql, params = queryset.query.sql_with_params()
with connection.cursor() as cursor:
cursor.execute(f"EXPLAIN ANALYZE {sql}", params)
rows = cursor.fetchall()
return "\n".join(row[0] for row in rows)
# Использование
qs = Product.objects.filter(is_active=True).select_related("category")
print(explain_query(qs))
Для UPDATE/DELETE оборачивай в транзакцию с ROLLBACK чтобы не изменить данные:
from django.db import transaction
with transaction.atomic():
with connection.cursor() as cursor:
cursor.execute(
"EXPLAIN ANALYZE UPDATE shop_product SET stock = stock - 1 WHERE id = %s",
[1]
)
rows = cursor.fetchall()
print("\n".join(r[0] for r in rows))
transaction.set_rollback(True)
Или через строковый метод QuerySet (только для просмотра SQL, без выполнения):
print(Product.objects.filter(is_active=True).query)
# Показывает SQL без выполнения
pgBadger и pg_stat_statements
Для систематического анализа медленных запросов в production.
pg_stat_statements, расширение PostgreSQL, собирает статистику по всем выполненным запросам:
-- Топ 10 медленных запросов
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
Подключение в postgresql.conf:
shared_preload_libraries = 'pg_stat_statements'
Практическое задание
1) Выполните EXPLAIN ANALYZE для запроса SELECT * FROM shop_product WHERE slug = 'iphone-15'. Какой тип скана? Почему?
2) Выполните EXPLAIN ANALYZE для запроса без индекса: SELECT * FROM shop_product WHERE description LIKE '%phone%'. Какой тип скана? Какова стоимость?
3) Добавьте индекс на price и выполните EXPLAIN ANALYZE SELECT * FROM shop_product ORDER BY price DESC LIMIT 5. Изменился ли тип скана?
4) Напишите функцию explain_query(queryset) которая печатает план EXPLAIN ANALYZE для любого QuerySet. Используйте её для анализа запроса с JOIN (select_related).
5) Сравните планы для:
Product.objects.filter(is_active=True).order_by("-price")[:10]Product.objects.filter(is_active=True, price__gt=500)
Найдите разницу в типах операций.
Возможные ошибки
EXPLAIN без ANALYZE, оценки могут сильно отличаться от реальности
-- Только оценки, может вводить в заблуждение
EXPLAIN SELECT * FROM shop_product WHERE ...;
-- Реальные цифры, используйте для диагностики
EXPLAIN ANALYZE SELECT * FROM shop_product WHERE ...;
Игнорирование расхождения rows
(cost=... rows=1) (actual rows=50000)
Огромное расхождение между оценкой и реальностью, сигнал что нужен ANALYZE и возможно пересмотр индексов.
Делать EXPLAIN ANALYZE для тяжелых UPDATE/DELETE
EXPLAIN ANALYZE выполняет запрос реально, для UPDATE и DELETE это изменит данные. Оберните в транзакцию:
BEGIN;
EXPLAIN ANALYZE UPDATE shop_product SET stock = stock - 1 WHERE id = 1;
ROLLBACK;
Связь со следующим уроком
В уроке 5.3 разберем only(), defer() и iterator(), инструменты для управления памятью при работе с большими датасетами. Увидим как ограничение выбираемых полей влияет на план запроса и потребление памяти.
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru