
Привет, Хабр! На связи команда разработки кредитных продуктов для физлиц Т-Банка. Продолжаем серию статей о нашем опыте работы с Zeebe — workflow-движком, лежащим в основе Camunda 8. В прошлой части затронули архитектуру, отказоустойчивость, масштабируемость и асинхронную обработку.
За высокой производительностью в начале эксплуатации может скрываться постепенное замедление. Долгосрочная эксплуатация Zeebe выявляет критическую зависимость от инфраструктуры хранения истории. Накопление данных о процессах нагружает не только сам движок, увеличивая потребление памяти и время создания снапшотов, но и Elasticsearch, который при недостатке ресурсов быстро становится узким местом. Хотя архитектура Zeebe позволяет гибко управлять экспортом событий через кастомные экспортеры и выбирать альтернативные инструменты мониторинга, именно стабильность работы с историей определяет общую эффективность системы.
В статье разберем, как рост объема данных трансформируется в падение throughput, и почему оптимизация хранилища зачастую важнее тонкой настройки самого движка.
Накопление event-логов
Мы выявили зависимость между общим объемом дискового пространства и использованием буферной памяти, обусловленную механизмом mmap, который отображает файлы логов в адресное пространство процесса. В версии 8.5 отсутствует механизм flow control, что позволяет обработке событий опережать их экспорт, приводя к накоплению необработанных event-логов. Очистка логов происходит только после создания снапшота, который формируется при одновременном выполнении следующих условий:
все события в сегменте были обработаны экспортерами;
все события были реплицированы на другие брокеры;
все события были обработаны движком;
снапшот успешно создан.
Если хотя бы одно из условий не выполняется, сегменты не удаляются, что приводит к неограниченному росту event-логов и к исчерпанию дискового пространства.

При анализе содержимого узлов мы установили, что основной объем данных на диске занимают крупные файлы event-логов размером по 128 МБ на сегмент.

Суммарно по всем партициям они составляют около 40 ГБ — практически весь объем доступного дискового пространства.
Важно отметить, что речь идет не о данных RocksDB, а именно о неочищенных event-логах, накопленных из-за отсутствия продвижения экспортеров и невозможности создания снапшотов.

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

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

RocksDB использует LSM-дерево. Когда мы записываем данные, они сначала:
Попадают в MemTable (таблица в памяти).
При заполнении MemTable сбрасывается на диск в виде SSTable — отсортированного файла.
SSTable помещаются на разные уровни (L0, L1, L2 и т.д.). L0 — самый новый уровень, L2 — самый старый. То есть в L0 может поместиться самая актуальная версия объекта. Допустим, пришла операция update. В L1 может переместиться старая запись, а в L0 остается новая запись. В дальнейшем при новой итерации записи сольются в L2 с более актуальной версией.
Со временем на диске накапливается много SSTable, и, чтобы оптимизировать хранение и ускорить чтение, RocksDB периодически выполняет compaction — слияние нескольких SSTable в один, более крупный SSTable.
Хотя RocksDB может использовать mmap как при чтении, так и при записи, в нашем случае это оказалось неактуально. Анализ файла rocksdb.options показал, что оба соответствующих параметра отключены:
alow_mmap_reads=false
allow_mmap_writes=false
В текущей конфигурации RocksDB не использует mmap — значит, он не может быть источником роста mapped-памяти. Это позволило нам исключить его из дальнейшего расследования и сосредоточиться на других компонентах.
Поиск проблем в heap dump
Анализ дампа кучи позволил выделить ключевые аспекты, связанные с потреблением памяти:
Сериализация данных — значительная нагрузка наблюдается при формировании и накоплении батчей в процессе экспорта истории. Применяется связка MessagePack + Jackson, что может влиять на эффективность сериализации и объем потребляемой памяти.
Atomix — компонент, отвечающий за кластерную координацию и согласованность. Для сериализации он использует Kryo, что отличается от подхода в других частях системы, — например, MessagePack в экспортерах.
Работа с буферами организована через библиотеку Agrona, ориентированную на производительность и минимальное выделение памяти.

