Фичи Django ORM, о которых вы не знали
Разработчики, решившие более детально разобраться в работе СУБД, часто обнаруживают, что делает некоторые вещи не оптимально. Представляем вашему вниманию советы по работе с базами данных в Django ORM.
57К открытий58К показов
ORM весьма полезны для разработчиков, но абстрагирование доступа к базе данных имеет свою цену. Разработчики, которые решили покопаться в базе данных, обнаруживают, что некоторые вещи можно было сделать проще. Представляем вашему вниманию 9 советов по работе с базами данных в Django.
Агрегация с filter
До Django 2.0, если мы хотели получить что-нибудь вроде общего количества пользователей и общего количества активных пользователей, приходилось пользоваться условными выражениями:
В Django 2.0 был добавлен аргумент filter
для агрегатных функций, что сильно упростило процесс:
Коротко и ясно.
Если вы используете PostgreSQL, то два запроса будут выглядеть следующим образом:
Результаты QuerySet в виде именованного кортежа
В Django 2.0 в values_list был добавлен новый атрибут под названием named
. Если установить его значение равным True
, то в результате мы получим QuerySet в виде списка с именованными кортежами:
Пользовательские функции
ORM Django довольно многофункциональный, но тем не менее не может поспевать за всеми вендорами баз данных. К счастью, существует возможность дополнять его пользовательскими функциями.
Допустим, у нас есть модель Report
с полем duration
. Мы хотим найти среднее всех значений этого поля:
Здорово, но одно только среднее значение нам мало что даёт. Давайте попробуем получить ещё и среднеквадратичное отклонение:
Упс… PostgreSQL не поддерживает stddev
на поле типа interval
— нужно привести его к числу, прежде чем мы сможем использовать STDDEV_POP
.
Как вариант можно использовать функцию EXTRACT
:
А как это реализовать в Django? Правильно, с помощью пользовательских функций:
В итоге наша функция выглядит следующим образом:
Обратите внимание на использование F-выражения в вызове Epoch
.
Ограничиваем время выполнения запроса
Это, наверное, самый простой и самый важный совет. Все мы люди и нам свойственно совершать ошибки. Мы не можем абсолютно всё предусмотреть наперёд.
В отличие от других неблокирующих серверов приложений вроде Tornado, asyncio и даже Node, Django обычно использует синхронные рабочие процессы. Это значит, что, когда пользователь выполняет длительную операцию, рабочий процесс останавливается, и никто не может его использовать до тех пор, пока операция не завершится.
Конечно, вряд ли кто-то использует Django в продакшене только с одним процессом.
В большинстве Django-приложений большая часть времени тратится на ожидание запросов к базе данных. Поэтому будет неплохо начать с ограничения времени выполнения SQL-запросов.
Можно установить глобальный тайм-аут таким образом:
Почему wsgi.py
? Таким образом затрагиваются только рабочие процессы, а не какие-нибудь аналитические запросы, задачи cron и т.д.
Тайм-аут также можно установить на уровне пользователя:
Примечание На сетевые взаимодействия можно потратить уйму времени. Поэтому при вызове удалённой службы убедитесь, что установили тайм-аут:
LIMIT
Это немного связано с предыдущим пунктом в плане ограничений. Иногда мы хотим, чтобы пользователи могли создавать отчёты и, возможно, экспортировать их в таблицу. Такая функциональность всегда находится под подозорением, когда что-то идёт не так.
Нередко встречаются пользователи, которые считают целесообразным экспортировать все записи с начала времён в разгар рабочего дня. Также такие пользователи зачастую считают своим долгом открыть другую вкладку и попробовать снова, т.к. прошлая попытка успехом не увенчалась.
И здесь на помощь спешит LIMIT.
Давайте попробуем ограничить какой-нибудь запрос сотней записей:
Это — худшее, что вы можете сделать. Вы просто поместили дофигаллион записей в память, чтобы вернуть всего лишь первые 100 из них.
Попробуем ещё раз:
Уже лучше. Django использует оператор SQL limit
для получения только первых ста записей.
Итак, ограничение добавили, пользователи под контролем, всё хорошо. Но одна проблема все ещё есть — пользователь запросил все записи, и мы вернули ему 100. Теперь он думает, что их всего 100, что, конечно же, не так.
Вместо того чтобы просто возвращать первые 100 записей, давайте будем выбрасывать исключение, если записей на самом деле больше:
Это сработает, но мы просто добавили ещё один запрос.
Можем ли мы сделать лучше? Наверное, можем:
Вместо того, чтобы запросить первые 100 записей, мы запрашиваем 100 + 1 = 101 запись. Если 101 запись существует, то этого достаточно, чтобы понять, что записей больше 100. Другими словами, запрос LIMIT + 1 записи — меньшее, что можно сделать, чтобы убедиться, что запрос вернёт количество записей не больше, чем LIMIT.
Запомните этот трюк с LIMIT + 1, порой может пригодиться.
Select for update … of
К этому мы пришли через боль и страдания. Посреди ночи у нас начали появляться ошибки по поводу тайм-аутов транзакций из-за блокировок в базе данных.
Общая схема работы с транзакциями в нашем коде выглядит следующим образом:
Управление транзакциями обычно связано со свойствами пользователя и продукта, поэтому мы часто использовали select_related
для принудительного объединения и сохранения некоторых запросов.
Обновление транзакции также предполагает блокировку, чтобы быть уверенным, что больше никто с ней не взаимодействует.
Итак, вы видите, в чём проблема? Нет? Мы тоже не видели.
У нас были ETL-процессы, работающие в ночное время с таблицами пользователей и продуктов. Эти ETL выполняли обновление и вставку, поэтому они также блокировали таблицы.
Так в чём была проблема? Когда select_for_update
используется вместе с select_related
, Django пытается заблокировать все таблицы в запросе.
Код, который мы использовали, пытался заблокировать как таблицу транзакций, так и таблицы пользователей, продуктов и категорий. Когда ETC блокировал последние три таблицы в середине ночи, транзакции начинали давать сбой.
Как только мы поняли, в чём суть проблемы, мы стали искать способ заблокировать только нужную таблицу — таблицу транзакций. К счастью, новая опция для select_for_update
как раз стала доступна в Django 2.0:
Используя опцию of
, мы можем явно указать, какие таблицы надо заблокировать; self
— специальное ключевое слово, указывающее, что мы хотим заблокировать модель, с которой мы работаем, в данном случае это Transaction
.
На данный момент эта опция доступна только для PostgreSQL и Oracle.
Индексы внешних ключей
При создании модели Django автоматически создаёт B-Tree индекс для любого внешнего ключа. Эти индексы порой занимают много места и в то же время не всегда нужны.
Классический пример — модель с отношением многие-ко-многим:
В этой модели Django неявно создаст два индекса: один для user
и другой для group
.
Другим типичным поведением в моделях многие-ко-многим является добавление уникальных ограничений на два поля. В нашем случае это означает, что пользователь может быть членом одной группы только один раз:
Кроме того, unique_together
создаст индекс для обоих полей. Таким образом, мы получаем одну модель с двумя полями и тремя индексами.
В зависимости от того, что мы собираемся делать с моделью, во многих случаях мы можем отбросить индексы внешних ключей и оставить только созданный уникальным ограничением:
Удаление лишних индексов сделает вставку и обновление быстрее, кроме того, наша база данных теперь весит меньше, что всегда хорошо.
Порядок столбцов в составном индексе
Индексы с более чем одним столбцом называются составными. В составных индексах В-Tree первый столбец индексируется с помощью древовидной структуры. Из листьев первого уровня создаются деревья для второго уровня и так далее.
Порядок столбцов в индексе очень важен.
В примере выше у нас бы создавалось дерево сначала для групп, а затем для каждой группы ещё по дереву для всех пользователей в группе.
Секрет состоит в том, чтобы в составных индексах B-Tree делать вторичные индексы как можно меньше. Другими словами, столбцы с большей кардинальностью (больше разных значений) должны идти первыми.
В нашем примере разумно предположить, что пользователей больше, чем групп, поэтому столбец с пользователями мы выносим на первый план, чтобы сделать вторичный индекс меньше:
Это всего лишь совет, а не правило, которого нужно обязательно придерживаться. Конечная индексация должна быть оптимизирована в каждом случае соответствующим образом. На что нужно обратить внимание, так это на неявные индексы и важность порядка столбцов в составных индексах.
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 это выглядит так:
При использовании auto_now_add
Django автоматически заполняет поле временем создания записи. Созданное поле зачастую используется в запросах, поэтому его часто индексируют.
Добавим BRIN-индекс:
Чтобы ощутить разницу в размерах, создадим таблицу с ~2 млн записей, где есть отсортированное поле даты:
B-Tree индекс: 37 MB
BRIN индекс: 49 KB
Всё верно, здесь нет никакой ошибки.
При создании индексов следует учитывать не только их размер. Однако теперь, учитывая поддержку индексов с Django 1.11, мы можем легко добавлять новые типы индексов в наши приложения и делать их легче и быстрее.
57К открытий58К показов