Всем привет! В своей статье я поделюсь опытом разработки одного из финансовых приложений на Java в ТехЦентре Дойче Банка, расскажу про стек технологий, который мы используем, и каких результатов достигаем.


Предметная область

Наш проект разрабатывается для рынка электронного обмена валют. Большинство людей знакомо с обменниками валют: у нас есть российские рубли, мы планируем (планировали?) поехать в отпуск и хотим заранее обменять рубли на местную валюту, например, доллары или евро. Для этого мы берем пачку денег с рублями, идем в обменник, который предложит наиболее выгодный курс обмена, и, собственно, меняем рубли на иностранную валюту по курсу, который они нам предлагают (или то же самое проделываем через интернет в онлайн обменниках). Просто и удобно… для обычных обывателей.

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

Инвестбанки как раз и предоставляют специальные инструменты, которые позволяют заключать контракты на обмен валют в будущем по уже известному зафиксированному курсу.  Такие контракты называются форвардными. И в отличие от обмена одной валюты на другую по рыночному курсу здесь и сейчас, форвардные контракты несут под собой риски для банка. Например, если фактический курс значительно уйдет в сторону от курса, который был зафиксирован в контракте, то банк может понести значительные убытки. Метрика, которая учитывает такие риски, называется Potential Future Exposure (PFE). Есть еще Credit Valuation Adjustment (CVA), которая учитывает вероятность банкротства клиента на момент исполнения контракта, или Capital Valuation Adjustment (KVA), учитывающая стоимость капитала, который банк должен будет зарезервировать, если клиент заключит сделку.

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

Наш проект как раз и занимается расчетом кредитных метрик для FX рынка.

Что было до нас

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

Latency на обслуживание одного запроса была около 600 ms. Причинами тому были:

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

  • Частое взаимодействие с внешними источниками данных.

  • Как следствие, allocation rate – 500 MB/sec.

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

Что у нас в продакшене сейчас

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

  • Latency 4 ms на request-response.

  • Allocation rate – 10 MB/sec.

  • Размер хипа одного процесса – 20 GB.

  • Рассчитываем 6 разных вариантов метрик для одного запроса.

  • Типичная нагрузка на один процесс – 50 req/sec.

Основной стек это:

  • Java 11 на OpenJDK.

  • G1 для сборки мусора.

  • Spring 4 (планируем перейти на 5-й).

  • gRPC + protobuf для взаимодействия с клиентами.

  • Log4j2 для логирования.

И ключевая концепция, которая помогает нам показывать хорошую производительность и выпускать качественный код, – это модель акторов и фреймворк Akka, который её имплементирует.

Что мы считаем

Если представить наши расчеты в общем виде, то они выглядят так:

На входе есть набор матриц размером 60x1000 элементов с типом double. Каждая матрица представляет собой параметры расчета. Например, одна из матриц – это 1000 вариантов симуляций курса одной из валют на 60 различных дат в будущем. Остальные матрицы — это также симуляции, которые приходят к нам как статические данные в начале дня и зависят от типа метрики, которую мы считаем. 

Далее набор матриц по определенным правилам в зависимости от типа метрики «схлопывается» в одну матрицу, дальше матрица приводится к вектору, и вектор – к одному числу, в случае если мы считаем метрики CVA или KVA. Для PFE на выходе будет вектор из чисел.

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

Расчетное ядро

Если расчеты представить в виде кода, то основное расчетное ядро будет выглядеть очень просто:

for (int i = 0; i < 60; i++)
    for (int j = 0; j < 1000; j++)
        …

Основной цикл по 60-ти датам и вложенный цикл через 1000 симуляций. А внутри циклов выполняются расчеты, которые зависят от типа метрики.

Если запустить такой расчет для одной из наших метрик в один поток, то время выполнения будет 1,5 ms.

Задача нашего сервиса:

  • имплементировать расчетные ядра для различных метрик;

  • предоставить клиентский интерфейс к расчетному ядру и организовать механизм клиентских подписок;

  • организовать сбор статических данных для расчетов;

  • обеспечить перерасчет метрик и нотификацию клиентов в случае изменения статических данных.

