Фичи Django ORM, о которых вы не знали

django

ORM весьма полезны для разработчиков, но абстрагирование доступа к базе данных имеет свою цену. Разработчики, которые решили покопаться в базе данных, обнаруживают, что некоторые вещи можно было сделать проще. Представляем вашему вниманию 9 советов по работе с базами данных в Django.

Агрегация с filter

До Django 2.0, если мы хотели получить что-нибудь вроде общего количества пользователей и общего количества активных пользователей, приходилось пользоваться условными выражениями:

from django.contrib.auth.models import User
from django.db.models import (
    Count,
    Sum,
    Case,
    When,
    Value,
    IntegerField,
)
User.objects.aggregate(
    total_users=Count('id'),
    total_active_users=Sum(Case(
        When(is_active=True, then=Value(1)),
        default=Value(0),
        output_field=IntegerField(),
    )),
)

В Django 2.0 был добавлен аргумент filter для агрегатных функций, что сильно упростило процесс:

from django.contrib.auth.models import User
from django.db.models import Count, F
User.objects.aggregate(
    total_users=Count('id'),
    total_active_users=Count('id', filter=F('is_active')),
)

Коротко и ясно.

Если вы используете PostgreSQL, то два запроса будут выглядеть следующим образом:

SELECT
    COUNT(id) AS total_users,
    SUM(CASE WHEN is_active THEN 1 ELSE 0 END) AS total_active_users
FROM
    auth_users;
SELECT
    COUNT(id) AS total_users,
    COUNT(id) FILTER (WHERE is_active) AS total_active_users
FROM
    auth_users;

Результаты QuerySet в виде именованного кортежа

В Django 2.0 в values_list был добавлен новый атрибут под названием named. Если установить его значение равным True, то в результате мы получим QuerySet в виде списка с именованными кортежами:

> user.objects.values_list(
    'first_name',
    'last_name',
)[0]
(‘Haki’, ‘Benita’)
> user_names = User.objects.values_list(
    'first_name',
    'last_name',
    named=True,
)
> user_names[0]
Row(first_name='Haki', last_name='Benita')
> user_names[0].first_name
'Haki'
> user_names[0].last_name
'Benita'

Пользовательские функции

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

Допустим, у нас есть модель Report с полем duration. Мы хотим найти среднее всех значений этого поля:

from django.db.models import Avg
Report.objects.aggregate(avg_duration=Avg('duration'))
> {'avg_duration': datetime.timedelta(0, 0, 55432)}

Здорово, но одно только среднее значение нам мало что даёт. Давайте попробуем получить ещё и среднеквадратичное отклонение:

from django.db.models import Avg, StdDev
Report.objects.aggregate(
    avg_duration=Avg('duration'),
    std_duration=StdDev('duration'),
)
ProgrammingError: function stddev_pop(interval) does not exist
LINE 1: SELECT STDDEV_POP("report"."duration") AS "std_dura...
               ^
HINT:  No function matches the given name and argument types. You might need to add explicit type casts.

Упс… PostgreSQL не поддерживает stddev на поле типа interval — нужно привести его к числу, прежде чем мы сможем использовать STDDEV_POP.

Как вариант можно использовать функцию EXTRACT:

SELECT
    AVG(duration),
    STDDEV_POP(EXTRACT(EPOCH FROM duration))
FROM 
    report;
      avg       |    stddev_pop    
----------------+------------------
 00:00:00.55432 | 1.06310113695549
(1 row)

А как это реализовать в Django? Правильно, с помощью пользовательских функций:

# common/db.py
from django.db.models import Func
class Epoch(Func):
   function = 'EXTRACT'
   template = "%(function)s('epoch' from %(expressions)s)"

В итоге наша функция выглядит следующим образом:

from django.db.models import Avg, StdDev, F
from common.db import Epoch
Report.objects.aggregate(
    avg_duration=Avg('duration'), 
    std_duration=StdDev(Epoch(F('duration'))),
)
{'avg_duration': datetime.timedelta(0, 0, 55432),
 'std_duration': 1.06310113695549}

