Рубріки: Теория

Django ORM и его самые популярные фичи

Сергей Почекутов

Django ORM — это инструмент фреймворка Django, который позволяет взаимодействовать с базами данных, используя высокоуровневые методы Python, а не SQL-запросы. Он относится к типу ORM, который реализует шаблон Active Record. Общая суть шаблона в том, что каждой таблице в приложении соответствует одна модель.

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

Содержание:
1. Основы Django ORM
2. Плюсы и минусы Django ORM
3. Проверка запросов
4. Фильтрация данных
5. QuerySet – результаты в виде именованного кортежа
6. Пользовательские функции ORM Django
7. Ограничение времени выполнения запроса
8. Установка лимита
9. Использование кэшированных внешних ключей
10. Использование select_related
11. Индексы внешних ключей
12. Порядок столбцов в составном индексе
13. BRIN-индексы
Заключение

1. Основы Django ORM

Вот пример модели пользователя:

from django.db import models
class User(models.Model):
    email = models.EmailField(unique=True)
    nickname = models.CharField(max_length=100, null=True)

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

Например, можно найти пользователя по идентификатору:

user = User.objects.get(id=2)

Или обновить его никнейм:

user.nickname = 'High and load'

Изменения сохраняются в базе данных после использования метода save:

user.save()

Можно посчитать количество пользователей в БД:

User.objects.count()

Главный плюс Django ORM — существенное упрощение запросов. Это возможно благодаря Query Builder — абстракции поверх SQL.

Сравните:

users = User.objects.order_by('email')[:20]

С SQL-запросом:

SELECT "user"."id",
  ...
 FROM "user"
 ORDER BY "user"."email" ASC
 LIMIT 20

В Django ORM существует возможность делать SQL-запросы напрямую. Однако это лучше оставить для тех крайних случаев, с которыми Query Builder не справляется.

2. Плюсы и минусы Django ORM

Главные плюсы использования Django ORM — миграция и транзакции.

  1. Можно без труда менять таблицы и обновлять модели. Django автоматически генерирует порядок миграции для внесений изменений в БД.
  2. В рамках одной транзакции можно сделать несколько обновлений БД. Если что-то пойдет не так, всегда остается возможность откатиться к предыдущему состоянию.

Недостатки у Django ORM тоже есть. Главным источником проблем становится чрезмерная простота инструмента. Разработчикам не обязательно знать, какие SQL-запросы генерируются. Из-за этого может значительно увеличиваться нагрузка на сервер.

3. Проверка запросов

Чтобы грамотно пользоваться возможностями Django ORM, нужно понимать, что происходит «под капотом». Сделать это можно несколькими способами.

Connection.queries

Если в настройках Django-проекта установлен параметр debug = True, то можно смотреть выполненные запросы с помощью connection.queries. Пример:

from django.db import connection
ost.objects.all()
connection.queries
[
   {
      'sql': 'SELECT "blogposts_post"."id", "blogposts_post"."title", '
             '"blogposts_post"."content", "blogposts_post"."blog_id", '
             '"blogposts_post"."published" FROM "blogposts_post" LIMIT 21',
      'time': '0.000'
   }
]

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

from django.db import reset_queries
reset_queries()

Shell_plus –print-sql

Shell_plus — одна из функций библиотеки расширений для Django. При вызове с параметром —print-sql она отображает SQL-запросы по мере их выполнения. Например:

manage.py shell_plus --print-sql
post = Post.objects.get(id=1)
SELECT "blogposts_post"."id",
       "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
ORDER BY "blogposts_post"."id" ASC
LIMIT 1

Django Silk

Silk — инструмент профилирования Django. Он записывает и визуализирует выполненные SQL-запросы. Это позволяет разработчику видеть, какие запросы отработали, изучать подробности по каждому обращению к БД, в том числе контролировать, какая строка кода инициировала запрос.

Django Debug Toolbar

Debug Toolbar добавляет в браузер отладочную панель. Она предоставляет много возможностей для проверки проекта и исправления ошибок, в том числе отображает выполненные SQL-запросы. Можно проверить каждый запрос, посмотреть порядок их выполнения, а также затраченное время (профилирование).

4. Фильтрация данных

Аргумент filter появился еще в версии Django 2.0. Это заметно упростило получение данных по нескольким условиям. Например, вот так просто можно увидеть общее количество пользователей и общее количество активных пользователей.

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')),
)

Сравните, насколько больше кода нужно написать для решения той же задачи без использования filter:

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(),
    )),
)

В 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;

Ничего сложного, но с ORM все равно намного удобнее.

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

Еще один полезный атрибут — named. Если он равен True, то QuerySet отображается в виде именованного кортежа:

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

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

В Django ORM доступно добавление пользовательских функций. Это помогает расширить его возможности и не дожидаться обновлений от вендоров баз данных.

Например, нужно найти среднюю продолжительность. Сделать это легко:

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. Сначала нужно привести interval к числу. Можно сделать это с помощью функции 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}

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

В Django используются синхронные процессы. Пока пользователь выполняет длительную операцию, рабочий процесс приостанавливается. В большинстве случаев время тратится на запросы к БД. Поэтому будет нелишним ограничить время выполнения запросов.

Например, вот так устанавливается глобальный таймаут:

# 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
    
    # Таймаут через 40 секунд.
    with connection.cursor() as cursor:
        cursor.execute("""
            SET statement_timeout TO 40000;
        """)

Используется файл wsgi.py, чтобы ограничить только рабочие процессы.

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

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

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

import requests
response = requests.get(
    'https://api.very-slow.com',
    timeout=4000,
)

