Эффективная работа с ORM в Django

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

1. Избегайте лишних запросов в циклах

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

# Запрашиваем имена документов для последующего просмотра.
names = Document.objects.values_list('name', flat=True)[0:5000]

# Медленный способ получения списка документов с заданными именами.
# Будет выполняться больше минуты.
documents = []
for name in names:
    documents.append(Document.objects.get(name=name))

Вместо того, чтобы в цикле генерировать по одному SQL-запросу на каждый элемент names, эффективнее выполнить всего один запрос, затрагивающий все элементы names:

# Код отработает очень быстро.
names = Document.objects.values_list('name', flat=True)[0:5000]
documents = list(Document.objects.filter(name__in=names))

На чистом SQL это будет примерно так:

SELECT * FROM model WHERE fieldname IN ('1', '2', ...)

2. Избегайте зависимых подзапросов

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

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

Предположим, у нас есть два следующих класса:

class Owner(models.Model):
    name = models.CharField(max_length=50)

class Vinyl(models.Model):
    name = models.CharField(max_length=50)
    owner = models.ForeignKey(Owner)

Зависимый подзапрос, который отработает неэффективно:

all_vinyls = Vinyl.objects.filter(owner__in=Owner.objects.filter(name="ElegantCode"))

Оптимизированный вариант подзапроса:

owner_ids = list(Owner.objects.filter(name="ElegantCode").values_list("id", flat=True))
all_vinyls = Vinyl.objects.filter(owner__id_in=owner_ids)

3. Выбирайте только необходимые поля

В большинстве случаев программисту не требуется объект целиком, достаточно 1-2 полей. В таком случае уместно использовать метод only():

# Эквивалент для SELECT id FROM myapp_vinyl;
Vinyl.objects.all().only('id')
Vinyl.objects.only('id').get(id=10)

В некоторых местах этот прием ускорит выполнение запроса.

4. Сохраняйте только необходимые поля

При изменении хотя бы одного поля Django пересохраняет все поля. Такое поведение не всегда уместно.

# Эквивалент для UPDATE myapp_vinyl SET url='http://www.google.com' WHERE id = 10;
vinyl = Vinyl.objects.get(id=12)
vinyl.url = 'http://www.the-scorpions.com'
# vinyl.save() -> наивный способ, обновит все поля
vinyl.save(update_fields=['url'])    # эффективный способ, будет обновлено только одно поле

5. Сохраняйте множества объектов одним запросом

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

to_insert = []
for vinyl in vinyls:
  if needs_saving:
    to_insert.append(vinyl)  # vinyl - словарь

Vinyl.objects.bulk_create([
    Vinyl(**vinyl) for vinyl in to_insert
])

У этого способа есть некоторые ограничения. Например, метод модели save() не будет вызван, и сигналы pre_save и post_save не сработают. С подробностями можно ознакомиться в документации Django.

6. Обновляйте множества объектов одним запросом

В духе bulk_create, массовое обновление также ускорит ваш код:

ids_to_update = []
# Код, который заполяет список ids_to_update
Vinyl.objects.filter(id__in=ids_to_update).update(failed=True)

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

7. Обеспечивайте атомарность SQL-запросов с помощью транзакций

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

# Декоратор
@transaction.atomic
def run_multiple_queries:
  pass

# Менеджер контекста
with transaction.atomic():
  # выполнить запросы
  pass

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

8. Убедитесь, что поля, по которым выполняется поиск, проиндексированы

Используйте параметр поля db_index=True.

class AccessToken(models.Model):
    token = models.CharField(max_length=255, db_index=True)
    user_id = models.IntegerField()
    permission_level = models.IntegerField()
# Пример запроса
AccessToken.objects.filter(token="UAEKASDKLJGQWJKHWESDFKJL")

Индекс хранится в B-дереве, поэтому token будет найден за логарифмическое время — O(log(n)). Если бы у вас был миллиард элементов, поиск token занял бы столько времени, сколько занимает линейный поиск 30 элементов.

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

9. Используйте select_related() и prefetch_related()

Метод select_related() возвращает QuerySet, который автоматически включает в выборку данные связанных объектов при выполнении запроса. При доступе к связанным объектам через модель не потребуются дополнительные запросы к базе данных. Удобно использовать для отношений «один-ко-многим» или «один-к-одному».

Метод prefetch_related() возвращает QuerySet, который получает за один подход связанные объекты для каждого из указанных параметра поиска. Удобно использовать для отношений «многие-к-одному» и «многие-ко-многим».

Пример:

# допустим, у нас 100 пластинок
vinyls = Vinyl.objects.all().select_related('artist')

{% for vinyl in vinyls %}
  {{ vinyl.artist.name }}  # без select_related на каждой итерации будет выполнен лишний запрос
{% endfor %}

Так, с select_related() к БД будет осуществлен 1 запрос, а без select_related() — 101 запрос.

Выводы

ORM замечателен тем, что предоставляет разработчику возможность манипулировать данными на уровне объектов. Абстрагироваться от реализации, писать красивый, удобный и качественный объектно-ориентированный код, упрощать тестирование. Но, к сожалению, не во всех случаях ORM предоставляет оптимальный SQL-код, что, разумеется, сказывается на производительности проекта. В этой статье я еще раз поднял тему известных приемов оптимизации ORM-запросов в Django, которые сводятся к следующим практикам:

  1. Профилируйте запросы. Контролируйте SQL, который предоставляет ORM. Обращайте внимание, какие запросы и сколько раз выполняются, насколько быстро это происходит.
  2. Индексируйте поля поиска. Хорошая практика — по результатм профилирования запросов добавлять необходимые индексы.
  3. Не получайте данные, которые вам не нужны, а также не пересохраняйте то, что не изменилось.
  4. Используйте методы пакетной обработки для случаев, где это применимо (вставка, обновление данных).
  5. Где уместно, включайте в выборку связанные данные, чтобы уменьшить количество запросов к базе данных, например, в циклах.