Обратите внимание на использование F-выражения в вызове Epoch.

Ограничиваем время выполнения запроса

Это, наверное, самый простой и самый важный совет. Все мы люди и нам свойственно совершать ошибки. Мы не можем абсолютно всё предусмотреть наперёд.

В отличие от других неблокирующих серверов приложений вроде Tornado, asyncio и даже Node, Django обычно использует синхронные рабочие процессы. Это значит, что, когда пользователь выполняет длительную операцию, рабочий процесс останавливается, и никто не может его использовать до тех пор, пока операция не завершится.

Конечно, вряд ли кто-то использует Django в продакшене только с одним процессом.

В большинстве Django-приложений большая часть времени тратится на ожидание запросов к базе данных. Поэтому будет неплохо начать с ограничения времени выполнения SQL-запросов.

Можно установить глобальный тайм-аут таким образом:

# wsgi.py
from django.db.backends.signals import connection_created
from django.dispatch import receiver
@receiver(connection_created)
def setup_postgres(connection, **kwargs):
    if connection.vendor != 'postgresql':
        return
    
    # Тайм-аут через 30 секунд.
    with connection.cursor() as cursor:
        cursor.execute("""
            SET statement_timeout TO 30000;
        """)

Почему wsgi.py? Таким образом затрагиваются только рабочие процессы, а не какие-нибудь аналитические запросы, задачи cron и т.д.

Тайм-аут также можно установить на уровне пользователя:

postgresql=#> alter user app_user set statement_timeout TO 30000;
ALTER ROLE

Примечание На сетевые взаимодействия можно потратить уйму времени. Поэтому при вызове удалённой службы убедитесь, что установили тайм-аут:

import requests
response = requests.get(
    'https://api.slow-as-hell.com',
    timeout=3000,
)

LIMIT

Это немного связано с предыдущим пунктом в плане ограничений. Иногда мы хотим, чтобы пользователи могли создавать отчёты и, возможно, экспортировать их в таблицу. Такая функциональность всегда находится под подозорением, когда что-то идёт не так.

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

И здесь на помощь спешит LIMIT.

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

# плохой пример
data = list(Sale.objects.all())[:100]

Это — худшее, что вы можете сделать. Вы просто поместили дофигаллион записей в память, чтобы вернуть всего лишь первые 100 из них.

Попробуем ещё раз:

data = Sale.objects.all()[:100]

Уже лучше. Django использует оператор SQL limit для получения только первых ста записей.

Итак, ограничение добавили, пользователи под контролем, всё хорошо. Но одна проблема все ещё есть — пользователь запросил все записи, и мы вернули ему 100. Теперь он думает, что их всего 100, что, конечно же, не так.

Вместо того чтобы просто возвращать первые 100 записей, давайте будем выбрасывать исключение, если записей на самом деле больше:

LIMIT = 100
if Sales.objects.count() > LIMIT:
    raise ExceededLimit(LIMIT)
return Sale.objects.all()[:LIMIT]

Это сработает, но мы просто добавили ещё один запрос.

Можем ли мы сделать лучше? Наверное, можем:

LIMIT = 100
data = Sale.objects.all()[:(LIMIT + 1)]
if len(data) > LIMIT:
    raise ExceededLimit(LIMIT)
return data

Вместо того, чтобы запросить первые 100 записей, мы запрашиваем 100 + 1 = 101 запись. Если 101 запись существует, то этого достаточно, чтобы понять, что записей больше 100. Другими словами, запрос LIMIT + 1 записи — меньшее, что можно сделать, чтобы убедиться, что запрос вернёт количество записей не больше, чем LIMIT.

Запомните этот трюк с LIMIT + 1, порой может пригодиться.

Select for update … of

К этому мы пришли через боль и страдания. Посреди ночи у нас начали появляться ошибки по поводу тайм-аутов транзакций из-за блокировок в базе данных.

Общая схема работы с транзакциями в нашем коде выглядит следующим образом:

from django.db import transaction as db_transaction
...
with db_transaction.atomic():
  transaction = (
        Transaction.objects
        .select_related(
            'user',
            'product',
            'product__category',
        )
        .select_for_update()
        .get(uid=uid)
  )
    ...

Управление транзакциями обычно связано со свойствами пользователя и продукта, поэтому мы часто использовали select_related для принудительного объединения и сохранения некоторых запросов.

Обновление транзакции также предполагает блокировку, чтобы быть уверенным, что больше никто с ней не взаимодействует.

Итак, вы видите, в чём проблема? Нет? Мы тоже не видели.

У нас были ETL-процессы, работающие в ночное время с таблицами пользователей и продуктов. Эти ETL выполняли обновление и вставку, поэтому они также блокировали таблицы.

Так в чём была проблема? Когда select_for_update используется вместе с select_related, Django пытается заблокировать все таблицы в запросе.

Код, который мы использовали, пытался заблокировать как таблицу транзакций, так и таблицы пользователей, продуктов и категорий. Когда ETC блокировал последние три таблицы в середине ночи, транзакции начинали давать сбой.

Как только мы поняли, в чём суть проблемы, мы стали искать способ заблокировать только нужную таблицу — таблицу транзакций. К счастью, новая опция для select_for_update как раз стала доступна в Django 2.0:

from django.db import transaction as db_transaction
...
with db_transaction.atomic():
  transaction = (
        Transaction.objects
        .select_related(
            'user',
            'product',
            'product__category',
        )
        .select_for_update(
            of=('self',)
        )
        .get(uid=uid)
  )
  ...

Используя опцию of, мы можем явно указать, какие таблицы надо заблокировать; self — специальное ключевое слово, указывающее, что мы хотим заблокировать модель, с которой мы работаем, в данном случае это Transaction.

На данный момент эта опция доступна только для PostgreSQL и Oracle.

Индексы внешних ключей

При создании модели Django автоматически создаёт B-Tree индекс для любого внешнего ключа. Эти индексы порой занимают много места и в то же время не всегда нужны.

Классический пример — модель с отношением многие-ко-многим:

class Membership(Model):
    group = ForeignKey(Group)
    user = ForeignKey(User)

В этой модели Django неявно создаст два индекса: один для user и другой для group.

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

class Membership(Model):
    group = ForeignKey(Group)
    user = ForeignKey(User)
    class Meta:
        unique_together = (
           'group',
           'user',
        )

Кроме того, unique_together создаст индекс для обоих полей. Таким образом, мы получаем одну модель с двумя полями и тремя индексами.

В зависимости от того, что мы собираемся делать с моделью, во многих случаях мы можем отбросить индексы внешних ключей и оставить только созданный уникальным ограничением:

class Membership(Model):
    group = ForeignKey(Group, db_index=False)
    user = ForeignKey(User, db_index=False)
    class Meta:
        unique_together = (
            'group',           
            'user',
        )

Удаление лишних индексов сделает вставку и обновление быстрее, кроме того, наша база данных теперь весит меньше, что всегда хорошо.

Порядок столбцов в составном индексе

Индексы с более чем одним столбцом называются составными. В составных индексах В-Tree первый столбец индексируется с помощью древовидной структуры. Из листьев первого уровня создаются деревья для второго уровня и так далее.

Порядок столбцов в индексе очень важен.

В примере выше у нас бы создавалось дерево сначала для групп, а затем для каждой группы ещё по дереву для всех пользователей в группе.

Секрет состоит в том, чтобы в составных индексах B-Tree делать вторичные индексы как можно меньше. Другими словами, столбцы с большей кардинальностью (больше разных значений) должны идти первыми.

В нашем примере разумно предположить, что пользователей больше, чем групп, поэтому столбец с пользователями мы выносим на первый план, чтобы сделать вторичный индекс меньше:

class Membership(Model):
    group = ForeignKey(Group, db_index=False)
    user = ForeignKey(User, db_index=False)
    class Meta:
        unique_together = (
            'user',
            'group',
        )

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

