Ранее мы уже рассказывали о том, что по мере роста нагрузки постепенно ушли от использования Python в бэкенде критичных сервисов на продакшене, заменив его на Go. А сегодня я, Денис Гирько, тимлид команды разработки Madmin, хочу поделиться деталями: как и почему это происходило на примере одного из важнейших для нашего бизнеса сервисов — расчета цены с учетом скидок по купонам.



Механику работы с купонами представляет, наверное, каждый, кто хотя бы раз совершал покупки в интернет-магазинах. На специальной странице или прямо в корзине ты вводишь номер купона, и цены пересчитываются в соответствии с обещанной скидкой. Расчет зависит от того, какую именно скидку предоставляет купон — в процентах, в виде фиксированной суммы или с использованием какой-то иной математики (у нас, например, дополнительно учитываются баллы программы лояльности, акции магазина, типы товаров и т.п.). Естественно, заказ оформляется уже с новыми ценами.

Бизнес в восторге от всех этих механизмов работы с ценами, но мы хотим поговорить о сервисе с несколько иной точки зрения.

Как это работает


За расчет цен с учетом всех этих сложностей на бэкенде сейчас у нас отвечает отдельный сервис. Однако самостоятельным он был не всегда. Сервис появился через год или два после начала работы интернет-магазина, и к 2016 году это была часть большого монолита на Python, включавшего самые разнообразные компоненты для маркетинговой активности (Мадмин). В самостоятельный «блок» он выделился позже, по мере движения в сторону микросервисной архитектуры.

Как это обычно и бывает с монолитами, Madmin видоизменялся и частично переписывался большим числом разработчиков. Туда интегрировались сторонние библиотеки, которые упрощали разработку, но зачастую не самым лучшим образом сказывались на производительности. Однако на тот момент мы не особо заботились об устойчивости к большим нагрузкам во время распродаж, поскольку сервис отлично справлялся с поставленной задачей. Но 2016 год все изменил.



В США «Черная пятница» известна с 60-х годов прошлого века. В России ее начали запускать в 2010-х, при этом акцию пришлось фактически создавать с нуля — рынок был к ней не совсем готов. Однако усилия организаторов не прошли зря, и с каждым годом пользовательский трафик на наш сайт в дни распродаж увеличивался. А поэтому наше столкновение с нагрузкой, непосильной для той версии сервиса расчета цены, было лишь вопросом времени.

«Черная пятница» 2016. И мы ее проспали


С тех пор, как идея распродажи заработала в полную силу, от любого другого дня в году «черная пятница» отличается тем, что к полуночи в магазин приходит примерно недельная аудитория сайта. Это сложный период для всех сервисов. Даже в тех из них, которые бесперебойно функционируют в течение всего года, порой вылезают проблемы.

Теперь к каждой новой «черной пятнице» мы готовимся, имитируя ожидаемую нагрузку,  но в 2016 году мы еще поступали иначе. Тестируя Мадмин перед важным днем, мы проверяли устойчивость к нагрузкам, используя сценарии поведения пользователей в обычные дни. Как выяснилось, тест этот не совсем отражает реальную ситуацию, поскольку в «черную пятницу» приходит множество людей с одним и тем же купоном. В результате сервис расчета цены с учетом этой скидки, не справившись с трехкратной (по сравнению с обычными днями) нагрузкой, в самый жаркий пик распродажи блокировал нам возможность обслуживать клиентов в течение двух часов.

Сервис «лег» за час до полуночи. Все началось с обрыва подключения к БД (на тот момент — MySQL), после которого не все запущенные копии сервиса расчета цены смогли подключиться обратно. А те, что все-таки подключились, не выдержали пришедшей нагрузки и перестали отвечать на запросы, застряв на блокировках базы.

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

По мере разбирательств начали открываться подробности о том, насколько неоптимально работал сервис. К примеру выяснилось, что для расчета одного купона делалось 28 запросов в базу (не удивительно, что все работало со 100% загрузкой CPU). Упомянутые выше пользователи с одним и тем же купоном «черной пятницы» не упрощали ситуацию, тем более тогда для всех купонов у нас существовал счетчик применений — так что каждое использование увеличивало нагрузку, обращаясь к этому счетчику.

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


Итоги Black Friday 2016

«Черная пятница» 2017. Мы готовились серьезно, но...


Получив хороший урок, к следующей «черной пятнице» мы готовились заранее, серьезно перестроив и оптимизировав сервис. Например, мы наконец-то создали два типа купонов: лимитные и безлимитные — чтобы избежать блокировок на одновременном доступе к базе, мы убрали из сценария применения популярного купона запись в базу. Параллельно за 1 — 2 месяца до «черной пятницы» мы в сервисе перешли с MySQL на PostgreSQL, что вместе с оптимизацией кода дало сокращение количества обращений к базе с 28 до 4 — 5. Эти усовершенствования позволили дотянуть сервис на тестировании до требований SLA — ответ за 3 секунды по 95 перцентилю при 600 RPS.

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