В куче мы не обнаружили значительных утечек или накопления гигабайтов данных — основная проблема с памятью лежит вне heap.
Анализ памяти через async profiler
Для глубокого анализа потребления памяти вне heap мы провели профилирование с помощью async-profiler, используя флаг alloc. Это позволило отследить динамику выделения off-heap-памяти в реальных условиях нагрузки. Основные наблюдения:
Основной объем аллокаций связан с экспортом истории: активно создаются, переиспользуются и очищаются буферы при формировании батчей.
Значительная нагрузка также идет от внутрикластерной коммуникации через Atomix — передача данных между узлами генерирует большое количество временных байтовых буферов.
В целом картина схожа с выводами по heap dump, но теперь видна динамика: активные аллокации подтверждают, что проблема не в утечках в куче, а в интенсивном использовании off-heap-памяти, особенно при сериализации и сетевом обмене.

При профилировании с флагом nativemem были выявлены значительные объемы выделенной native-памяти, связанные с RocksDB:
Несмотря на то, что mmap отключен по умолчанию, RocksDB остается активным потребителем off-heap-памяти.
Основное потребление связано с внутренними структурами данных: кэш блоков, фильтры, memtables и другие компоненты, управляемые нативным кодом.
RocksDB сильно зависит от объема доступной RAM: при нехватке ресурсов возрастает нагрузка на диск и деградирует производительность.
Первоначальное предположение о влиянии mmap не подтвердилось, но анализ подтвердил: RocksDB — ключевой потребитель native-памяти и его настройки критически важны для стабильной работы брокера.

Просмотр участков использования памяти в Flamegraph
Для глубокого анализа производительности Zeebe мы использовали flamegraph. Мы начали с профилирования выделения памяти с помощью опции -e alloc.

Ключевые моменты анализа использования памяти Zeebe:
Zeebe Scheduler (io/camunda/zeebe/scheduler) — верхние широкие полосы показывают, что значительная часть аллокаций происходит внутри планировщика Zeebe, в частности в ActorJob.execute и ActorJob.invoke. Это ожидаемо, так как это ядро системы, через которое проходят все задачи.
Stream Processor (io/camunda/zeebe/streamprocessor) — в центральной части виден поток обработки команд (ProcessingStateMachine). Большое количество мелких вертикальных полос указывает на создание множества объектов в процессе выполнения логики BPMN (например, BpmnStreamProcessor).
Сетевой стек и сериализация — справа блоки io/netty и com/fasterxml/jackson. Это означает, что заметная доля памяти уходит на сетевое взаимодействие и преобразование данных из JSON (Jackson) и обратно.
Структура стека выглядит здоровой: нагрузка сосредоточена на ключевых, ожидаемых участках. Аномалий и «странных» вызовов не обнаружено.
Дальше мы перешли к анализу native memory — памяти, выделяемой нативным кодом вне JVM. Здесь ключевым игроком оказался RocksDB — встроенный движок хранения, используемый Zeebe для persistence.

Flamegraph по nativemem выявил:
RocksDB — одна из основных «горячих точек» по выделению памяти.
Наиболее активное выделение происходит под MemTables — структуры данных в памяти, куда RocksDB сначала пишет новые записи перед их сбросом на диск (в SST-файлы).
Видно, что буферы под MemTables выделяются часто и в большом объеме, особенно при интенсивной записи.
Интересно, что RocksDB использует собственный аллокатор памяти, чтобы минимизировать накладные расходы JVM и улучшить контроль над фрагментацией. Но это также означает, что такие выделения не видны в стандартных Java-профайлерах и их можно отследить только через native-профилирование.