BRIN-индексы

Индекс B-Tree имеет структуру дерева. Стоимость поиска одного значения — это высота дерева + 1 при случайном доступе к дереву. Это делает B-Tree индексы идеальными для уникальных ограничений и некоторых диапазонных запросов.

Проблема B-Tree индексов заключается в том, что они могут занимать много места.

Зачастую кажется, что нет никаких альтернатив, но базы данных предлагают другие типы индексов для разных случаев.

В Django 1.11 появилась новая опция Meta для создания индексов на модели. Это даёт нам возможность изучить другие типы индексов.

В PostgreSQL есть очень полезный тип индексов под названием BRIN (Block Range Index). В некоторых случаях этот тип индексов может быть более эффективным, чем B-Tree.

Давайте посмотрим, что об этом говорится в официальной документации:

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

Чтобы понять это утверждение, важно понимать, как работает индекс BRIN. Как следует из названия, BRIN создаёт мини-индекс по ряду соседних блоков в таблице. Индекс очень маленький, и он может только сказать, находится ли определённое значние или нет в диапазоне индексированных блоков.

Давайте посмотрим на упрощённый пример работы BRIN, чтобы лучше со всем разобраться.

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

1, 2, 3, 4, 5, 6, 7, 8, 9

Теперь создадим три диапазона для смежных блоков:

[1,2,3], [4,5,6], [7,8,9]

Для каждого диапазона мы будем хранить его минимальное и максимальное значения:

[1–3], [4–6], [7–9]

Попробуем найти 5, используя этот индекс:

  • [1–3] —  точно не здесь;
  • [4–6] —  возможно, здесь;
  • [7–9] —  точно не здесь.

Благодаря этому индексу наш поиск ограничивается блоками 4–6.

Возьмём другой пример, в этот раз значения уже не отсортированы:

[2,9,5], [1,4,7], [3,8,6]

А вот наш индекс с минимальным и максимальным значениями для каждого диапазона:

[2–9], [1–7], [3–8]

Попробуем найти 5:

  • [2–9] — возможно, здесь;
  • [1–7] —  возможно, здесь;
  • [3–8] —  возможно, здесь.

Этот индекс бесполезен — он не просто не ограничил наш поиск, но ещё и вынудил нас проделать больше работы, так как мы запросили и индекс, и всю таблицу.

Вернёмся к документации:

…значение индексируемого столбца имеет некоторую естественную корреляцию с физическим положением строки в таблице.

Вот оно что. Чтобы извлечь из BRIN-индекса максимальную пользу, значения в столбце должны быть отсортированы или сгруппированы на диске.

Возвращаясь к Django, какое поле у нас часто индексируется и с наибольшей вероятностью будет естественным образом отсортировано на диске? Правильно, auto_now_add.

Обычно в моделях Django это выглядит так:

class SomeModel(Model):    
    created = DatetimeField(
        auto_now_add=True,
    )

При использовании auto_now_add Django автоматически заполняет поле временем создания записи. Созданное поле зачастую используется в запросах, поэтому его часто индексируют.

Добавим BRIN-индекс:

from django.contrib.postgres.indexes import BrinIndex
class SomeModel(Model):
    created = DatetimeField(
        auto_now_add=True,
    )
    class Meta:
        indexes = (
            BrinIndex(fields=['created']),
        )

Чтобы ощутить разницу в размерах, создадим таблицу с ~2 млн записей, где есть отсортированное поле даты:

B-Tree индекс: 37 MB
BRIN индекс: 49 KB

Всё верно, здесь нет никакой ошибки.

При создании индексов следует учитывать не только их размер. Однако теперь, учитывая поддержку индексов с Django 1.11, мы можем легко добавлять новые типы индексов в наши приложения и делать их легче и быстрее.

Перевод статьи «9 Django Tips for Working with Databases»

Ещё интересное для вас:
Тест: какой язык программирования вам стоит выбрать для изучения?
Тест: как хорошо вы разбираетесь в Data Science?
Соревнования и бесплатная онлайн-школа для программистов