Все, что вам нужно знать о предварительной загрузке в Джанго

Недавно я работал над системой заказа билетов на конференцию. Для клиента было очень важно увидеть таблицу заказов, включающую столбец со списком названий программ в каждом заказе:

Модели выглядели (примерно) так:

class Program(models.Model):
    name = models.CharField(
        max_length=20,
    )

class Price(models.Model):
    program = models.ForeignKey(
        Program,
    )
    from_date = models.DateTimeField()
    to_date = models.DateTimeField()

class Order(models.Model):
    state = models.CharField(
        max_length=20,
    )
    items = models.ManyToManyField(Price)
  • Program - сеанс, лекция или день конференции.
  • Price - цены могут меняться со временем. Одним из способов моделирования изменений во времени является использование медленно меняющегося измерения типа 2 (SCD). Модель Price представляет цену программы в определенный момент времени.
  • Order - Заказ на одну или несколько программ. Каждый пункт в заказе - это цена программы на момент оформления заказа.

Прежде чем мы начнем

На протяжении всей этой статьи мы будем отслеживать запросы, выполняемые Django. Для регистрации запросов добавьте следующее в настройки LOGGING в settings.py:

LOGGING = {
    # ...
    'loggers': {
        'django.db.backends': {
            'level': 'DEBUG',
        },
    },
}

В чем проблема?

Давайте попробуем получить названия программ для одного заказа:

>>> o = Order.objects.filter(state='completed').first()

(0.002) SELECT ... FROM "orders_order"
WHERE "orders_order"."state" = 'completed'
ORDER BY "orders_order"."id" ASC LIMIT 1;

>>> [p.program.name for p in o.items.all()]

(0.002) SELECT ... FROM "events_price"
INNER JOIN "orders_order_items" ON ("events_price"."id" = "orders_order_items"."price_id")
WHERE "orders_order_items"."order_id" = 29; args=(29,)

(0.001) SELECT ... FROM "events_program"
WHERE "events_program"."id" = 8; args=(8,)

['Day 1 Pass']
  • Для получения выполненных заказов нам нужен один запрос.
  • Чтобы получить имена программ для каждого заказа, нам нужно еще два запроса.

Если нам нужно два запроса для каждого заказа, количество запросов для 100 заказов будет 1 + 100 * 2 = 201 запрос, это много!

Давайте используем Django, чтобы уменьшить количество запросов:

>>> o.items.values_list('program__name')

(0.003) SELECT "events_program"."name" FROM "events_price"
INNER JOIN "orders_order_items" ON ("events_price"."id" = "orders_order_items"."price_id")
INNER JOIN "events_program" ON ("events_price"."program_id" = "events_program"."id")
WHERE "orders_order_items"."order_id" = 29 LIMIT 21;

['Day 1 Pass']

Отлично! Django выполнил объединение Price и Program и сократил количество запросов до одного на заказ.

На данный момент вместо 201 запроса нам нужно только 101 запрос на 100 заказов. Можем ли мы сделать лучше?

Почему мы не можем соединять?

Первый вопрос, который должен прийти на ум «почему мы не можем объединить таблицы?»

Если у нас есть внешний ключ, мы можем использовать select_related или использовать "snake case", как мы делали выше, чтобы получить связанные поля в одном запросе.

Например, мы выбрали имя программы для списка цен в одном запросе, используя values_list ('program__name'). Мы смогли это сделать, потому что каждая цена связана только с одной программой.

Если отношение между двумя моделями многие ко многим, мы не можем этого сделать. Каждый заказ имеет одну или несколько связанных цен - если мы объединяем две таблицы, мы получаем дубликаты заказов:

SELECT
    o.id AS order_id,
    p.id AS price_id
FROM
    orders_order o
    JOIN orders_order_items op ON (o.id = op.order_id)
    JOIN events_price p ON (op.price_id = p.id)
ORDER BY
    1,
    2;

 order_id | price_id
----------+----------
    45    |    38
    45    |    56
    70    |    38
    70    |    50
    70    |    77
    71    |    38

Заказы 70 и 45 содержат несколько предметов, поэтому в результате они появляются более одного раза - Django не может справиться с этим.

Django имеет хороший встроенный способ решения этой проблемы, называемый prefetch_related:

>>> o = Order.objects.filter(
...     state='completed',
... ).prefetch_related(
...     'items__program',
... ).first()

(0.002) SELECT ... FROM "orders_order"
WHERE "orders_order"."state" = 'completed'
ORDER BY "orders_order"."id" ASC LIMIT 1;

(0.001) SELECT ("orders_order_items"."order_id") AS "_prefetch_related_val_order_id", "events_price"...
FROM "events_price"
INNER JOIN "orders_order_items" ON ("events_price"."id" = "orders_order_items"."price_id")
WHERE "orders_order_items"."order_id" IN (29);

(0.001) SELECT "events_program"."id", "events_program"."name" FROM "events_program"
WHERE "events_program"."id" IN (8);

