Django ORM — медленный? Оптимизируем (хардкорно)

в 12:17, , рубрики: django, lazy evaluation, orm, python, оптимизация

Наступила осень, и, несмотря на хорошую погоду, наши зрители потянулись за новым видеоконтентом. Бекенд-серверы, обслуживающие эмбеды с видео, стали упираться в CPU. С криками "а-а-а" прибежали системные администраторы и начали отбирать у отдела разработки ноутбуки и десктопы, грозясь поставить их в датацентры "на усиление". Разработке это конечно всё не понравилось и все поувольнялись с этой хренью решили что-нибудь сделать.

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

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

В результате, для каждого конкретного просмотра необходимо сделать с десяток запросов к MySQL, отформатированный вариант которых не помещается на экране. Запросы идут к небольшим таблицам, выполняются быстро, однако узким местом является формирование таких запросов с использованием Django ORM. Посидев с полчаса с профайлером и посмотрев на результаты, автор выяснил две вещи. Первое — что использование django-rest-framework в настолько сложном API подталкивает программиста к копипасте и повторным вызовам одних и тех же функций в рамках одного запроса. Рекорд — 6 раз разобрать URL реферера на поддомены и сегменты пути. Второе — что внутри Django ORM действительно медленный проходит до половины времени обработки запроса.

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

str.format()

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

SQLAlchemy + Baked Queries

Смена ORM на другой, более быстрый и со встроенной поддержкой кэширования, могла бы стать отличным вариантом, если бы этим заморочились на старте проекта. А так, несмотря на наличие тестов, встает вопрос об эквивалентности переписанного кода. Baked Queries из SQLAlchemy позволяют вместо построения запроса с нуля использовать кэш в памяти процесса, чтобы переиспользовать однажды отформатированный SQL-запрос при повторных вызовах с другими параметрами. Эта идея была одной из наиболее "вкусных", пока не появился вариант, ставший причиной публикации данной статьи.

Кэш для Django ORM

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

queryset = get_some_complex_sql(flag1=True, flag2=True)
sql, params = queryset.query.sql_with_params()
raw_queryset = Model.objects.raw(sql, params=params)

Берем технику memoization, модифицируем так, чтобы в расчет брались не конкретные значения, а их типы, наличие/отсутствие (ну ладно, (True, False, None, 0, 1) можно тоже учитывать). Запоминаем SQL, в случае cache hit подставляем новые значения в RawQuerySet и вот наш кэш готов.

На практике, проблемы начинаются почти сразу. params — это всегда просто кортеж встроенных типов python, и отличить, где в нем flag1, а где flag2 — невозможно. Помимо этого, любой фильтр типа filter(value__in=[1,2,3]) модифицирует SQL-запрос в зависимости от числа переданных в фильтр значений, а это ведет к комбинаторному взрыву ключей кэширования.

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

Мне стыдно за этот код...

@cached(
    'play_qualityrule',
    # локальные поля модели
    licensed=not_null_and_negate('licensed'),
    protected=not_null_and_negate('protected', default=False),
    is_active={'exact': True},
    # QualityRule.rightholders.filter(rightholder_id=...)
    rightholders__rightholder_id=null_or_equal('rightholder'),
    # QualityRule.user_agents.filter(useragent_id=...)
    user_agents__useragent_id=null_or_equal('user_agent'),
    # QualityRule.groups.filter(group=...)
    groups__group_id=null_or_in('group'),
    # QualityRule.alternative_sales_rule.filter(
    #     alternativesalesrule=...)
    alternative_sales_rule__alternativesalesrule_id=null_or_equal(
        'alternative_sales_rule'),
    # QualityRule.users.filter(user_id=...)
    users__user_id=null_or_equal('user'))
def get_filtered_query(self, **kwargs):
    ....

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

Promise и ленивые вычисления

Следующий подход был навеян декоратором django.utils.functional.lazy. Работает он следующим образом.

def compute(param):
    return param ** 2

compute_lazy = lazy(compute, int)

lazy_value = compute_lazy(43)

Если lazy_value передать в Django ORM, то до самого последнего момента вызов функции compute() будет отложен; функция будет вызываться каждый раз, когда будет производиться явное приведение объекта Promise к реальному значению.

Это свойство было задействовано для получения фактических значений параметров из контекст-менеджера.

def lazy_param(name):
    return ContextManager.instance.params[name]