И основные нефункциональные требования:

  • latency на стороне клиента меньше 10 ms на один запрос;

  • надежность и стабильность сервиса.

Взаимодействие компонентов внутри сервиса

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

  • обслуживание клиентских подписок;

  • обор и подготовку статических данных;

  • расчеты кредитных метрик.

Наиболее простой и прямолинейный метод организации взаимодействия компонентов внутри сервиса — это синхронный подход. При таком подходе последовательность действий будет следующая:

  1. В сервис приходит клиентская подписка.

  2. На отдельную подписку создается отдельный программный поток, который будет её обслуживать.

  3. Внутри потока вызываются методы из модуля, отвечающего за статику, и подготавливается контекст для расчетов.

  4. Созданный контекст внутри того же потока передается в модуль с расчетами и выполняется расчет одной метрики.

  5. Повторяем п.4 для каждой метрики.

Как я уже писал, нам нужно посчитать в среднем 6 метрик, т. е. при подходе один поток – одна подписка, время между запросом и ответом будет 6 * 1,5 ms = 9 ms, но это без учета сетевого latency (которое у нас будет 2 ms) и накладных расходов на взаимодействие компонентов и сбор статики. Очевидно, что такой подход не позволит нам уложиться в заданные 10 ms.

Как вариант оптимизации, расчеты разных метрик можно выполнять в разных потоках. 6 метрик – 6 потоков. Тогда, если у нас всего одна подписка, мы сможем выполнить все расчеты за 1,5 ms и уложиться в заданные критерии по latency. Еще, например, можно распараллелить сам расчет при помощи ForkJoinPool (мы, кстати, так и делаем). Но что будет, если в систему придет одновременно 50 клиентов? Можно подбирать разные наборы потоков для разных целей до бесконечности, но лучше сразу заложить в архитектуру асинхронную модель взаимодействий.

При асинхронном подходе:

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

  • Когда результат готов, один компонент нотифицирует другой о готовности результата.

  • Результат обрабатывается в вызывающем модуле.

Особенность такого подхода в том, что мы можем выделять разные пулы потоков, которые будут обслуживать разные модули. Например, на подготовку статики мы можем выделить 5 потоков, на обслуживание подписок 10 потоков, на расчеты 12, т. е. можем сконфигурировать систему так, чтобы ресурсы сервера использовались наиболее эффективно. Проблема кроется в том, как имплементировать такую модель на практике.

Её можно имплементировать, используя только стандартные механизмы Java, например, CompletableFuture с параметром executor для вызываемых методов, в котором будем передавать пул потоков и на котором должна быть выполнена задача.

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

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

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

Давайте посмотрим, что из себя представляет актор.

Модель акторов

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

На приведенном рисунке изображен актор для выполнения расчетов – CalculationActor. Такой актор принимает на вход сообщения типа CalculationMessage, которые содержат в себе всю необходимую информацию для расчетов. В ответ CalculationActor посылает ResultMessage с результатами расчетов.

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

Такая, казалось бы, простая концепция позволяет выстраивать достаточно сложные системы:

Так в упрощенном виде выглядят компоненты нашей системы. Есть SubscriptionsActor, который отвечает за прием входящих соединений. Есть SubscriptionActor’ы, обслуживающие подписки. SubscriptionActor’ы собирают данные из StaticDataActor. Когда данные для расчетов готовы, отправляются сообщения в CalculationActor’ы.

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

Akka

Если перейти к конкретной реализации модели акторов, например, фреймворку Akka, который мы используем в своем проекте, то конфигурирование пулов потоков вынесено в отдельный конфигурационный файл.

Давайте посмотрим, что скрыто за актором:

У актора есть пул потоков (а у пула потоков – набор акторов), который его обслуживает, и mailbox – ящик для входящих сообщений. По сути, это очередь, например, ConcurrentLinkedQueue. Когда в mailbox приходит сообщение, свободный поток в пуле достает одно сообщение из mailbox и выполняет код по его обработке внутри актора.