8. Установка лимита

Еще один способ оптимизации запросов — установка лимитов.
Например, пользователю нужно получить список всех продаж с начала работы компании. Разработчик понимает, что такая ситуация может случиться, и поэтому устанавливает лимит: не более 100 записей.

Простой способ:

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

Оператор 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, то всего записей уже точно больше 100. Значит, будет выброшено исключение.

9. Использование кэшированных внешних ключей

Если необходимо получить идентификатор внешнего ключа, можно использовать кэшированный ID с помощью <field_name>_id.
Например, пусть будет такой запрос:

Post.objects.first().blog.id

Вот какие SQL-запросы выполняются:

SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
ORDER BY "blogposts_post"."id" ASC
LIMIT 1
Execution time: 0.001668s [Database: default]
SELECT "blogposts_blog"."id",
      "blogposts_blog"."name",
      "blogposts_blog"."url"
 FROM "blogposts_blog"
WHERE "blogposts_blog"."id" = 1
LIMIT 21
Execution time: 0.000197s [Database: default]

После обращения к id объекта blog создается еще один запрос, который возвращает весь объект blog. Но если не требуется доступ к другим атрибутам объекта blog, то и возвращать его целиком нет смысла.

Использование кэшированного id:

Post.objects.first().blog_id

При таком вызове будет на один запрос меньше:

SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
ORDER BY "blogposts_post"."id" ASC
LIMIT 1
Execution time: 0.000165s [Database: default]

10. Использование select_related

Еще одна хорошая практика — заранее рассказывать Django, что нужно делать. Например, для этого есть функция select_related. Она позволяет точно указать, какие связанные модели потребуются, чтобы Django мог выполнить JOIN.

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

Допустим, нужно получить определенный Post:

post = Post.objects.get(id=1)

Выполненные запросы:

SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
ORDER BY "blogposts_post"."id" ASC
LIMIT 1

А теперь нужно получить доступ к Blog из Post:

post.blog

Выполненные запросы:

SELECT "blogposts_blog"."id",
      "blogposts_blog"."name",
      "blogposts_blog"."url"
 FROM "blogposts_blog"
WHERE "blogposts_blog"."id" = 1
LIMIT 21
Execution time: 0.000602s [Database: default]
<Blog: Rocio's Blog>

Чтобы получить информацию из Blog, ORM выполнил новый запрос. Этого можно избежать, используя select_related:

post = Post.objects.select_related("blog").get(id=1)

Выполненные запросы:

SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published",
      "blogposts_blog"."id",
      "blogposts_blog"."name",
      "blogposts_blog"."url"
 FROM "blogposts_post"
INNER JOIN "blogposts_blog"
   ON ("blogposts_post"."blog_id" = "blogposts_blog"."id")
WHERE "blogposts_post"."id" = 1
LIMIT 21
Execution time: 0.000150s [Database: default]

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

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

Django создает B-Tree индексы для внешних ключей в модели. Они не всегда нужны и при этом занимают много места. Типичный пример — модель с отношением многие-ко-многим:

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

Здесь будет два внешних индекса — для user и group.

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

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

Unique_together создает индекс для обоих полей: group и user. В итоге есть одна модель, два поля и целых три индекса.

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

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

Цель этих манипуляций — оптимизация. Без лишних индексов вставка и обновление данных будут проходить быстрее, а база данных будет весить меньше.

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

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

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

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

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

13. BRIN-индексы

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

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]

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

[1–3] —  здесь такого точно нет;

[4–6] —  здесь такого точно нет;

[7–9] —  возможно, здесь.

Благодаря такому разделению поиск ограничивается одним диапазоном.

Но все ломается, если значения не отсортированы. Например,

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

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

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

Например, нужно найти блок 5.

[2–8] — возможно, здесь;

[3–9] —  возможно, здесь;

[1–7] —  возможно, здесь.

Разделение на диапазоны становится не только бесполезным, но и вредным. Одну и ту же работу приходится выполнять несколько раз.
Для максимально полезного использования BRIN данные должны быть отсортированы или сгруппированы. Например, можно использовать поле auto_now_add:

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

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

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

При небольшом количестве записей разница в размере БД будет незаметной. Но если добавить в таблицу два миллиона записей и отсортировать их по дате, то разница будет значительная:

B-Tree-индекс — 37 MB

BRIN-индекс — 49 KB

Заключение

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

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

Останні статті

Обучение Power BI – какие онлайн курсы аналитики выбрать

Сегодня мы поговорим о том, как выбрать лучшие курсы Power BI в Украине, особенно для…

13.01.2024

Work.ua назвал самые конкурентные вакансии в IТ за 2023 год

В 2023 году во всех крупнейших регионах конкуренция за вакансию выросла на 5–12%. Не исключением…

08.12.2023

Украинская IT-рекрутерка создала бесплатный трекер поиска работы

Unicorn Hunter/Talent Manager Лина Калиш создала бесплатный трекер поиска работы в Notion, систематизирующий все этапы…

07.12.2023

Mate academy отправит работников в 10-дневный оплачиваемый отпуск

Edtech-стартап Mate academy принял решение отправить своих работников в десятидневный отпуск – с 25 декабря…

07.12.2023

Переписки, фото, история браузера: киевский программист зарабатывал на шпионаже

Служба безопасности Украины задержала в Киеве 46-летнего программиста, который за деньги устанавливал шпионские программы и…

07.12.2023

Как вырасти до сеньйора? Девелопер создал популярную подборку на Github

IT-специалист Джордан Катлер создал и выложил на Github подборку разнообразных ресурсов, которые помогут достичь уровня…

07.12.2023