Ключевые моменты анализа использования памяти RocksDB. Анализ нативной памяти выявил доминирование системных вызовов выделения памяти, таких как malloc_hook и operator new, что указывает на активное использование системной кучи в обход JVM. Основная нагрузка приходится на операции чтения: система тратит существенные ресурсы на поиск ключей в RocksDB, что обусловлено либо высокой сложностью BPMN-процессов, либо большим количеством одновременно активных инстансов.
Ситуацию усугубляет механизм транзакций: Zeebe использует Transactional Column Family для обеспечения атомарности операций. Это влечет дополнительные расходы на поддержание индекса транзакции, включая работу Write Batch With Index и создание новых итераторов.
Поскольку все эти аллокации происходят на уровне native-кода через malloc, стандартные настройки garbage collector в JVM на них не влияют. Это создает риск утечки или фрагментации нативной памяти, поэтому критически важно следить за общим лимитом памяти не только внутри контейнера, но и на уровне хоста.
Не нужно ограничиваться только heap-профилированием. Native memory — часто скрытый источник проблем, особенно в системах с embedded storage. В итоге вот что нам дало профилирование RocksDB и анализ event-логов:
Объем buffer pool memory в 40 ГБ обусловлен использованием mmap для event log и отражает размер отображенных файлов, а не реальное потребление RAM.
Фактическое использование оперативной памяти определяется объемом данных в page cache, который динамически управляется ОС.
RocksDB не использует mmap по умолчанию и полагается на собственные механизмы кэширования, что снижает зависимость от page cache, но требует выделения значительного объема RAM.
Elasticsearch выделяет буферы в heap, что делает его поведение предсказуемым, но требует контроля за GC.
Использование mmap эффективно, но производительность зависит от состояния page cache и общего объема доступной памяти.
Основной фактор роста нагрузки — накопление истории. Без очистки логов система постепенно потребляет больше ресурсов, что приводит к деградации производительности.
Стабильная работа Zeebe при нагрузке в 300 RPS достижима только при управлении объемом хранящихся данных.
Ключ к долгосрочной стабильности — настройка политик хранения и регулярная очистка устаревших данных.
Основная проблема связана не с архитектурными ограничениями, а с операционной практикой. Управление объемом истории является критически важным элементом эксплуатации Zeebe в продакшене.
История в Operate
Как мы выяснили в предыдущей статье, производительность дисков — ключевой фактор стабильной работы Zeebe, но это не единственная потенциальная слабость движка. Не менее критично отображение истории процессов в Operate: при высокой нагрузке история может отставать на часы, если Elasticsearch не справляется с потоком данных.

Экспортер истории собирает события из Event Log через processor и накапливает их во внутреннем буфере в памяти. Этот буфер агрегирует записи до выполнения одного из условий для flush:
достигнут размер батча — по умолчанию 1000 записей;
превышен лимит по памяти — 10 МБ;
прошел таймаут — 5 секунд.
Только после выполнения любого из этих условий батч отправляется в Elasticsearch для индексации.
Эффективность экспорта истории зависит не только от ресурсов Zeebe, но и от производительности Elasticsearch. При недостаточной мощности эластик-кластера буферизация в памяти, накопление батчей приводят к задержкам — история в Operate становится устаревшей. Поэтому для стабильной работы Zeebe важно обеспечивать не только стабильную работу Zeebe, но и масштабируемый, отзывчивый Elasticsearch.
Elasticsearch — узкое место в цепочке экспорта
Хотя сам Zeebe быстро собирает и выгружает историю, основная нагрузка все равно приходится именно на экспорт: красные и желтые блоки на скрине ниже показывают максимальное число батчей в секунду. На практике экспорт работает по первому условию — батч формируется из 1000 записей.

В качестве эксперимента мы решили увеличить размер батча до 10 000 записей, чтобы уменьшить частоту отправки и снизить задержки. После этого пропускная способность движка не изменилась: лаг в экспорте остался прежним, несмотря на меньшее количество больших батчей. Дальнейшие попытки тюнить параметры экспортера не приводили к заметным улучшением.

Память стала поменьше расходоваться, и эластику стало немного проще. Но тюнинг параметров Zeebe и экспортера не решает проблему отставания истории в Operate, потому что узкое место — производительность и масштабируемость Elasticsearch. Без оптимизации и масштабирования эластик-кластера (увеличение числа шардов, ресурсов, настройка индексов) никакие изменения на стороне Zeebe не дадут желаемого эффекта: история будет отставать, а пропускная способность экспорта ограничена возможностями индексации.
Разбор ключевых метрик производительности Elasticsearch. При анализе производительности кластера Elasticsearch основное внимание мы уделили метрикам CPU, латентности операций и дисковой подсистемы. На начальном этапе наблюдалась высокая утилизация CPU, которая существенно снизилась после изменения конфигурации. Это произошло за счет оптимизации количества шардов, настройки интервала refresh_interval и управления thread pool'ами.
Снижение утилизации после оптимизации указывает на то, что нагрузка на процессор была вызвана не только нехваткой ресурсов, но и неэффективной работой Elasticsearch в базовой конфигурации. Значит, Elasticsearch поддается также значительной оптимизации на уровне конфигурации.