Предположим, что актор 1 обслуживается пулом потоков 1, а актор 2 – пулом потоков 2:

Если актор 1 хочет послать сообщение актору 2, то актор 1 вызывает метод tell у актора 2 на одном из потоков из пула 1. Когда в пуле потоков 2 появляется свободный поток, то он достает сообщение из mailbox и выполняет его обработку внутри актора 2.

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

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

У Akka достаточно хорошая документация. Для быстрого старта про создание и запуск акторов можно почитать, например, здесь, а про конфигурирование пулов потоков (или в терминах Akka – Dispatcher) здесь. Но, к сожалению, найти работающие готовые примеры не так просто, поэтому давайте посмотрим, как будет выглядеть приложение с двумя акторами на Java.

Простое приложение с использованием Akka

ActorSystem system = ActorSystem.create();
ActorRef calculationActor = system.actorOf(CalculationActor.props(), "calculation-actor");
ActorRef clientActor = system.actorOf(ClientActor.props(calculationActor), "client-actor");

В первой строке мы создаем ActorSystem, которая будет управлять экосистемой акторов. Затем создаем два актора, которые будут обмениваться сообщениями. Обратите внимание на имена акторов: calculation-actor и client-actor. Далее мы будем ссылаться на них в конфигурационных файлах.

Код CalculationActor выглядит так:

public class CalculationActor extends AbstractActor {
    // Используется специальный акковский логгер, но под ним можно настроить Log4j2, например
    private final LoggingAdapter log = Logging.getLogger(getContext().getSystem(), this);
    
    // Декларируем набор сообщений, которые актор может обрабатывать
    @Override
    public Receive createReceive() {
        return receiveBuilder().match(CalculationMessage.class, msg -> onCalculate(msg, sender())).build();
    }

    // Метод для обработки сообщения CalculationMessage
    private void onCalculate(CalculationMessage msg, ActorRef sender) {
        log.info("Received CalculationMessage. Actor name: {}, Thread name: {}", self().path(), Thread.currentThread().getName());
        sender.tell(new CalculationResult(), ActorRef.noSender());
    }

    // Специальный метод для создания актора. Здесь можно передать параметры в конструктор для CalculationActor
    public static Props props() {
        return Props.create(CalculationActor.class);
    }
}

Я опустил код ClientActor, но он очень похож на CalculationActor, с разницей в том, что ClientActor шлет сообщения каждые 100 ms для CalculationActor, а CalculationActor отвечает сообщением CalculationResult. Своего рода «ping-pong» приложение.

Полный код примера доступен по ссылке на github.

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

Received CalculationMessage. Actor path: akka://default/user/calculation-actor, Thread name: default-akka.actor.default-dispatcher-6
Received CalculationResult. Actor path: akka://default/user/client-actor, Thread name: default-akka.actor.default-dispatcher-5

CalculationActor принял запрос на расчет, и ClientActor получил сообщение с результатом.

Что здесь еще интересного? Во-первых, у каждого актора есть свой уникальный путь, по которому его можно идентифицировать, например, akka://default/user/calculation-actor. Во-вторых, имя потока на котором выполняется код актора. Для ClientActor – это default-akka.actor.default-dispatcher-5, я для CalculationActor – это default-akka.actor.default-dispatcher-6, т. е. два актора обслуживаются на двух разных потоках. Потоки берутся из дефолтного пула потоков, про его конфигурирование мы поговорим чуть позже. Кстати, новые сообщения, обрабатываемые в тех же акторах, могут быть обработаны уже на других потоках.

Давайте заглянем в стектрейс и посмотрим, какой путь проходит сообщение, прежде чем оно будет обработано в акторе:

onCalculate:27, CalculationActor (actors)
lambda$createReceive$0:22, CalculationActor (actors)
apply:-1, 1056141993 (actors.CalculationActor$$Lambda$165)
apply:24, UnitCaseStatement (akka.japi.pf)
apply:20, UnitCaseStatement (akka.japi.pf)
applyOrElse:187, PartialFunction (scala)
applyOrElse$:186, PartialFunction (scala)
applyOrElse:20, UnitCaseStatement (akka.japi.pf)
applyOrElse:241, PartialFunction$OrElse (scala)
aroundReceive:537, Actor (akka.actor)
aroundReceive$:535, Actor (akka.actor)
aroundReceive:220, AbstractActor (akka.actor)
receiveMessage:577, ActorCell (akka.actor)
invoke:547, ActorCell (akka.actor)
processMailbox:270, Mailbox (akka.dispatch)
run:231, Mailbox (akka.dispatch)
exec:243, Mailbox (akka.dispatch)
doExec:290, ForkJoinTask (java.util.concurrent)
topLevelExec:1020, ForkJoinPool$WorkQueue (java.util.concurrent)
scan:1656, ForkJoinPool (java.util.concurrent)
runWorker:1594, ForkJoinPool (java.util.concurrent)
run:183, ForkJoinWorkerThread (java.util.concurrent)

Если смотреть по стеку снизу-вверх, то мы видим, что:

  • Есть ForkJoinPool, который отвечает за обслуживание mailbox, привязанного к актору.

  • Внутри ForkJoinTask выбирается сообщение из mailbox.

  • Минуя несколько несущественных вызовов, сообщение обрабатывается в CalculationActor в методе onCalculate.

А вот так выглядит стектрейс после вызова метода tell, который посылает сообщение другому актору:

add:283, ConcurrentLinkedQueue (java.util.concurrent)
enqueue:530, UnboundedQueueBasedMessageQueue (akka.dispatch)
enqueue$:530, UnboundedQueueBasedMessageQueue (akka.dispatch)
enqueue:656, UnboundedMailbox$MessageQueue (akka.dispatch)
enqueue:89, Mailbox (akka.dispatch)
dispatch:63, Dispatcher (akka.dispatch)
sendMessage:159, Dispatch (akka.actor.dungeon)
sendMessage$:153, Dispatch (akka.actor.dungeon)
sendMessage:410, ActorCell (akka.actor)
sendMessage:326, Cell (akka.actor)
sendMessage$:325, Cell (akka.actor)
sendMessage:410, ActorCell (akka.actor)
$bang:178, RepointableActorRef (akka.actor)
tell:128, ActorRef (akka.actor)

Вызов dispatch:63, Dispatcher (akka.dispatch) отвечает за поиск актора, которому нужно направить сообщение и выбор подходящего mailbox. А вызов enqueue:89, Mailbox кладет сообщение в ConcurrentLinkedQueue, которое потом будет обработано получателем в методе processMailbox:270, Mailbox.

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

Два диспатчера

Давайте для начала разнесем два актора по двум разным пулам потоков.

Как я уже писал, конфигурация Akka описывается в отдельном файле, например, actor-system-two-dispatchers.conf:

client-dispatcher {
    type = "Dispatcher"
    executor = "fork-join-executor"
    fork-join-executor {
        parallelism-min = 1
        parallelism-max = 1
    }
}
 
calculation-dispatcher {
    type = "Dispatcher"
    executor = "fork-join-executor"
    fork-join-executor {
        parallelism-min = 1
        parallelism-max = 1
    }
}
 
akka.actor.deployment {
    /client-actor {
        dispatcher = client-dispatcher
    }
 
    /calculation-actor {
        dispatcher = calculation-dispatcher
    }
}

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

client-dispatcher будет обслуживать ClientActor, а calculation-dispatcher – CalculationActor. Оба диспатчера имеют тип Dispatcher. Такой диспатчер создает отдельный mailbox для каждого актора и выбирает свободный поток из пула для обслуживания входящих сообщений. Еще есть, например, PinnedDispatcher, который закрепляет отдельный поток за каждым актором. Подробнее можно почитать здесь.  

Параметр executor, как не сложно догадаться, отвечает за тип пула потоков. Мы будем использовать ForkJoinPool с одним потоком для ClientActor и такой же для CalculationActor.

В секции akka.actor.deployment указываем, какие акторы на каких диспатчерах будут выполняться.

