Django ORM

EXPLAIN и анализ запросов в Django ORM | Курс Django ORM урок 5.2

EXPLAIN и анализ запросов в Django ORM | Курс Django ORM урок 5.2
Mikhail
Автор
Mikhail
Опубликовано 16.03.2026
0,0
Views 3

Цель урока

Научиться читать план выполнения запроса 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(), инструменты для управления памятью при работе с большими датасетами. Увидим как ограничение выбираемых полей влияет на план запроса и потребление памяти.


<< Урок 5.1

Урок 5.3 >>


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

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

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

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

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

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

Пишите info@aisferaic.ru

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