В день «ЧП» с приходом основного потока покупателей нагрузка на сервис начала расти в геометрической прогрессии. Некоторые запросы обрабатывались до двух минут. Из-за долгой обработки одних запросов росла нагрузка на другие воркеры.

Нашей главной задачей было обслужить такой ценный для бизнеса трафик. Но стало очевидно, что «забрасывание железом» не решает проблему и с минуты на минуту количество занятых воркеров достигнет 100%. Не зная тогда, с чем именно мы столкнулись, приняли решение активировать harakiri в uWSGI и просто прибивать длинные запросы (которые обрабатываются более 6 секунд), чтобы освободить ресурсы для нормальных. И это действительно помогло устоять — воркеры стали освобождаться буквально за пару минут до их полного исчерпания.

Чуть позже мы разобрались в ситуации…  Выяснилось, что это были запросы с очень большими корзинами — от 40 до 100 товаров — и со специфическим купоном, имеющим ограничения на ассортимент. Именно эта ситуация плохо отрабатывалась новым кодом. В нем обнаружилась некорректная работа с массивом, которая превращалась в бесконечную рекурсию. Любопытно, что кейс с большими корзинами мы тогда тестировали, но не в сочетании с хитрым купоном. В качестве решения мы просто переключились на другую версию кода. Правда, произошло это часа за три до конца «черной пятницы». С этого момента все корзины начали обрабатываться корректно. И хотя план по продажам мы на тот момент выполнили, глобальных проблем из-за нагрузки, впятеро превышающей обычный день, мы избежали чудом.

«Черная пятница» 2018


К 2018 году для высоконагруженных сервисов, обслуживающих сайт, мы постепенно начали внедрять Go. Учитывая историю предыдущих «черных пятниц», сервис расчета скидок был одним из первых кандидатов на переработку.



Конечно, мы могли сохранить уже «проверенную в бою» версию Python, а перед новой «черной пятницей» заняться отключением тяжелых библиотек и выкидыванием неоптимального кода. Однако Golang к тому моменту уже прижился и выглядел более перспективным.

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

В ходе тестирования выяснилось, что слабым местом с точки зрения высоких нагрузок у нас остается база. Слишком длительные транзакции приводили к тому, что мы выбирали весь пул коннектов, и запросы стояли в очереди. Так что нам пришлось немного переделать логику работы приложения, сократив использование базы до минимума (обращаясь к ней только тогда, когда без этого никак) и закешировав справочники из БД и данные по популярным в «черную пятницу» купонам.

Правда, в этом году мы ошиблись с прогнозами нагрузки в большую сторону: готовились к 6-8 кратному росту в пиках и добились хорошей работы сервисов именно для такого объема запросов (добавили кеши, заранее отключили экспериментальные функции, упростили некоторые вещи, развернули дополнительные ноды Kubernetes и даже серверы БД для реплик, которые в итоге не потребовались). На деле всплеск пользовательского интереса был меньше, так что все прошло в штатном режиме. Время ответа сервиса не превышало 50 мс по 95 перцентилю.

Для нас одна из важнейших характеристик — то, как приложение масштабируется при нехватке ресурсов одной копии. Go эффективнее расходует аппаратные ресурсы, поэтому при той же нагрузке требуется запускать меньше копий (в конечном счете обслуживая больше запросов на тех же аппаратных ресурсах). В этом году в самый пик распродажи работало 16 экземпляров приложения, которые обрабатывали в среднем 300 запросов в секунду с пиками до 400 запросов в секунду, что примерно в два раза выше обычной нагрузки. Отмечу, что в прошлом году сервису на Python потребовалось 102 экземпляра.

Казалось бы, сервис на Go с первого подхода закрыл все наши потребности. Но Golang — не «универсальное решение всех проблем». Здесь не обошлось без некоторых особенностей. К примеру, нам пришлось ограничить количество потоков, которые может запустить сервис на многопроцессорной ноде Kubernetes, чтобы при масштабировании не мешать «соседним» приложениям на продакшене (по умолчанию у Go нет ограничений на то, сколько процессоров он займет). Для этого во всех приложениях на Go мы задали GOMAXPROCS. Будем рады комментариям о том, насколько это было полезно — в нашей команде это была лишь одна из гипотез относительно того, как следует бороться с деградацией «соседей».