Указать акке, какой файл конфигурации использовать, можно так:

Config config = ConfigFactory.load("actor-system-two-dispatchers.conf");
ActorSystem system = ActorSystem.create("actor-system", config);

Давайте запустим пример:

Received CalculationMessage. Actor path: akka://actor-system/user/calculation-actor, Thread name: actor-system-calculation-dispatcher-6
Received CalculationResult. Actor path: akka://actor-system/user/client-actor, Thread name: actor-system-client-dispatcher-7
Received CalculationMessage. Actor path: akka://actor-system/user/calculation-actor, Thread name: actor-system-calculation-dispatcher-6
Received CalculationResult. Actor path: akka://actor-system/user/client-actor, Thread name: actor-system-client-dispatcher-7

Если посмотреть на параметр Thread name, то видно, что для ClientActor используется поток из actor-system-client-dispatcher, а для CalculationActor - из пула actor-system-calculation-dispatcher. Более того, потоки используются всегда одни и те же, так как мы создали ForkJoinPool’ы с одним потоком.

Роутеры

А как насчет масштабирования?

Предположим, у нас может быть максимум 10 параллельных клиентов, и мы хотим, чтобы у нас было 10 CalculationActor’ов. Как нам поступить, создавать акторы динамически на каждый запрос? К счастью, Akka предоставляет более удобный механизм под названием роутеры. Настраивается роутер также через конфигурационный файл:

akka.actor.deployment {
    /calculation-actor {
        router = round-robin-pool
        nr-of-instances = 10
    }
 
    /client-actor {
        router = round-robin-pool
        nr-of-instances = 10
    }
}

Теперь ClientActor и CalculationActor – это роутеры, т. е. для других акторов и друг для друга они выглядят как обычные акторы, но на самом деле они создают под собой по 10 реальных акторов типа ClientActor и CalculationActor и переправляют сообщения им. Код создания роутера немного отличается от обычного актора, но не существенно.

Давайте запустим пример:

Received CalculationMessage. Actor path: akka://actor-system/user/calculation-actor/$h, Thread name: actor-system-akka.actor.default-dispatcher-5
Received CalculationMessage. Actor path: akka://actor-system/user/calculation-actor/$b, Thread name: actor-system-akka.actor.default-dispatcher-12
Received CalculationMessage. Actor path: akka://actor-system/user/calculation-actor/$e, Thread name: actor-system-akka.actor.default-dispatcher-10
Received CalculationResult. Actor path: akka://actor-system/user/client-actor/$j, Thread name: actor-system-akka.actor.default-dispatcher-9
Received CalculationResult. Actor path: akka://actor-system/user/client-actor/$i, Thread name: actor-system-akka.actor.default-dispatcher-10
Received CalculationMessage. Actor path: akka://actor-system/user/calculation-actor/$i, Thread name: actor-system-akka.actor.default-dispatcher-9
Received CalculationMessage. Actor path: akka://actor-system/user/calculation-actor/$j, Thread name: actor-system-akka.actor.default-dispatcher-10

Из логов видно, что имена акторов теперь имеют вид akka://actor-system/user/calculation-actor/$h, где /calculation-actor - это имя роутера, а $h – это имя реального актора, и таких акторов по 10 для ClientActor и CalculationActor, как мы и указали в конфигурации. Но, к сожалению, акторы опять выполняются на actor.default-dispatcher. Для того, чтобы они выполнялись на отдельных диспатчерах, нужно немного подправить конфигурацию в секции deployment:

"/client-actor/**" {
    dispatcher = client-dispatcher
}
 
"/calculation-actor/**" {
    dispatcher = calculation-dispatcher
}

