Статьи

Clojure в Scale: почему Python просто не хватило для AppsFlyer

Опыт из первых рук и введение в Clojure в масштабе

Clojure, по-прежнему считающийся немного эзотерическим языком, является одним из языков JVM, который вдохновляет нас. Не так много историй о том, почему компании начинают использовать Clojure или как они используют его для построения систем в Scale. Нам повезло услышать отличный пример использования Clojure во время демонстрации средства просмотра ошибок Takipi для Clojure в офисе AppsFlyer, где мы узнали об архитектуре, которая обеспечивает платформу для измерения и отслеживания их мобильных приложений.

В этой статье мы поделимся с вами опытом наших новых друзей из AppsFlyer, Ади Шахам-Шавит, который руководит отделом исследований и разработок, и Рона Кляйна, старшего бэкэнд-разработчика. Перво-наперво, огромное спасибо Рону и Ади, которые обращались к нам за кулисами Clojure на AppsFlyer ! Если у вас есть какие-либо вопросы к ним и вы хотите узнать больше, пожалуйста, используйте раздел комментариев ниже.

Вот их история:

Давайте начнем с некоторых чисел

  • 2 миллиарда событий в день
  • Трафик был удвоен за последние 3 месяца
  • Сотни случаев
  • За прошедший год компания выросла с 6 до 50 человек.
  • 10 Clojure разработчиков
  • Технологии — Redis, Kafka, Couchbase, CouchDB, Neo4j, ElasticSearch, RabbitMQ, Consul, Docker, Mesos, MongoDB, Риман, Hadoop, Secor, Cascalog, AWS

Боли расширения

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

Принимая функциональный подход

По мере накопления подобных трудностей нам пришлось выбирать между двумя вариантами:

  1. Перепишите некоторые из наших сервисов на C (отличная производительность, но не так весело для кода) и оберните их с помощью кода взаимодействия Python (легко сделать)
  2. Перепишите некоторые из наших услуг на языке программирования, более подходящем для обработки данных

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

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

Скала Vs. OCaml Vs. Haskell Vs. Clojure

Scala был вне поля зрения, потому что он представляет собой гибрид объектно-ориентированного программирования и функционального программирования и больше ориентируется на ООП. OCaml был отброшен из-за относительно небольшого сообщества и глобальной блокировки интерпретаторов (GIL), которая позволяет одновременно выполнять только один поток — даже на многоядерных машинах (что также было проблемой для нас в Python). Монады в Хаскеле заставили нас съежиться от страха, поэтому мы остались с Clojure.

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

Clojure — это диалект языка программирования Lisp от Rich Hickey. Это язык программирования общего назначения с упором на функциональное программирование. Как и другие Лиспы, Clojure обрабатывает код как данные и имеет систему макросов. В его центре лежат неизменные значения и явные конструкции прогрессирования времени, предназначенные для облегчения разработки более надежных программ, особенно многопоточных.

Архитектура микросервисов

Серверная часть системы AppsFlyer предназначена для непрерывного получения сообщений (событий), их обработки, хранения и иногда для вызова дополнительных веб-запросов к внешним конечным точкам на их основе. Этот «поток» событий заставил нас принять некоторые архитектурные решения, которые помогли нам масштабироваться по мере необходимости. Одним из основных решений было думать о системе как о наборе сервисов, взаимодействующих в основном через очереди сообщений (ранее через паб / суб Redis, а в настоящее время через Кафку). Это сделало наши услуги независимыми и слабо связанными.

Поток событий

Давайте рассмотрим упрощенный пример: событие «Приложение установлено» публикуется во всей системе через тему (очередь) Kafka под названием «Установки». Наша служба отчетов прослушивает эту тему, чтобы сохранить этот фрагмент данных для соответствующих отчетов. Кроме того, наша служба Postbacks прослушивает эту же тему и по своим собственным правилам решает, вызывать или нет веб-запрос и к какой конечной точке.

Поскольку вся система основана на микросервисах, которые принимают сообщения (и публикуют сообщения) из общего конвейера, их легко переписать на любом языке программирования, предполагая, что в общем конвейере имеется приличная клиентская библиотека. Кафка используется в качестве основной магистрали, а RabbitMQ — для канала реального времени.
Параллелизм в Clojure

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

Clojure также имеет механизмы, которые защищают данные от повреждения. Это, конечно, идет с компромиссом: очень низкая вероятность того, что общий ресурс, содержащийся в потоке A, не будет содержать все изменения, сделанные ранее потоком B. Вообще говоря, Clojure предоставляет хороший механизм неизменяемых структур данных, обеспечивая целостность данных и несколько жертвующая согласованность. Clojure имеет доступ почти ко всему, что может предоставить JVM, поэтому вы все еще можете использовать традиционные блокировки. Однако, если система, которую вы строите, основана на статистике, и вы можете допустить незначительную потерю данных, такую ​​как аналитическая система, которую мы имеем на AppsFlyer, то Clojure более чем достаточно.

Пример из реальной жизни

Скажем, у нас есть сервис, который хранит свое состояние в структуре данных ключ-значение, карте . Карта изначально определяется на уровне модуля как пустая (этот пример упрощен для ясности, поэтому код не написан для повторного использования):

1
2
(def my-map {})
;; Don't panic, you'll get used to the braces...

Приведенное выше утверждение создает пустую карту, доступную по имени my-map .

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

Основной код для добавления ключа «k» со значением «v» к данной карте:

1
(assoc some-map "k" "v")

Этот код не обновляет исходную карту. Clojure сохраняет свои структуры данных как можно более неизменными. Вместо этого приведенный выше оператор возвращает новую копию исходной карты с новым ключом и новым значением. За кулисами Clojure не полностью дублирует всю карту. Вместо этого он сохраняет ревизии с указателями на предыдущие ревизии вместе с различиями. Умный, а ?!

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

1
(def my-map (atom {}))

Этот маленький атом — почти все, что нам нужно, чтобы идти параллельно. Итак, теперь, когда работающий поток «обновляет» my-map (читай: создает новую ревизию), чтобы он также содержал ключ «my-key» со значением 42, код выглядит следующим образом:

1
(swap! my-map assoc "my-key" 42)

Это утверждение меняет my-map так, что теперь оно содержит новую версию себя.

Пока что у нас есть ветка «Обновление» my-map . Чтение карты в Clojure и продолжение предыдущего примера выглядит следующим образом:

1
(get some-map "k")

Вышеприведенное утверждение должно возвращать значение «v.» При работе с атомом Clojure следующий код может быть выполнен, когда поток считывает значение из my-map :

1
(get @my-map "my-key")

Разница лишь в том, что перед моей картой мало «@». Это говорит что-то вроде: «Эй, Clojure, дай мне последнюю версию моей карты ». Как указано выше, последняя, ​​самая последняя версия может содержать не все изменения, внесенные в нашу карту до сих пор, но возвращаемое значение всегда безопасно с точки зрения целостности данных (например, не повреждено).

Вывод

У Clojure есть свой собственный образ мыслей — неизменяемые объекты, синтаксис Lispy и т. Д. Основное преимущество заключается в подходе к параллелизму, сосредоточении на логике приложения и уменьшении накладных расходов на механизмы блокировки. Этот пост покрывает лишь небольшую часть параллелизма Clojure. Мы ощутили значительное повышение производительности, когда перевели AppsFlyer в Clojure. Кроме того, использование функционального программирования позволяет нам иметь действительно небольшую базу кода с несколькими сотнями строк кода для каждого сервиса. Эффекты работы в Clojure значительно ускоряют время разработки и позволяют нам создавать новый сервис за считанные дни.