Одновременно с анализом метрик Elasticsearch мы зафиксировали высокие задержки при индексировании, особенно выраженные в периоды слияния сегментов. В отдельных случаях латентность достигала нескольких минут. Merge-операции в Elasticsearch — ресурсоемкий процесс, требующий как CPU, так и интенсивного дискового I/O. При высокой интенсивности записи они могут становиться узким местом, блокируя другие операции и вызывая непредсказуемую задержку.

Как и в случае с Zeebe, особое внимание привлекла дисковая подсистема. Несмотря на использование производительных SSD-дисков в инфраструктуре OpenStack 2.0, наблюдалась значительная очередь на запись. Это подтвердило наши подозрения о том, что даже при наличии быстрых накопителей реальная производительность может ограничиваться особенностями виртуализации, общей нагрузкой на shared storage или неоптимальными настройками I/O. В таких условиях метрики I/O операций диска становятся критически важными для диагностики узких мест.

Анализ показал, что производительность Elasticsearch зависит не только от железа, но и от правильной настройки самого кластера, учета характера нагрузки и особенностей инфраструктуры. Оптимизация merge-политик, управление частотой обновления сегментов и переход на более изолированные дисковые решения — ключевые шаги для стабилизации работы и снижения латентности.
Размер дискового пространства приближается к нулю. Когда диск, выделенный под Elasticsearch, переполняется, это напрямую влияет на стабильность всей системы Zeebe и связанных компонентов. Первое, что можно заметить, — история процессов перестает поступать в Operate.
Elasticsearch, будучи перегруженным, начинает тратить все ресурсы на попытки внутренней оптимизации: сброс буферов, слияние сегментов, удаление старых индексов по политике ротации. При нехватке свободного места эти процессы замедляются или вовсе блокируются.

Zeebe перестает выгружать историю в Operate — Elasticsearch не принимает данные. Он пытается оптимизироваться: сбрасывает буферы, удаляет старые индексы, но при нехватке места эти процессы замедляются или блокируются. В итоге деградирует не только эластик, но и сам Zeebe из-за накопления данных в буфере экспорта. При этом сами процессы не теряются: они продолжают работать, так как логи хранятся на диске движка Zeebe, отдельно от эластика.
Рекомендации:
Подключить Kafka Exporter — но это не решение, а отсрочка: проблемы просто переместятся на уровень Kafka-кластера.
Настроить политики протухания истории через параметры Operate. Если история не нужна — чистить ее чаще или не писать вовсе.
Тюнить Elasticsearch: выделить больше ресурсов, настроить индексацию, слияния.
При необходимости — реализовать кастомный экспортер, например в другую БД или хранилище.
Нехватка места на диске — серьезный сигнал. Она требует комплексного подхода: от настройки политик хранения до архитектурных решений по экспорту данных. Главное — не полагаться только на Elasticsearch как на единственный буфер, а проектировать систему с учетом баланса между производительностью, надежностью и удобством мониторинга.
Заключение
Elasticsearch становится узким местом при недостатке ресурсов. Его стабильная работа требует тщательного подбора конфигурации и выделения достаточных вычислительных мощностей. При этом Zeebe предоставляет гибкость: можно реализовать собственный экспортер и использовать альтернативное хранилище, минуя ограничения стандартного REST-экспорта истории.
Operate, хотя и ограничен в функционале в бесплатной версии, может быть заменен на open-source-решения вроде zeebe-simple-monitor. Однако основной путь к эффективной эксплуатации — оптимизация Elasticsearch, который зачастую оказывается настоящим бутылочным горлышком.
В следующей статье разберем, какие есть нововведения в Camunda 8.6 для попытки справиться с проблемами, описанными здесь.
Мы описали путь, который прошли сами, но уверены, что есть и другие способы борьбы за производительность. А как вы оптимизируете экспорт истории в Elasticsearch? Может, используете кастомные экспортеры или другие хранилища? Будем рады вашим идеям в комментариях — обсудим!

