Наступила осень, и, несмотря на хорошую погоду, наши зрители потянулись за новым видеоконтентом. Бекенд-серверы, обслуживающие эмбеды с видео, стали упираться в 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.

Комментарии (16)


  1. tumbler Автор
    05.10.2017 15:41

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


  1. ultimatras
    05.10.2017 16:17
    +1

    Если обратиться к лидирующему на данный момент ответ с str.format, чисто вскидку, если это возможно прикинуть, эффективность была бы выше, как вы думаете?


    1. tumbler Автор
      05.10.2017 16:20

      Это вообще похоже наиболее эффективный вариант, круче только прямое обращение к MySQL по HTTP за JSON-ами. Вот только в наших условиях переписать на str.format около тысячи (на вскидку) возможных вариантов SQL-текстов будет затруднительно.


    1. tumbler Автор
      05.10.2017 16:38
      -1

      Приведу еще аргумент против str.format.


      как это вообще переписать возможно?
      SELECT DISTINCT
        "paywall_alternativesalesrule"."id",
        "paywall_alternativesalesrule"."priority",
        "paywall_alternativesalesrule"."is_active",
        "paywall_alternativesalesrule"."name",
        "paywall_alternativesalesrule"."player_stub_id",
        "paywall_alternativesalesrule"."type",
        "paywall_alternativesalesrule"."watch_everywhere",
        "paywall_alternativesalesrule"."provider_id",
        "paywall_alternativesalesrule"."licensed",
        CASE WHEN ("paywall_alternativesalesrule_os"."os_id" IS NULL AND
                   "paywall_alternativesalesrule_subnet"."subnetgroup_id" IS NULL AND
                   ("acl_domain"."name" IN (%s, %s, %s) OR
                    "paywall_alternativesalesrule_allow_domains"."domain_id" IS NULL)
                   AND (NOT (T11."name" IN (%s, %s, %s) AND T11."name" IS NOT NULL) OR
                        "paywall_alternativesalesrule_deny_domains"."domain_id" IS
                        NULL))
          THEN %s
        ELSE %s END AS "is_targeted"
      FROM "paywall_alternativesalesrule"
        LEFT OUTER JOIN "paywall_alternativesalesrule_videos"
          ON ("paywall_alternativesalesrule"."id" =
              "paywall_alternativesalesrule_videos"."alternativesalesrule_id")
        LEFT OUTER JOIN "paywall_alternativesalesrule_os"
          ON ("paywall_alternativesalesrule"."id" =
              "paywall_alternativesalesrule_os"."alternativesalesrule_id")
        LEFT OUTER JOIN "paywall_alternativesalesrule_subnet"
          ON ("paywall_alternativesalesrule"."id" =
              "paywall_alternativesalesrule_subnet"."alternativesalesrule_id")
        LEFT OUTER JOIN "paywall_alternativesalesrule_allow_domains"
          ON ("paywall_alternativesalesrule"."id" =
              "paywall_alternativesalesrule_allow_domains"."alternativesalesrule_id")
        LEFT OUTER JOIN "acl_domain"
          ON ("paywall_alternativesalesrule_allow_domains"."domain_id" =
              "acl_domain"."id")
        LEFT OUTER JOIN "paywall_alternativesalesrule_deny_domains"
          ON ("paywall_alternativesalesrule"."id" =
              "paywall_alternativesalesrule_deny_domains"."alternativesalesrule_id")
        LEFT OUTER JOIN "acl_domain" T11
          ON ("paywall_alternativesalesrule_deny_domains"."domain_id" = T11."id")
      WHERE ("paywall_alternativesalesrule"."is_active" = % s AND
             ("paywall_alternativesalesrule"."licensed" IS NULL OR
              "paywall_alternativesalesrule"."licensed" = % s) AND
             ("paywall_alternativesalesrule_videos"."video_id" IS NULL OR
              "paywall_alternativesalesrule_videos"."video_id" = % s) AND
             ("paywall_alternativesalesrule"."watch_everywhere" = % s OR (
               "paywall_alternativesalesrule_os"."os_id" IS NULL AND
               "paywall_alternativesalesrule_subnet"."subnetgroup_id" IS NULL AND
               ("acl_domain"."name" IN (%s, %s, %s) OR
                "paywall_alternativesalesrule_allow_domains"."domain_id" IS NULL) AND
               (NOT ("paywall_alternativesalesrule"."id" IN
                     (SELECT U1."alternativesalesrule_id" AS Col1
                      FROM "paywall_alternativesalesrule_deny_domains" U1 INNER JOIN
                        "acl_domain" U2 ON (U1."domain_id" = U2."id")
                      WHERE U2."name" IN (%s, %s, %s))) OR
                "paywall_alternativesalesrule_deny_domains"."domain_id" IS NULL))))
      ORDER BY "paywall_alternativesalesrule"."priority" ASC, "is_targeted" DESC


      1. lorc
        05.10.2017 17:07

        Написать хранимую процедуру?

        Кстати, вот такие IN (%s, %s, %s) с переменным количеством %s бьют уже по самой СУБД. Потому что СУБД тоже пытается кешировать планы запросов. А каждое изменение количества параметров в запросе приводит к созданию нового плана запросов и к тому же самом комбинаторному взрыву. Правильнее было бы передавать один параметр — массив значений.


        1. tumbler Автор
          05.10.2017 18:06

          Не кажется ли вам, что трансляция существующей бизнес-логики из Django в хранимые процедуры это, мягко скажем, безумие? Даже если не брать в расчет декодирование idna-доменов и прочие штуки, для которых нет SQL-функций MySQL, получается перенос нагрузки на CPU с бекенда на базу данных.


  1. resetme
    05.10.2017 18:01

    «не передавать объекты моделей (только ID)» — вообще в Python объекты передаются по ссылке поэтому нет смысла передавать айдишники.


    1. tumbler Автор
      05.10.2017 18:03

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


  1. hssergey
    05.10.2017 19:39

    Не думаю, что стоимость получившейся разработки вышла намного дешевле стоимости апгрейда серверов… кроме того, увеличились вероятности ошибок в связи с нестандартным поведением (грубо говоря, где-то забыл передать id, а передал по-старинке объект, тест прошел, а аукнулось это намного позже). Я бы тут больше подумал об оптимизации бизнес логики, возможно там слишком наверчено, что это приводит к таким вот зубодробительнм запросам…

    Хотя тут может быть все решает политика руководства — типа платить за новые сервера дорого и с чего бы это. А программистам вроде как и так зарплата платится, вот пусть чинят свои косяки…


    1. tumbler Автор
      05.10.2017 21:21

      1 человеко-месяц понадобился в данном случае. Это примерно четверть от стоимости одного нового сервера.


  1. ivangermes
    05.10.2017 21:17

    В вашем случае этот метод не приемлемый, но вдруг пробовали…
    Что будет если оптимизнуть сам Python в этом месте, например на PyPy запустить?


    1. tumbler Автор
      05.10.2017 21:20

      Не знаю, поддерживает ли PyPy Django в полной мере сейчас, давно не смотрел в эту сторону. Но перетянуть с полсотни зависимостей на другой интерпретатор будет довольно сложно.


      1. ivangermes
        07.10.2017 20:14

        Да, на таком проекте я бы не рискнул так делать для боевых задач,
        но в познавательных целях было-бы интересно посмотреть на результат.
        На моих задачах подобного рода прирост скорости был примерно в 5 раз. При этом, что удивительно, потребление памяти оказалось в 2 раза меньше, чем из под CPython.


  1. baldr
    06.10.2017 10:29
    +1

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

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

    Тут еще вопрос что будет быстрее… Я бы лучше оставил один плейсхолдер, а параметр передавал как
    ",".join([str(i) for i in ids])
    . Быстрее чем подставлять плейсхолдеры в строке. Минус — вы теряете проверку на безопасность аргументов, но если они не напрямую переданы с веба, то почему бы и нет?

    однако узким местом является формирование таких запросов с использованием Django ORM

    После беглого взгляда на реализацию возникает вопрос — а сильно ли меньше кода получилось со всеми этими Lazy классами? Метрикам верю, но не сложнее ли вышло?


    1. tumbler Автор
      06.10.2017 11:47
      +1

      Насчет Class-Based Views. Декоратор навешивается на метод менеджера модели, хотя это не обязательно. Получается, что всё кэширование остается на уровне работы с моделями, не задевая вьюшки и остальной код. Таким образом почти всё, касающееся Manager.self, можно объявить безопасным в плане учета в ключе кэширования. Понятно, что накосячить можно везде, от всего не защитишься.


      Насчет замены в выражении IN проще показать код (и да, мы передаем в запрос разобранное значение заголовка реферер — так что str не катит).


      Всё крайне просто
      def normalize(sql, params):
          placeholders = []
          real_params = []
          for p in map(reveal, params):
              if isinstance(p, (list, tuple)):
                  # IN(%s) -> IN(%s,%s,%s)
                  placeholders.append(', '.join(['%s'] * len(p)))
                  real_params.extend(p)
              else:
                  placeholders.append('%s')
                  real_params.append(p)
          return sql % tuple(placeholders), tuple(real_params)


  1. user-vova
    09.10.2017 11:46

    Может и не уместно, но не могу промолчать. У джанги не только ОРМ медленный, и шаблонизатор, и даже простой json другие фреймворки отдают быстрее. Модульная архитектура быстрее, функциональнее, гибче. Flask, bottle, Jinja2, Babel, Beaker, WTForms и т.д. и т.п. А если без ОРМ совсем никак, то SQLAlchemy. Печально, что в вебе такой язык как питон в первую очередь ассоциируется с джангой. Сугубо ИМХО.