Привет, Хабр! Меня зовут Егор, я бэкенд-разработчик в T-Банке, участвую в разработке продуктов комплаенса. Недавно в одном из наших проектов мы столкнулись с проблемой низкой производительности Camunda — и хотим поделиться опытом, который мы получили в процессе ее решения.
Статья для тех, кто уже немного знаком с Camunda BPM или имел опыт разработки на ней. Многие описанные здесь вещи будут, скорее всего, непонятны новичкам в Camunda, поэтому лучше будет прежде ознакомиться с основами этого движка в других статьях или в официальной документации.
Какая была проблема
Сервис, который мы разрабатываем, занимается актуализацией данных клиентов банка. В основном это создание задач во внутренней CRM для сотрудников операционной линии и информирование клиентов — нам нужно передать актуальные документы. Если клиент вовремя этого не делает, мы накладываем на него ограничения, потому что из-за такого клиента мы рискуем получить санкции от регулятора.
В качестве основы приложения используется bpmn-движок Camunda, который встроен в контекст Spring. Мы поддерживаем 13 bpmn-процессов, и их количество растет. Сервис слушает топики в Kafka с данными компаний, для которых необходимо провести актуализацию, и запускает по ним экземпляры процессов в Camunda.
Обычно в день мы обрабатываем около 50 тысяч процессов — это небольшая нагрузка для Camunda, с которой она справляется без особых усилий. Были случаи, когда в топик отправляли по миллиону сообщений в день и мы успевали их обрабатывать по мере поступления. Даже сложилась уверенность, что наш сервис может справиться с любым количеством процессов.
Но уверенность быстро пропала, когда произошла очередная массовая выгрузка данных в один из топиков. Нам отправили всего 700 тысяч событий, но если раньше их присылали со скоростью 10 сообщений в секунду, то в этот раз топик наполнялся значительно быстрее, чем мы успевали читать.
Все экземпляры нашего сервиса упорно разгребали свои партиции и за пару часов перевели все события из топика в процессы внутри Camunda. В итоге на момент вычитки последнего события в базе данных находилось более 500 тысяч активных процессов.
И тогда мы столкнулись с рядом проблем:
В пять раз упала скорость обработки. Сильно деградировал запрос на поиск Job’ов из-за большого числа записей в таблице act_ru_job.
Процессы стали зависать в ожидании обработки. Зависали на несколько часов, хотя раньше отрабатывали за секунды. Это привело к срабатыванию таймеров, которые были добавлены без расчета на то, что процесс может зависнуть в очереди на исполнение.
Затормозили критичные процессы на других схемах. На тот момент в конфигурации не были выставлены приоритеты выполнения процессов, и Camunda обрабатывала их в порядке общей очереди. Из-за этого критичные процессы стали ждать, когда до них дойдет очередь из 500 тысяч менее важных процессов.
Пропала возможность массово остановить, удалить или перезапустить процессы, потому что их скопилось такое количество, что любое действие над ними в админке приводило к ошибке переполнения памяти в самом сервисе или в БД. В Camunda API есть возможность запустить асинхронный процесс удаления процессов, но мы не могли его использовать, потому что это работало очень медленно. Быстрее было дождаться, пока все процессы доработают сами.
Высокая нагрузка на БД. Даже когда мы смогли приостановить все процессы, нагрузка на CPU базы данных держалась в районе 60% в простое. Поиск Job’ов в БД запускался постоянно и грузил базу тяжелыми запросами.
Чем больше активных процессов было в БД, тем сильнее Camunda тормозила. Приблизительный график падения производительности:
Какие решения рассмотрели
Вскоре выяснилось, что с этой проблемой сталкиваются многие разработчики нагруженных приложений на Camunda. Все экземпляры сервиса работают через общую базу данных, поэтому при горизонтальном масштабировании она становится бутылочным горлышком всей системы.
Разработчики Camunda в новой версии решили проблему бутылочного горлышка переходом на Zeebe — их новый движок bpmn-процессов, который более устойчив к большим нагрузкам, в первую очередь за счет горизонтального масштабирования. Мы не рассматривали переход на Camunda 8, поэтому искали более простые варианты решений.
Большинство советов от Сamunda-комьюнити делятся на две категории:
Стараться не допускать накопления активных процессов.
Пытаться оптимизировать запрос на получение Job’ов из базы данных.
С советами из первой категории все просто: нужно стараться не допускать большого количества активных Job’ов в БД. Например, отказаться от таймеров на схемах и реализовать их вручную в отдельной таблице.
Второй способ технически более сложен и подразумевает различные оптимизации кода движка, тюнинг настроек БД и так далее. К этому можно прийти, если не получится избежать большого количества активных процессов. Например, когда бизнес-процесс может ожидать срабатывания таймера или ответа от внешней системы.
Заполнение даты выполнения для всех Job’ов. В документации Camunda авторы предлагают включить настройку ensureJobDueDateNotNull для оптимизации запроса на получение Job’ов из БД.
В таблице act_ru_job есть столбец duedate, который содержит дату, при наступлении которой этот Job можно будет выполнить. Но если указать в нем значение null, то Camunda выполнит его с максимальным приоритетом. Настройка ensureJobDueDateNotNull позволяет отказаться от включенной по умолчанию функции, но взамен будет выполнять более оптимальный запрос на чтение Job’ов из БД.
@Configuration
class ExtendedProcessEngineConfiguration() : DefaultProcessEngineConfiguration() {
override fun preInit(configuration: SpringProcessEngineConfiguration) {
configuration.isEnsureJobDueDateNotNull = true
}
}
Результаты:
Чуда не произошло, процессы продолжали накапливаться и завершаться за несколько часов. Но видно, что включение этой настройки ускорило запрос на получение Job’ов и увеличило скорость их обработки.
Для нашего случая это решение не принесло большой пользы, потому что процессы у нас надолго не задерживаются. Но в ситуации, когда в БД постоянно будет лежать больше 100 тысяч процессов, эта небольшая оптимизация принесет больше пользы и сократит число операций в базе данных при чтении.
Rate Limiter для запуска новых процессов. Когда выяснилось, что сервис запускает новые процессы быстрее, чем завершает старые, стали искать способы, как ограничить скорость создания процессов. Для этого было решено прикрутить Rate limiter в виде готовой реализации от Resilience4j. В настройках поставили ограничение на семь процессов в секунду, то есть два экземпляра приложения в сумме за секунду могли запустить не больше 14 процессов.
Пример конфигурации лимитера от Resilience4j:
resilience4j.ratelimiter:
limiters:
process-start:
limitForPeriod: 7
limitRefreshPeriod: 1000ms
Вешаем аннотацию @RateLimiter на метод, в котором описана логика запуска процесса. Пример использования:
@RateLimiter(name = "process-start")
fun startProcess(): ProcessInstance {
// TODO этот код будет запускаться не чаще 7 раз в секунду
}
Результаты:
Хотя в этом решении нет никаких оптимизаций, результат довольно ощутим.
Оказалось, что, если запускать новые процессы дозированно, время обработки всех процессов будет меньше на 30%. Это происходит из-за того, что у Camunda появляется время на обработку прошлых процессов — и они не успевают накапливаться.
В итоге получили красивое число 14 в метрике скорости полной обработки процессов, что равно границе в Rate limiter, которую мы выставили в конфигурации.
Но есть и минусы:
Сложно с ходу подобрать границу для лимитера, поэтому процессы могут продолжать накапливаться, если выставить слишком большой порог. И наоборот, при выставлении низкого порога обработка может быть сильно медленнее, чем позволяет Camunda. Это проблема, если для топика установлена retention-политика с коротким сроком жизни сообщений.
Граница лимитера привязана к количеству слушателей топика. Нужно будет менять настройки лимитера каждый раз, когда изменится число партиций или экземпляров приложения.
Границу для лимитера нужно устанавливать индивидуально для каждого слушателя. Так как в зависимости от размера bpmn-схемы пропускная способность для каждого процесса может сильно отличаться.
Изменение конфигурации Camunda. В Camunda есть несколько настроек, которые позволяют менять скорость обработки Job’ов. В нашем случае нужно было сократить количество запросов на чтение, поэтому решили увеличить размер очереди у JobExecutor.
Конфигурация JobExecutor:
camunda:
bpm:
job-execution:
max-jobs-per-acquisition: 20
core-pool-size: 10
max-pool-size: 20
spring.datasource.hikari.maximum-pool-size: 10
max-pool-size — максимальный размер пула потоков, который обрабатывает очередь Job’ов. По умолчанию стоит значение 10, увеличили его в два раза, чтобы очередь обрабатывалась быстрее.
max-jobs-per-acquisition — настройка, которая указывает, сколько Job’ов будет получено за один запрос в базу данных. Также она устанавливает максимальный размер очереди для JobExecutor. По умолчанию стоит значение 3, мы увеличили его до 20, что позволило сократить число запросов на чтение из БД.
spring.datasource.hikari.maximum-pool-size — оставили значение 10, чтобы пул соединений БД был в два раза меньше пула JobExecutor’а.
Увеличение пула ускорило обработку и дало неожиданный сайд-эффект: Camunda перестала запускать новые процессы, пока не завершались старые.
Дело в том, что, когда мы увеличили пул потоков у JobExecutor, он стал занимать весь пул соединений БД. Это привело к тому, что слушателю kafka-топика приходилось постоянно ожидать свободного соединения из-за голодания пула.
Процессы перестали накапливаться, и 99% из них успевали завершиться за 50 секунд. Это изменение решило исходную проблему, но привело к другим, которые заставили нас отказаться от этого решения:
Из-за голодания пула соединений в случайном месте приложения стали возникать ошибки с нехваткой свободного соединения. Это сделало бы сервис слишком непредсказуемым при большой нагрузке.
Из-за замедления скорости создания новых процессов заметно увеличилось время вычитывания топика. При большой загруженности топика есть риск не успеть вычитать часть сообщений из-за retention-политики топика.
Сокращение количества сохранений в БД. По метрикам приложения было видно, что код делегатов отрабатывает быстро относительно всего времени обработки процесса. Значит, большую часть времени процесс находился в ожидании очереди на выполнение.
Движок Camunda работает так: схема процесса делится на исполняемые отрезки (JobDefinition), которые в документации движка называются транзакциями. Границы этих транзакций можно определять, указывая на элементах схемы признаки asyncBefore/asyncAfter.
Например, выставленный признак asyncAfter=”true” у ServiceTask означает, что после обработки этого элемента сохранится состояние процесса в БД. И выполнение этого процесса продолжится только после того, как очередь у JobExecutor снова дойдет до этого процесса.
Если на схеме не будет ни одной такой точки сохранения, процесс будет выполняться полностью in-memory и в случае ошибки или рестарта сервиса полностью исчезнет. И наоборот, если везде расставить такие точки сохранения, каждое действие будет записано в БД и каждый раз будет ждать очереди в JobExecutor.
Если не указать приоритеты схем или отдельных Job’ов, по умолчанию Camunda будет обрабатывать их по алгоритму round-robin, то есть будет проходить все процессы по кругу и выполнять по одному Job у каждого процесса.
Например, если в БД находится 100 тысяч активных процессов, то, чтобы продвинуть один из них дальше по схеме, нужно ожидать, пока Camunda обработает 100 тысяч Job’ов в других процессах. Получается, при скорости обработки 100 Job’ов в секунду процесс будет ждать своей очереди примерно 16 минут на каждый Job. Если схема процесса состоит из десяти Job’ов, суммарное время жизни процесса будет примерно 160 минут.
Ради эксперимента решили проверить, насколько сильно вырастет производительность, если сократить количество Job’ов на схеме в два раза. Обычно мы ставим asyncAfter=”true” после каждого ServiceTask, чтобы избежать лишних перезапусков делегатов при ошибках, поэтому изначально в процессе было восемь таких сохранений. Убрали четыре из них, и вот что получилось:
По результатам видно, что количество Job’ов в схеме почти линейно влияет на скорость обработки процесса. Поэтому при разработке схемы стоит осознанно подходить к расстановке границ транзакций. Привычка везде расставлять asyncBefore/asyncAfter может привести к увеличению времени обработки процесса в разы.
Исходную проблему такой подход не решил, процессы продолжали накапливаться, но уже заметно медленнее. Также немного упала скорость обработки Job’ов, так как увеличилось их количество на одну транзакцию.
Замеры производительности
Для проверки гипотез мы провели нагрузочное тестирование: в топик с двумя партициями в очень короткий срок отправлялось 200 тысяч сообщений со средней скоростью 80 rps. Само приложение было развернуто в Kubernetes в двух экземплярах. После каждого прогона полностью очищалась БД.
Все подходы тестировались независимо друг от друга, и их итоговые результаты сравнивались только с исходными показателями.
Пройдемся по всем метрикам, которые мы собирали.
Время обработки всех процессов — за какое время сервис обработает все события из топика. Эти показатели могут быть критичны для бизнеса.
Время вычитывания всех сообщений из топика — если в описании топика указан retention на время хранения сообщений, они могут быть удалены до того, как будут прочитаны.
Максимальный consumer-lag топика — ситуация аналогична метрике выше: у топика может быть установлен retention на размер в байтах, поэтому нужно следить, чтобы сервис успевал вычитывать события до их удаления.
Максимальное количество активных процессов — как мы выяснили, большое число активных процессов в БД сильно замедляет производительность Camunda, так что чем их меньше, тем лучше.
Скорость обработки Job’ов в секунду — основная метрика производительности движка Camunda. Показывает, сколько Job’ов было обработано за 1 секунду. Были взяты 5-й и 15-й перцентили, чтобы были видны максимальные просадки в производительности.
Скорость обработки процессов в секунду — показывает, сколько процессов завершается за 1 секунду. Логично предположить, что если за секунду создается больше процессов, чем завершается, то активные процессы начинают накапливаться.
Время выполнения запроса на получение Job’ов из БД в секундах — техническая метрика, измеряющая время, за которое JobExecutor получает новые джобы на исполнение. Для сбора метрики был создан отдельный scheduled-метод, который вызывал компонент движка, ответственный за поиск Job’ов, и замерял время работы всей функции.
Время жизни процесса — показывает, сколько времени процесс находился в активном состоянии, то есть исполнялся движком Camunda. Зависание процесса может быть неприемлемо с точки зрения бизнеса, также могут сработать лишние таймеры на схеме, поэтому важно, чтобы процесс выполнялся максимально быстро.
Чтобы собрать метрику, при старте процесса в него записывалась переменная с временем создания, а в конец процесса был добавлен слушатель (ExecutionListener), который считал, сколько времени прошло до завершения процесса.
Скрытый текст
Что получилось
Подведем итоги:
Включение настройки ensureJobDueDateNotNull не дало желаемого результата, но мы решили ее оставить, потому что это дает небольшой прирост производительности при минимальных усилиях.
RateLimiter решил исходную проблему, но нам пришлось отказаться от него — мы посчитали его сложным в сопровождении, потому что требуется постоянное внимание к его конфигурации.
Повышение пула и очереди у JobExecutor тоже решило исходную проблему, но привело к нестабильности приложения при нагрузке и куче ошибок в логах. От этого решения также пришлось отказаться.
Последний способ нам тоже не подошел, потому что он не решил полностью проблему и мы не готовы сокращать количество сохранений процессов в БД.
К сожалению, ни одно из быстрых решений не дало нам желаемого результата, поэтому мы решили выделить больше времени на решение этой проблемы. Приняли решение, что будем внедрять паттерн Message Inbox для входящих сообщений из Kafka. О том, что из этого получилось расскажу в следующей части.
Надеюсь, что эта статья была вам полезна. Буду рад обратной связи: возможно, вы тоже сталкивались с описанной проблемой и у вас есть опыт, которым можно поделиться.