Следующая идея была наивно простой:

  • оборачиваем все параметры функции в lazy-прокси
  • кэшируем QuerySet
  • ...
  • профит!

К сожалению, не сложилось. Оказалось, что Django-ORM для каких-то своих грязных целей производит приведение типов и проверку длин списков еще на этапе конструирования QuerySet: например, в вызове filter(a__in=[1,2,3]) производится проверка на пустое значение, и соответствующая нода, вместо добавления нового условия в WHERE, бросает EmptyResultSet, типа для оптимизации запроса, зараза.

Соответственно, ни для каких ленивых вычислений тут уже места не остается.

Lazy!

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

  • При приведении к str/unicode возвращается LazyUnicode
  • При приведении к int возвращается LazyInt
  • При попытках итерации возвращается итератор с одним элементом — LazyList.

За счет всей этой "лени" удалось довести Lazy-обертки до завершения формирования SQL-запроса, попутно решив проблему с переменным числом placeholder-ов у проверки на вхождение в список.

sql, params = "SELECT * FROM bla WHERE a IN (%s) AND b = %s", ([1,2,3], 4)

placeholders = get_placeholders(params)
sql = sql % placeholders
params = flatten(params)

# SELECT * FROM bla WHERE a IN (%s, %s, %s) AND b = %s", (1, 2, 3, 4)

При подстановке фактических параметров выражение IN (%s) заменяется на IN (%s, %s, %s) с числом плейсхолдеров, соответствующих реальному числу значений в списке, а кортеж params делается плоским.

Теперь код, использующих кэширование, выглядит намного изящнее.

    @cached
    def get_filtered_query(self, **params):
        ...
        return queryset

    def useful_method(self, **params):
        with LazyContext(**params):
            qs = self.get_filtered_query(**params)
            # query database
            return list(qs)

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

Какова цена?

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

@cached
def get_queryset(user):
    if user and user.is_authenticated():
        return Model.objects.exclude(author=user)
    return Model.objects.filter(public=True)

Видно, что SQL-запрос зависит двух фактов:

  • в функцию передан пользователь
  • этот пользователь авторизован.

То, что в функцию передан пользователь, декоратор отследить в состоянии; однако проверять авторизацию не в его компетенции, как, например, и то, что ID этого пользователя является простым числом. Такие случаи ветвлений, основанные на вызовах методов у переданных объектов, на дополнительных запросах к БД и состоянии глобального контекста функции (да хоть datetime.now()) — это всё необходимо выносить за скобки. К счастью, правила для этого достаточно просты:

  • не передавать объекты моделей (только ID),
  • не передавать пустых списков (None вместо них),
  • не использовать значения, которые нельзя вычислить по переданным аргументам.

Пример выше изменяется совсем чуть-чуть.

@cached
def get_queryset(user_id, is_authenticated):
    # проверяем фактическое значение параметра, а не Lazy-обертку на истину
    if reveal(is_authenticated):
        return Model.objects.exclute(author_id=user_id)
    return Model.objects.filter(public=True)

def caller(user):
    if user and user.is_authenticated():
        user_id = user.pk
        is_authenticated = True
    else:
        user_id = None
        is_authenticated = False
    with LazyContext(user_id=user_id, is_authenticated=...):
        qs = get_queryset(user_id, is_authenticated)
        return list(qs)

Вместо заключения

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

  • На старте боевой трафик зеркалируется на отдельный бекенд, с целью сравнить результаты работы оригинальной и модифицированной версии кода
  • В тестах используется "параноидальный" режим работы, когда наряду с "ленивой" реализацией работает "нормальная" версия кода, и если SQL-запросы или их параметры не соответствуют друг другу, это считается ошибкой
  • Более вменяемый режим сверяет эталонную реализацию с ленивой в момент сохранения результата в кэш, а в случае расхождений отключает кэширование
  • Наконец, набор feature-флагов позволяет нам быстро отключить кэш SQL-запросов для конкретного метода, если мы заметим, что код работает неверно.

Что касается эффективности, то на синтетических прогонах ab-шкой одного и того же запроса удалось ускориться с 31 до 44 запросов в секунду. Результат получен во-первых, для специфической бизнес-логики, во-вторых, в дико синтетической ситуации, в-третьих, на первой попавшейся машине. И что немаловажно, с учетом округления, мы получили ускорение на 42%.

Реализация кэша запросов доступна на GitHub.

Автор: Сергей Тихонов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js