/** – означает – для всех дочерних акторов.

Если запустить пример:

Received CalculationMessage. Actor path: akka://actor-system/user/calculation-actor/$h, Thread name: actor-system-calculation-dispatcher-11
Received CalculationResult. Actor path: akka://actor-system/user/client-actor/$a, Thread name: actor-system-client-dispatcher-21

то мы увидим, что теперь сообщения обрабатываются на отдельных диспатчерах и пулах потоков.

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

Преимущества акторов

Для себя мы видим следующие преимущества использования акторов и Akka:

  • жесткое разделение бизнес-логики и логики управления потоками;

  • гибкая конфигурация пулов потоков и масштабирование;

  • надежность и производительность Akka;

  • акторы по умолчанию thread safe, и не требуется дополнительная синхронизация потоков – обрабатывается только одно сообщение в отдельный момент времени в одном акторе, каждое входящее сообщение самодостаточно и неизменяемо;

  • лучшая утилизация CPU, так как относительно небольшие куски кода выполняются на одних и тех же потоках;

  • в Akka много дополнительной функциональности, например, работа с сетью, работа в кластере и др.

Недостатки акторов и Akka

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

Второй недостаток Akka в сложности тонкой настройки. Из коробки с дефолтными параметрами до определенных пределов Akka показывает неплохую производительность, но когда растет нагрузка и хочется утилизировать ресурсы сервера «по полной», приходится погружаться во все тонкости и нюансы этого фреймворка, которые достаточно нетривиальны.

Тонкая настройка

Как вы видели, Akka позволяет настроить тип диспатчера, пулы потоков под диспатчерами, можно сделать роутер из актора, который, кстати, тоже настраивается. Еще можно настроить тип mailbox.

Настроек очень много, и сходу не понятно, что подкручивать, если хочется быть чуть быстрее.

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

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

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

Второе – это непосредственно конфигурирование диспатчера и пула потоков. Еще можно задать диспатчеру тип mailbox, который будет использоваться для акторов, например, SingleConsumerOnlyUnboundedMailbox, если из mailbox читает только один актор. Или можно попробовать задать разные значения для параметра throughpu, например, 2 или 3. Это количество сообщений, которое будет «выгребаться» из mailbox за один раз и не будет тратиться время на дополнительную синхронизацию потоков.

Третье – это попробовать подкрутить роутеры и количество инстансов акторов.

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

Что еще мы используем в проекте

Как я уже писал в начале, для «обвязки» всех компонентов мы используем Spring. Акторы можно создавать как динамически, так и определять внутри Spring контекста.

Также:

  • Для взаимодействия с внешними клиентами мы используем protobuf+gRPC завернутые и зашейденные в толстом джарнике. В целом нас такая связка устраивает, можно лишь отметить, что клиентский jar получается относительно большим, около 10 MB, и есть необходимость реализации собственного фейловера и механизма переподписки, если один из серверов «упал».

  • Для логирования мы используем Log4j2, настроенный в асинхронном и GC-free режиме, т. е. запись логов на диск выполняется в отдельном потоке с минимальной генерацией мусора. Кстати, логирование – основной контрибьютор в allocation-rate, поэтому с ним нужно быть внимательным.

  • Для мониторинга, основной метрикой нам служат перцентили latency на request-response. 95-й перцентиль как раз держится на уровне 4 ms, т. е. 95% запросов мы обслуживаем быстрее, чем за 4 ms. Верхние перцентили, к сожалению, могут доходить до 200 ms и выше. Мы живем в мире Java, и от проблем с GC без дополнительных приседаний не уйти.

  • Также мы мониторим детальное latency на критическом пути от прихода подписки до посылки результатов расчетов, текущую нагрузку, строим графики GC и других стандартных серверных метрик.

  • Дополнительно мы разносим сервисы расчетов и сервис по сбору и обработке статики по отдельным процессам. Это очень важный момент, так как обработка, очистка и подготовка статики очень требовательны к CPU. Также нет гарантии, что внешние сервисы отвечают быстро и клиентские библиотеки написаны с прицелом на производительность. Поэтому все взаимодействие с внешними сервисами и предобработку данных удобно вынести в отдельный процесс, и в сервис расчетов передавать уже готовый снапшот данных.

Заключение

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

Если вам интересно, над чем еще работают наши команды, добро пожаловать в наши каналы на ХабреYoutube и соцсетях – FBVK