Мы сказали Django, что намереваемся извлечь items__program из набора результатов. Во втором и третьем запросе мы видим, что Django извлек сквозную таблицу orders_order_items и соответствующие программы из events_program. Результаты предварительной выборки кэшируются на объектах.

Что происходит, когда мы пытаемся извлечь имена программ из результата?

>>> [p.program.name for p in o.items.all()]

['Day 1 Pass']

Никаких дополнительных запросов - именно то, что мы хотели!

При использовании предварительной выборки важно работать с объектом, а не с запросом. Попытка получить имена программ с помощью запроса приведет к тому же результату, но приведет к дополнительному запросу:

>>> o.items.values_list('program__name')

(0.002) SELECT "events_program"."name" FROM "events_price"
INNER JOIN "orders_order_items" ON ("events_price"."id" = "orders_order_items"."price_id")
INNER JOIN "events_program" ON ("events_price"."program_id" = "events_program"."id")
WHERE "orders_order_items"."order_id" = 29 LIMIT 21;

['Day 1 Pass']

На данный момент для получения 100 заказов требуется всего 3 запроса. Можем ли мы сделать еще лучше?

Представляем Prefetch

В версии 1.7 Django представил новый объект Prefetch, который расширяет возможности prefetch_related.

Новый объект позволяет разработчику переопределить запрос, используемый Django для предварительной выборки связанных объектов.

В нашем предыдущем примере Django использовал два запроса для предварительной выборки - один для сквозной таблицы и один для таблицы программы. Что если бы мы могли сказать Джанго присоединиться к этим двум?

>>> prices_and_programs = Price.objects.select_related('program')

>>> o = Order.objects.filter(
...     state='completed'
... ).prefetch_related(
...     Prefetch('items', queryset=prices_and_programs)
... ).first()

(0.001) SELECT ... FROM "orders_order"
WHERE "orders_order"."state" = 'completed'
ORDER BY "orders_order"."id" ASC LIMIT 1;

(0.001) SELECT ("orders_order_items"."order_id") AS "_prefetch_related_val_order_id",
"events_price"..., "events_program"...
INNER JOIN "events_program" ON ("events_price"."program_id" = "events_program"."id")
WHERE "orders_order_items"."order_id" IN (29);

Мы создали запрос, который объединяет цены с программами. Затем мы сказали Django использовать этот запрос для предварительной выборки значений. Это все равно что сказать Django, что вы намереваетесь получать как предметы, так и программы для каждого заказа.

Получение названий программ для заказа:

>>> [p.program.name for p in o.items.all()]

['Day 1 Pass']

Никаких дополнительных запросов - это сработало!

Переводим это на следующий уровень

Когда мы говорили ранее о моделях, мы упоминали, что цены смоделированы как таблица SCD. Это означает, что мы можем запрашивать только активные цены на определенную дату.

Цена активна на определенную дату, если она находится между from_date и end_date:

>>> from django.utils import timezone
>>> now = timezone.now()

>>> active_prices = Price.objects.filter(
...     from_date__lte=now,
...     to_date__gt=now,
... )

Используя объект Prefetch, мы можем указать Django хранить предварительно выбранные объекты в новом атрибуте набора результатов:

>>> from django.utils import timezone
>>> now = timezone.now()
>>> active_prices_and_programs = (
...     Price.objects.filter(
...         from_date__lte=now,
...         to_date__gt=now,
...     ).select_related('program')
... )
>>> o = Order.objects.filter(
...     state='completed'
... ).prefetch_related(
...     Prefetch(
...         'items',
...         queryset=active_prices_and_programs,
...         to_attr='active_prices',
...     ),
... ).first()

(0.001) SELECT ... FROM "orders_order"
WHERE "orders_order"."state" = 'completed'
ORDER BY "orders_order"."id" ASC
LIMIT 1;

(0.001) SELECT ... FROM "events_price"
INNER JOIN "orders_order_items" ON ("events_price"."id" = "orders_order_items"."price_id")
INNER JOIN "events_program" ON ("events_price"."program_id" = "events_program"."id")
WHERE ("orders_order_items"."order_id" IN (29)
AND "events_price"."from_date" <= '2017–04–29T07:53:00.210537+00:00'::timestamptz
AND "events_price"."to_date" > '2017–04–29T07:53:00.210537+00:00'::timestamptz);

В логах видно, что Django выполнил только два запроса, и запрос предварительной выборки теперь включает в себя пользовательский фильтр, который мы определили.

Чтобы получить активные цены, мы можем использовать новый атрибут, определенный в to_attr:

>>> [p.program.name for p in o.active_prices]

['Day 1 Pass']

Нет дополнительного запроса!

Заключительные слова

Предварительная выборка - очень мощная функция Django ORM. Я настоятельно рекомендую просмотреть документацию, вы обязаны "нанести удар".

Перевод https://hakibenita.com/all-you-need-to-know-about-prefetching-in-django#before-we-start

Вернуться на верх