Еще одна «настройка» — количество соединений, которые удерживаются как Keep-Alive. Штатные клиенты http и БД в Go по умолчанию удерживают только два соединения, поэтому если есть много конкурентных запросов, и нужно экономить на трафике установки TCP-соединения, имеет смысл увеличить это значение, задав MaxIdleConnsPerHost и SetMaxIdleConns соответственно.

Однако даже с учетом этих ручных «докручиваний» Golang обеспечил нам большой запас по производительности на будущие распродажи.

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


  1. jehy
    25.12.2018 11:44

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

    Ни разу не спорю, что Go крут для тяжёлых вычислений под нагрузкой, но… Насколько я понял, микросервис представляет собой простой API с несколькими запросами в СУБД и скромной математикой. Так что тяжёлых вычислений там просто нет…


    1. livinbelievin Автор
      25.12.2018 12:13

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

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


      1. jehy
        25.12.2018 12:20

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


        1. livinbelievin Автор
          25.12.2018 12:27

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


  1. polyanin
    25.12.2018 11:54

    Расскажите пожалуйста по подробнее вот про это место: «перешли с MySQL на PostgreSQL, что вместе с оптимизацией кода дало сокращение количества обращений к базе с 28 до 4 — 5».
    Какие преимущества PostgreSQL вы применили?


    1. livinbelievin Автор
      25.12.2018 12:17

      Основными причинами перехода были стремление к единообразию (почти все сервисы, которым нужна база, у нас используют PG) и наличие экспертизы в поддержке этой базы. В Мадмине, насколько я помню, ничего из фич, присущих только PG, не используется.


  1. SirEdvin
    25.12.2018 12:05

    В этом году в самый пик распродажи работало 16 экземпляров приложения, которые обрабатывали в среднем 300 запросов в секунду с пиками до 400 запросов в секунду, что примерно в два раза выше обычной нагрузки. Отмечу, что в прошлом году сервису на Python потребовалось 102 экземпляра.

    У вас python приложение было написано на каком-то django или flask, верно?


    1. livinbelievin Автор
      25.12.2018 12:13

      На django, ага.


      1. magic4x
        25.12.2018 23:50

        Т.е. вы сравниваете синхронную джангу с го. Ооокей.


        1. mcsseifer
          26.12.2018 01:29

          Скорее мы сравниваем варианты решения бизнесовой задачи


  1. KirEv
    25.12.2018 12:24
    +1

    все ждал увидеть примеры кода, или скл-запросы… «было-стало»…
    … ато получается:
    у нас был проект на питоне+мускл, и тормозил, переписали часть на го+постгрес — и больше не тормозит… пишите на го :)


  1. noize
    25.12.2018 13:30

    Расскажите, какие Go-шные либы используете в проекте


    1. livinbelievin Автор
      25.12.2018 13:58

      Да, с удовольствием:



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


      1. noize
        25.12.2018 14:12

        А как насчёт http сервера? Вроде как выше писалось, что Go-шное приложение вместо джанги воткнули.


        1. livinbelievin Автор
          25.12.2018 14:18

          А, я думал, вы спросили про сторонние либы. Так да, используем штатный HTTP-сервер.


          1. noize
            25.12.2018 14:31

            Спасибо. Просто, на мой взгляд, штатный http сервер не особо удобен в использовании. Думал, что вы запускаете на чём-то стороннем, наподобие chi или gin.


  1. bigtrot
    25.12.2018 15:02

    Скажите вы использовали, какие-нибудь балансировщики нагрузки для базы данных, например pgbouncer, pgpool


    1. livinbelievin Автор
      25.12.2018 15:09

      Да, мы используем pgbouncer в режиме пулинга транзакций. Кстати, если вы тоже, и используете lib/pq для подключения к базе из Go, то обратите внимание на параметр binary_parameters=yes, его использование — один из способов избежать проблем с баунсером в lib/pq.


      1. Vanadium
        26.12.2018 01:29

        А каких проблем, расскажите пожалуйста?


        1. DrAndyHunter
          26.12.2018 08:04

          Удваиваю. Очень хочется знать, какие проблемы возникли с баунсером и lib/pq?


          1. livinbelievin Автор
            26.12.2018 10:41

            Ответил чуть ниже.


        1. livinbelievin Автор
          26.12.2018 10:41

          Если в двух словах, то lib/pq неявно делает prepared statements, которые не поддерживаются pgbouncer в режиме пулинга транзакций.

          Случается это, если делать вызов с параметрами, например, такой:

          _, err := db.Exec("SELECT $1::int + $2::int", 1, 2)
          

          Что мы увидим в логе БД?

          LOG: duration: 0.402 ms parse <unnamed>: SELECT $1::int + $2::int
          LOG: duration: 0.123 ms bind <unnamed>: SELECT $1::int + $2::int
          LOG: execute <unnamed>: SELECT $1::int + $2::int

          Использование prepared statements — это нормально, говорит контрибьютор, это способ lib/pq подставить значение вместо $-плейсхолдеров.

          Что же делает binary_parameters, и почему он помогает для работы через pgbouncer?

          Без binary_parameters=yes lib/pq сначала создает prepared statements, затем дожидается ответа от базы, чтобы узнать типы, в которые нужно скастовать параметры, и только потом отправляет их значения вместе с командой выполнить запрос. Такой двойной поход по сети не только делает само исполнение запроса дольше, но и оставляет шанс того, что pgbouncer выполнит PREPARE и EXECUTE в разных сессиях и мы получим что-нибудь вроде ERROR: prepared statements does not exist.

          Со включенным binary_parameters=yes, lib/pq использует возможность PG-протокола передавать параметры в «бинарном» виде, при котором ему больше не нужно заранее узнавать их тип. Поэтому пропадает нужда делать два похода в базу, и pgbouncer, как показывает наша практика и отзывы других людей, всегда коммутирует всю цепочку вызовов в одно и то же соединение.


  1. IvankoPo
    25.12.2018 16:31

    Скажите пожалуйсто сколько экземпляров БД использовалось, и как распределялась нагрузка?


    1. livinbelievin Автор
      25.12.2018 17:09

      Если коротко, то всего один.

      Если развернуть, то с самого начала мы не закладывали для этого сервиса деление запросов на читающие/пишущие, потому что, по нашим прогнозам, нагрузка не планировалась такой, чтобы один экземпляр (мастер) не справился бы.

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


      1. bat
        26.12.2018 08:47

        Мы пошли по второму пути, закэшировав некоторые результаты запросов в памяти приложения

        почему это не было сделано раньше?
        кеширование выборок это первое что приходит в голову, когда бд становится узким местом


        1. livinbelievin Автор
          26.12.2018 10:42

          БД в go-приложении стало узким местом на нагрузочных тестах. Обнаружили, оценили риски, сделали оптимизацию.


          1. bat
            26.12.2018 11:59

            хм, мне показалось что фраза про 28 запросов на купон относилась к питонячей версии

            ps
            а насколько много в бд оперативных данных? можно ли все загрузить в рам и в бд лазить только на запись?


            1. livinbelievin Автор
              26.12.2018 12:18

              А, 28 запросов — это действительно про питон. Увы, я не смогу уверенно сказать, почему тогда приняли такое решение, а не иное.

              Да, потенциал для кэширования в этом сервисе довольно большой. Как и писал выше, примерно 50% трафика к базе удалось срезать за счет in-memory-кэша. В базу остались походы за тем, что нельзя кэшировать — например, скидки с ограниченным кол-вом применений, и то, что плохо кэшируется — например, персональные скидки пользователя.


  1. melesik
    25.12.2018 17:22
    +2

    Тоже не понял, причём тут Go. На Perl можно всё сделать.


  1. Tercel
    25.12.2018 18:21

    А я не понимаю, почему у кого-то возникают сомнения в том, что статический нативный язык потребляет меньше ресурсов, чем динамический интерпретируемый.


    1. livinbelievin Автор
      25.12.2018 18:29

      Согласен, это должно быть интуитивно. Другой вопрос — насколько меньше? На него статья ответа не дает, потому что наш кейс уж очень радикальный: мы переезжали не просто с Python, а с django-приложения, которое еще и с багажом фич, которые появлялись, переставали использоваться, но оставались жить в коде.


      1. VanquisherWinbringer
        25.12.2018 21:12

        Ну вообще вот ребята соревнуются — тык. Пока что решение на Java в 2 раза быстрее чем на Go. Почему в сторону Java/C# не смотрели?
        Ну и еще, ну тут чистая синтетика — тык


        1. dimack
          25.12.2018 21:23

          там ребята соревнуются не сколько в языках, сколько в алгоритмах


      1. Tercel
        25.12.2018 22:38

        Присоединяюсь к вопросу, почему не Java. Сейчас уже можно и в native через Graalvm.


        1. livinbelievin Автор
          26.12.2018 10:47

          У меня скучный, но правдивый ответ: потому что мы умеем Go :)


  1. calg0n
    26.12.2018 01:04

    Скажите го сервис имеет общую базу с питоном? Если нет, то как разделили?


    1. livinbelievin Автор
      26.12.2018 10:53

      Да, go-сервис использует ту же базу, которую ранее использовал Python.

      Более того, django мы не выкинули, потому что кроме API для расчета скидок она была еще и развесистой админкой для конфигурирования акций. Роль API перешла от питона к go, а админка осталась на django, и они используют одну и ту же базу.

      Если интересно, могу подискутировать на тему плюсов/минусов такого подхода.