Представьте согласование кредита в банке. Клиент подал заявку — и дальше начинается процесс, который тянется днями. Скоринговый сервис проверяет платёжеспособность, служба безопасности смотрит документы, где-то в середине заявка ждёт решения человека, который может уйти на выходные. Если внешний сервис не ответил — надо подождать и попробовать снова через час. Если на последнем шаге пришёл отказ — надо откатить всё, что уже сделали: снять резерв средств, отменить предодобрение. Такой процесс живёт часами и днями, проходит через людей, сервисы, таймеры и откаты. Обычная стейт-машина в памяти здесь не спасёт: перезапустишь приложение — и все «висящие» заявки потеряют своё состояние. Хранить состояние в базе руками можно, но быстро упираешься в кучу однообразного кода: где мы сейчас, чего ждём, что уже сделано, что откатывать. Для таких длинных процессов есть отдельный класс инструментов — BPM-движки. Разберём, что это и когда оно нужно.
Что такое BPM-движок
BPM-движок (Business Process Management) — это программа, которая управляет длинными бизнес-процессами за вас. Там, где стейт-машина в памяти хранит текущее состояние в переменной, BPM-движок берёт на себя всё, что нужно долгоживущему процессу:
- Персистентное состояние. Движок записывает в базу, на каком шаге сейчас каждый экземпляр процесса. Перезапустили приложение — процессы продолжаются с того же места, ничего не потеряно.
- Ожидание событий и таймеров. Процесс может «уснуть» на середине и ждать: внешнего сообщения, ответа человека, наступления даты. «Через 3 дня без ответа — напомнить», «дождаться подтверждения оплаты» — это встроенные возможности, а не код, который вы пишете сами.
- Повторные попытки (ретраи). Если шаг обратился к внешнему сервису и тот не ответил, движок сам повторит попытку по заданной политике, а не уронит весь процесс.
- История выполнения. По каждому экземпляру видно, какие шаги прошли, когда, сколько заняли, где застряли. Это готовая картина процесса для аналитики и разбора инцидентов.
Сам процесс обычно описывают на языке-схеме BPMN (Business Process Model and Notation). Это графическая нотация: прямоугольники — задачи, ромбы — развилки (условия), кружки — старт и финиш, значки конвертов — ожидание сообщений. Ключевая идея в том, что такая схема исполняемая: движок не просто рисует картинку, а буквально идёт по ней, шаг за шагом. Аналитик и разработчик смотрят на одну и ту же диаграмму, и это же — работающая логика.
Тот же кредитный процесс как исполняемая BPMN-схема: кружки — старт и финиш, прямоугольники — задачи, ромб — развилка, конверт — ожидание решения человека. BPM-движок буквально идёт по этой схеме, помнит, где сейчас каждая заявка, и переживает перезапуски приложения.
Camunda
Camunda — один из самых известных BPM-движков в мире Java. Его суть в том, что нарисованная BPMN-схема становится исполняемой напрямую: вы моделируете процесс в визуальном редакторе, привязываете к задачам свой код, и движок гоняет по этой схеме реальные заявки.
Где применяют Camunda:
- Согласования — заявки на кредит, отпуск, закупку, доступ; всё, что проходит через несколько проверок и решений людей.
- Платежи — многошаговые денежные операции, где важны ретраи, ожидания и откаты.
- Приём клиента или сотрудника — регистрация: собрать документы, проверить, завести учётные записи, уведомить.
Стоит знать про две большие версии, они устроены по-разному:
- Camunda 7 — классический встраиваемый движок. Он живёт внутри вашего Java-приложения как библиотека и хранит состояние процессов в вашей же реляционной базе. Простой старт: подключил зависимость — и в том же сервисе крутятся процессы. Но выбирать его на новый проект уже поздно: вендор закрыл седьмую линейку. Последний выпуск 7.24 вышел в октябре 2025 года, тогда же бесплатная (community) редакция перестала получать даже обновления безопасности; платная поддержка продлена до апреля 2030-го, и только на этом выпуске. Новых возможностей в семёрке не будет — всех переводят на восьмёрку.
- Camunda 8 — переработанная версия поверх ядра Zeebe. Это уже не встраиваемая библиотека, а отдельный распределённый движок, рассчитанный на большой поток процессов и работу с множеством микросервисов. Ваш код общается с ним по сети, а не внутри одной JVM.
Если коротко: 7 — «движок внутри моего приложения», 8 — «движок как отдельная масштабируемая система рядом».
Аналоги
Camunda — не единственный вариант. Инструментов много, и различаются они прежде всего тем, как описывают процесс и как разворачиваются.
- Temporal — durable execution: процесс пишется как обычный код (на Java, Go и других языках), а не как BPMN-схема; движок гарантирует, что этот код переживёт перезапуски и падения и продолжится с того же места.
- Zeebe — строго говоря, не альтернатива Camunda, а её собственное ядро: именно на нём построена Camunda 8. В отдельный пункт он попадает в одном случае — когда нужен только исполнитель процессов с высокой пропускной способностью, а визуальное моделирование и веб-интерфейс Camunda не нужны.
- Flowable и Activiti — лёгкие встраиваемые BPMN-движки, удобные, когда нужен движок внутри приложения без отдельной инфраструктуры. Родство с Camunda тут есть, но идёт оно не от неё: первым в 2010 году появился Activiti, в 2013-м от него отделилась Camunda, в 2016-м — Flowable, который увела за собой исходная команда Activiti. То есть Activiti — общий предок, а Camunda 7 и Flowable — два разошедшихся потомка.
- jBPM — BPMN-движок от Red Hat, часто идёт в связке с правилами (Drools), когда в процессе много бизнес-логики «если — то».
- AWS Step Functions — управляемый (managed) оркестратор в облаке AWS: процесс описывают декларативно, а сервер держит и обслуживает сам провайдер, вам не нужно ничего разворачивать.
По одному предложению на каждый — этого достаточно, чтобы отличать их на слух. Дальше выбор зависит от того, хотите вы процесс как схему или как код, встроенный движок или отдельный, свой развёрнутый или облачный.
Чем оплачен «код, который переживает перезапуски»
Про Temporal легко уйти с ощущением бесплатного чуда: пишешь обычный код, движок делает его надёжным. Механика объясняет цену, и её надо знать до внедрения.
Движок восстанавливает состояние процесса повторным проигрыванием вашего кода: он заново выполняет функцию от начала, подставляя из журнала результаты всех действий, которые уже случались, и доходит до того места, где остановился. Отсюда жёсткое требование: код процесса обязан быть детерминированным — при одинаковом журнале он должен выполняться одинаково.
Что это значит на практике:
- Никакого обращения ко времени напрямую.
Instant.now()даст разное значение при повторном проигрывании, и ход выполнения разойдётся с журналом. Время берут через интерфейс движка; ожидание — тоже его вызовом, а неThread.sleep. - Никакой случайности и идентификаторов. Случайное число, новый идентификатор, перемешивание коллекции — только через движок, иначе при повторном проигрывании получатся другие значения.
- Никаких обращений наружу из кода процесса. Запрос в базу, вызов сервиса, чтение файла — всё это действия, которые вызываются через движок и чей результат он записывает в журнал. Код процесса сам никуда не ходит; он только решает, что вызвать дальше.
- Никакого чтения изменяемого внешнего состояния — статической переменной, настройки, системного окружения: при проигрывании оно может быть другим.
- Осторожно с коллекциями и порядком. Обход множества с непредсказуемым порядком может дать разный ход выполнения.
И вторая часть цены, менее очевидная: изменять код уже запущенного процесса нельзя свободно. Экземпляры, которые идут неделю, будут проигрываться новым кодом по старому журналу — и если вы переставили шаги, добавили действие в середину или убрали ожидание, проигрывание разойдётся с журналом, и экземпляр упадёт. Отсюда та же проблема, что у схем в BPM-движке (следующий раздел): нужны версии кода процесса и явное ветвление «старые экземпляры идут по старому пути». Инструменты для этого есть, но это работа, а не подарок.
Итог честно: Temporal снимает с вас надёжность выполнения долгого процесса и взамен требует писать код по своим правилам, отлаживать его особым образом и версионировать вместе с уже летящими экземплярами. Для процессов на дни и недели это выгодный обмен; для «трёх шагов в одном сервисе» — нет.
Сколько это стоит в деньгах
Разговор о движках обычно идёт про возможности, а решение часто принимают по лицензии. Порядок картины на 2026 год:
- Camunda 7 — бесплатная редакция больше не получает даже обновлений безопасности, а платная поддержка есть только для последнего выпуска. То есть для нового проекта вариант «поставлю бесплатно и буду жить» закрыт.
- Camunda 8 — для промышленного использования нужна коммерческая лицензия (самостоятельное развёртывание или облако); бесплатно доступна редакция для разработки и знакомства. Цену называют по запросу, и она уровня «отдельная строка в бюджете», а не «включим в накладные расходы».
- Temporal — сам движок с открытым кодом и бесплатен при самостоятельном развёртывании; платно — облачный сервис (по объёму действий и хранению). То есть выбор «платить деньгами или платить эксплуатацией».
- Flowable, Activiti, jBPM — есть бесплатные редакции с открытым кодом; коммерческая поддержка и часть возможностей — платно.
- Управляемые оркестраторы облаков — оплата по числу переходов между шагами; дёшево на малом объёме и заметно дорожает на потоке в миллионы шагов.
Что из этого следует для выбора. Цена лицензии сравнивается не с нулём, а с ценой своего решения — и своё решение не бесплатно: это код, эксплуатация, интерфейс для поддержки и время разработчиков. Порядок прикидки: стоимость лицензии в год против двух-трёх человеко-месяцев разработки плюс постоянное обслуживание. И второй вопрос, который задают реже, а он важнее: что будет, если поставщик изменит условия — сколько стоит уйти. У процесса, описанного схемой в формате движка, переезд дороже; у процесса, описанного кодом, — дешевле, но всё равно не бесплатен.
Практический вывод: для одного-двух процессов внутри одного сервиса разговор о движке обычно заканчивается на этой арифметике в пользу своего решения; для десятка процессов с людьми, таймерами и требованием видимости — в пользу движка.
BPM и saga
Отдельно стоит связать BPM-движок с распределёнными системами. Когда бизнес-процесс идёт через несколько микросервисов, возникает проблема: единой транзакции на всех больше нет. Сервис заказов, сервис платежей, сервис склада — у каждого своя база, и «откатить всё разом» одним rollback невозможно.
Здесь и появляется паттерн saga — цепочка локальных шагов, где у каждого шага есть компенсирующее действие на случай неудачи. Списали деньги, но товара не оказалось — выполняем компенсацию: возвращаем деньги. Saga — это по сути та же долгая последовательность шагов с откатами, что мы описывали в начале.
BPM-движок отлично играет роль оркестратора saga: он держит центральную схему процесса, вызывает сервисы по очереди, ждёт их ответов и при неудаче на позднем шаге запускает компенсации в обратном порядке. И дело тут не просто в том, что состояние где-то сохраняется: стейт-машина с колонкой status в базе тоже переживёт перезапуск. Движок хранит больше — не одно текущее состояние, а позицию в схеме, список уже выполненных шагов с их результатами и список того, что придётся компенсировать, если процесс не дойдёт до конца. Именно эти три вещи и приходится городить руками, когда пишешь saga самостоятельно. Так BPM-движок становится инструментом распределённой согласованности: не мгновенной, а достигаемой через последовательность шагов и компенсаций.
Три шага саги по трём сервисам: третий отказал, и компенсации идут в обратном порядке, сначала снимается резерв склада, потом возвращается платёж.
// Схематично: оркестратор saga вызывает шаги по очереди
// и при провале запускает компенсации в обратном порядке.
public void executeOrderSaga(Order order) {
reservePayment(order); // шаг 1
try {
reserveStock(order); // шаг 2 — тут может не хватить товара
} catch (OutOfStockException e) {
refundPayment(order); // компенсация шага 1
throw e;
}
}
// Схематично: оркестратор saga вызывает шаги по очереди
// и при провале запускает компенсации в обратном порядке.
func executeOrderSaga(ctx context.Context, order Order) error {
if err := reservePayment(ctx, order); err != nil { // шаг 1
return err
}
if err := reserveStock(ctx, order); err != nil { // шаг 2 — тут может не хватить товара
if errors.Is(err, ErrOutOfStock) {
refundPayment(ctx, order) // компенсация шага 1
}
return err
}
return nil
}
// Схематично: оркестратор saga вызывает шаги по очереди
// и при провале запускает компенсации в обратном порядке.
async function executeOrderSaga(order: Order): Promise<void> {
await reservePayment(order); // шаг 1
try {
await reserveStock(order); // шаг 2 — тут может не хватить товара
} catch (e) {
if (e instanceof OutOfStockError) await refundPayment(order); // компенсация шага 1
throw e;
}
}
# Схематично: оркестратор saga вызывает шаги по очереди
# и при провале запускает компенсации в обратном порядке.
async def execute_order_saga(order: Order) -> None:
await reserve_payment(order) # шаг 1
try:
await reserve_stock(order) # шаг 2 — тут может не хватить товара
except OutOfStockError:
await refund_payment(order) # компенсация шага 1
raise
В реальном BPM-движке эту логику не пишут таким кодом — её рисуют схемой, а движок сам помнит состояние и переживает перезапуски. Пример выше нужен лишь чтобы показать идею «шаг → компенсация».
Важно не унести из него неверный вывод «saga — это просто try/catch». В таком виде не решены три вопроса, и как раз они и составляют половину работы.
- Что, если упала сама компенсация?
refundPaymentтоже ходит во внешнюю систему и тоже может не ответить. Бросить исключение дальше нельзя: деньги останутся списанными. Компенсацию ставят в очередь и повторяют до успеха, а если не выходит долго — заводят задачу человеку. - Что, если шаг выполнится дважды? Повтор — штатная ситуация: ответ потерялся, а операция прошла. Каждый шаг и каждая компенсация должны быть идемпотентны — второй вызов с тем же ключом не должен списать деньги повторно.
- Где живёт состояние между шагами? В листинге оно в стеке вызовов, а значит умирает вместе с процессом. Перезапустили приложение между шагом 1 и шагом 2 — резерв платежа остался, и никто про него не помнит.
Всё это движок берёт на себя. Своими руками — пишется, но именно объём этой обвязки и есть повод задуматься о движке.
Где заканчивается транзакция
Первое, что надо знать инженеру, который подключает движок к своему сервису: ваша транзакция и процесс движка — разные вещи, и граница между ними проходит не там, где кажется.
Как это устроено у встраиваемого движка (Camunda 7 в одном приложении с вашей базой). Движок режет процесс на участки между точками ожидания: получил команду — выполнил все шаги, которые можно выполнить сразу, дошёл до места, где надо ждать (внешнее событие, таймер, задача человеку), и закоммитил. Внутри одного участка ваши записи в базу и записи движка о состоянии процесса коммитятся вместе, одной транзакцией — это большое удобство: либо шаг выполнен и процесс продвинулся, либо ни того, ни другого.
А между участками — нет. Процесс, дошедший до ожидания, зафиксирован; всё, что случится дальше, — другая транзакция. Значит, откатить «весь процесс» невозможно: откатить можно только текущий участок. Именно отсюда берётся необходимость компенсаций: отмена сделанного на предыдущих участках — это не откат, а новые действия.
Практические следствия, которые стоит знать до первого внедрения:
- Точка ожидания — граница атомарности. Хотите, чтобы два шага выполнились строго вместе, — между ними не должно быть точки ожидания. Это влияет на то, как рисуют схему: границы участков проектируют, а не получают случайно.
- Длинный участок держит транзакцию открытой. Пять шагов подряд без ожиданий — это одна транзакция на все пять, и если внутри есть обращение к внешнему сервису, транзакция висит на время сети. Лечится так же, как везде: внешние вызовы делаются отдельными шагами через механизм внешних задач, а не внутри транзакции участка.
- Ошибка в середине участка отменяет весь участок. Включая продвижение процесса: экземпляр останется в том же месте, и движок повторит участок. Значит, шаги внутри участка обязаны переносить повтор.
У отдельного движка (Camunda 8, Temporal, управляемый оркестратор) общей транзакции нет вовсе. Движок живёт в своём хранилище, ваш сервис — в своём, и между ними сеть. Отсюда два следствия, которые надо принять сразу:
- «Шаг выполнен» и «движок об этом знает» — два отдельных факта. Ваш обработчик выполнил работу, ответил движку — и ответ потерялся. Движок повторит шаг. Значит, каждый шаг обязан быть идемпотентным, и это не рекомендация, а условие работы: ключ идемпотентности берут из идентификатора шага процесса, который движок передаёт.
- Компенсация — тоже отдельный шаг со своими отказами. Компенсирующее действие может упасть, и это нормальный случай, который надо предусмотреть: повтор компенсации, а после нескольких неудач — ручной разбор, потому что компенсация, упавшая молча, оставляет систему в противоречивом состоянии.
Правило, в которое всё это сворачивается: внутри участка — транзакция, между участками — идемпотентность и компенсации. Инженер, который держит это в голове, подключает движок без сюрпризов; инженер, который ждёт транзакции на весь процесс, обнаружит её отсутствие на первом же сбое в середине.
Версии схемы и уже летящие экземпляры
Самая дорогая часть эксплуатации движка, и по этой причине от движков чаще всего и отказываются. Вопрос простой: заявка идёт три дня, за это время схему процесса поправили — что происходит с сотней заявок «в полёте»?
Как это устроено. Движок хранит версию схемы и привязывает экземпляр к той версии, с которой он начался. Значит, поведение по умолчанию такое: старые экземпляры доходят до конца по старой схеме, новые начинаются по новой. Обе версии живут в движке одновременно, и это правильное поведение — но у него есть последствия.
Что из этого следует.
- Версий накапливается много. Каждая правка схемы — новая версия, и они не удаляются, пока есть экземпляры. Через год в движке живут двадцать версий одного процесса, и это нормально; ненормально — не знать, какие ещё используются.
- Код должен поддерживать все живые версии. Схема версии 5 вызывает ваш обработчик с одним набором данных, схема версии 8 — с другим. Значит, обработчики обязаны понимать оба, пока есть экземпляры на старых версиях. Это та же совместимость N-1, что у контрактов, только сроком в длину самого долгого процесса.
- Исправление ошибки не доезжает до летящих экземпляров. Нашли в схеме ошибку — новая версия её исправит только для новых заявок. Сто заявок в полёте продолжат идти по сломанной схеме, и это самая неприятная ситуация из всех.
Что делают с летящими экземплярами. Три пути, по возрастанию цены:
- Дать дожить. Подходит, когда процесс короткий (часы) и ошибка не критична: подождали, старая версия опустела сама. Обязательное условие — знать, сколько ждать, то есть видеть число экземпляров по версиям.
- Миграция экземпляров. Движки это умеют: перевести экземпляры с версии 5 на версию 8, указав соответствие узлов («кто ждал на шаге „проверка документов“, встанет ждать на шаге „проверка документов v2“»). Работает, когда структура похожа; ломается, когда шаг исчез или изменились данные процесса. Практически это отдельная операция, которую готовят, проверяют на копии и выполняют пачками — то есть маленький проект, а не кнопка.
- Отменить и запустить заново. Грубо, но иногда единственный вариант: отменить летящие экземпляры и создать новые по новой схеме. Годится только если шаги идемпотентны (а иначе клиент получит второе письмо и второе списание) и если процесс можно перезапустить без ущерба.
Как с этим жить, чтобы не было больно. Практики, которые вырабатывают все команды на движках:
- Схема меняется совместимо, пока можно. Добавить ветку, добавить шаг после точки ожидания, изменить текст — безопасно. Убрать шаг, переставить порядок, изменить набор данных процесса — несовместимо, и это планируется отдельно.
- Данные процесса — стабильный контракт. Переменные процесса меняются по тем же правилам, что схема события: только добавлять, не переименовывать, не менять смысл.
- Число экземпляров по версиям — метрика на графике. Без неё вы не знаете, можно ли уже удалить старую схему и кто пострадает от исправления.
- Долгие процессы режут на короткие. Процесс на три месяца — источник всех перечисленных проблем; тот же путь, разбитый на три процесса по неделе со передачей через событие, версионируется несравнимо легче.
И общий вывод, который стоит сделать до внедрения: движок не отменяет работу по совместимости, а переносит её на схему процесса. Тот, кто ждёт «нарисовал и поехали», столкнётся с этим на первом же исправлении в проде.
Когда процесс зависает: отладка и разбор
Видимость — главный аргумент за движок, и стоит показать, как она выглядит в работе, а не в презентации. Сценарий обычный: заявка висит третий день, клиент звонит.
Где смотреть. У движка есть интерфейс оператора, и в нём по идентификатору заявки видно: на каком шаге экземпляр стоит, сколько он там стоит, какие шаги уже прошёл и с каким результатом, какие переменные процесса у него сейчас, и — главное — почему он стоит. Причин у стояния немного: ждёт внешнего события (которое не пришло), ждёт таймера (который ещё не сработал), ждёт задачи человека (которую никто не взял), или шаг падает с ошибкой и повторяется.
Что делает дежурный. Действия, которые есть в любом движке и которые надо уметь до аварии:
- Посмотреть последнюю ошибку шага. Инцидент в терминах движка: шаг упал столько-то раз, вот исключение. Это первое, куда смотрят.
- Повторить шаг. После починки внешней системы — перезапустить упавший шаг, не трогая остальное. Самая частая операция.
- Изменить переменные и продолжить. Данные пришли неверные (адрес с опечаткой) — поправить и повторить шаг. Мощно и опасно: это правка состояния процесса руками, и она должна быть под правами и с журналом.
- Пропустить шаг или перевести экземпляр на другой узел. Крайняя мера, когда шаг невыполним, а процесс надо довести. Требует понимания, что дальше по схеме это не сломает.
- Отменить экземпляр — с пониманием, что компенсации при отмене нужно запускать явно, если движок не сделает этого сам.
Что нужно настроить заранее, иначе видимость не сработает:
- Идентификатор бизнес-сущности как ключ экземпляра. Иначе поддержка ищет по внутреннему идентификатору движка, которого у неё нет. Номер заявки должен находить экземпляр в один поиск.
- Тревога на инциденты. Упавший шаг сам о себе не сообщает: нужен алерт на число инцидентов и на экземпляры, стоящие дольше нормы. Без этого «видимость» означает «можно посмотреть, если знать, что что-то не так».
- Осмысленные имена шагов. Схема с узлами «Task_0x4f2a» бесполезна для оператора; имена пишут словами предметной области, потому что их читает не автор.
- Права и журнал операторских действий. Правка переменных и пропуск шагов — сильные полномочия; кто это делал и зачем, должно быть видно.
И чего видимость не даёт. Движок покажет, что шаг упал с исключением, но не скажет, почему упал ваш код — за этим по-прежнему идут в журналы сервиса, и связывает их общий идентификатор. Поэтому идентификатор экземпляра процесса кладут в записи журнала своих обработчиков: без этого расследование идёт в двух несвязанных системах.
Когда своя стейт-машина, а когда BPM-движок
Главный практический вопрос: тащить ли в проект целый движок или хватит своей маленькой стейт-машины. Ответ зависит от природы процесса.
Своя стейт-машина (в коде, с состоянием в базе через enum-поле, Spring Statemachine у Java-стека или stateless у Go) подходит, когда:
- процесс живёт внутри одного сервиса, не размазан по системам;
- переходы быстрые — секунды, а не дни;
- состояние легко уложить в одну колонку
statusв вашей же таблице; - участие людей и длинных таймеров минимально;
- не нужна отдельная система для просмотра истории процессов — хватает обычных логов.
Классический пример — статус заказа: NEW → PAID → SHIPPED → DELIVERED. Пара переходов, всё в одном сервисе, состояние в поле записи. Тянуть сюда Camunda — заводить отдельный движок ради трёх строк в switch.
BPM-движок оправдан, когда:
- процесс долгий и межсервисный — идёт часами и днями через несколько сервисов;
- нужны таймеры и ожидания — «напомнить через 3 дня», «дождаться оплаты»;
- в процессе участвуют люди — согласования, ручные проверки, задачи в списке дел;
- важна видимость и история — бизнесу нужно видеть, где застряла каждая заявка и сколько времени заняли шаги;
- нужны встроенные ретраи и компенсации через оркестрацию saga.
Правило простое: короткий процесс в одном сервисе — своя стейт-машина; долгий процесс через людей, таймеры и несколько сервисов, где нужна видимость, — BPM-движок. Промежуточный случай (один сервис, но много состояний и переходов) закрывает библиотека автоматов — Spring Statemachine у Java-стека, stateless или looplab/fsm у Go; это середина между самописным перечислением и полноценным движком.
Третий вариант: свой оркестратор на таблице шагов
Между «перечисление в колонке» и «поставить движок» лежит решение, которое сегодня выбирают чаще обоих, — и его стоит назвать, потому что списки признаков выше его не видят.
Устроено оно так: у процесса есть таблица экземпляров (какой процесс, по какому объекту, на каком шаге, когда обновлён) и таблица шагов (что уже выполнено, с каким результатом, сколько было попыток). Продвигает процесс обычный обработчик: берёт из таблицы экземпляры, у которых пора выполнить следующий шаг, выполняет, записывает результат, переводит на следующий шаг. Всё в вашей базе, всё обычным кодом.
CREATE TABLE process_instance (
id uuid PRIMARY KEY,
process text NOT NULL, -- 'order_fulfillment'
business_key text NOT NULL, -- номер заказа: по нему ищет поддержка
current_step text NOT NULL,
status text NOT NULL, -- RUNNING / WAITING / FAILED / DONE
next_attempt_at timestamptz, -- когда продолжить: таймеры и повторы
attempts int NOT NULL DEFAULT 0,
payload jsonb NOT NULL, -- данные процесса
version bigint NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE process_step_log (
id bigserial PRIMARY KEY,
instance_id uuid NOT NULL REFERENCES process_instance (id),
step text NOT NULL,
outcome text NOT NULL, -- OK / RETRY / FAILED / COMPENSATED
detail text,
occurred_at timestamptz NOT NULL DEFAULT now()
);
Что это даёт, из списка возможностей движка: состояние процесса переживает перезапуск (оно в базе), таймеры и отложенные повторы (поле «когда продолжить» плюс выборка по времени), история шагов для поддержки (вторая таблица), компенсации (шаги с признаком «компенсирующий», выполняемые в обратном порядке), поиск по бизнес-ключу, метрики (возраст самого старого экземпляра, число неудачных). Раздача работы между копиями делается тем же приёмом, что в очереди задач на базе, — выборкой с пропуском заблокированных строк.
Чего он не даёт: визуальной схемы, которую читает бизнес; задач для людей со списком «что мне согласовать»; готового интерфейса оператора; параллельных ветвей с ожиданием всех; и версионирования схемы, которое придётся делать руками (номер версии в экземпляре плюс ветвление в коде).
Когда это правильный выбор — и он правильный чаще, чем думают:
- процессов один-два, а не десять;
- шаги выполняет код, а не люди;
- нужны надёжность, повторы, таймеры и история — но не нужна картинка для бизнеса;
- команда не хочет ещё одну систему в эксплуатации и ещё одну лицензию;
- процесс живёт часы-дни, а не месяцы.
Когда он перестаёт работать (и тогда пора смотреть на движок): появляются задачи для людей; процессов становится много и каждый со своими правилами; бизнес начинает просить схему и отчёты по шагам; нужны параллельные ветви и сложные ожидания; или ваш «маленький оркестратор» дорос до интерфейса оператора и версионирования — то есть вы начали писать движок.
Практическое правило: начинайте с таблицы шагов, переходите на движок по названной причине. Причина обычно одна из двух — люди в процессе или число процессов; всё остальное таблица закрывает дешевле.
Тут стоит уточнить, потому что путаница возникает часто. Spring Statemachine умеет сохранять состояние автомата в базу и восстанавливать его после перезапуска — это правда, и для автомата заказа этого достаточно. Но трёхдневному согласованию одного сохранённого состояния мало. Нет таймеров вида «через три дня без ответа напомнить». Нет задач для людей — списка «что мне надо согласовать». Нет истории прохождения, которую бизнес открывает и смотрит, где застряла заявка. Нет компенсаций с их порядком отката. Всё это пришлось бы писать вокруг библиотеки самому — и получился бы свой маленький BPM-движок.
Глубже: хореография: сага без координаторарасширенное
Раздел про сагу выше показывает оркестрацию: движок или process manager ведёт шаги. Второй способ, хореография, в статье не разобран, а именно его противопоставляют оркестратору в вопросах и берут в первых сервисах по умолчанию, потому что он «проще».
Устроен он так: координатора нет, каждый участник реагирует на события предыдущего и публикует своё. Заказ подтверждён, склад слышит и резервирует, публикует «зарезервировано», оплата слышит и списывает, публикует «оплачено», заказ слышит и переходит в «готов к отгрузке». Отказ это тоже событие: оплата публикует «отказано», склад слышит и снимает резерв, заказ слышит и отменяется. Состояние отмены живёт у каждого участника: каждый знает, что делать при чужом отказе, и делает это сам.
Плюсы честные: участники независимы, нет компонента, который знает всех, добавить ещё одного слушателя не требует менять остальных. Минусы всплывают на четвёртом шаге. Никто не видит процесс целиком: чтобы ответить «где сейчас заказ 42», нужно собрать события трёх сервисов по идентификатору корреляции. Зависшую сагу никто не замечает: склад зарезервировал, оплата не ответила вовсе (не отказала, а упала), и резерв висит, потому что событие отказа не пришло, а таймаут ждать некому. Логика отказов размазана: правило «при отказе оплаты снять резерв» живёт в складе, «при отказе резерва вернуть деньги» в оплате, и при добавлении шага все участники узнают о новом виде отказа.
Что делает хореографию рабочей. Идентификатор корреляции в каждом событии, чтобы собрать трассу процесса, о чём статья про контекст через брокер. Сторож таймаутов: задача по расписанию, которая находит заказы в промежуточном состоянии дольше N минут и публикует событие «истёк срок», на которое участники реагируют как на отказ. И проекция состояния процесса для людей: отдельная таблица, которую обновляют все события, чтобы поддержка видела, где заказ, без чтения трёх журналов.
Граница выбора: два-три шага у независимых команд, хореография; больше четырёх шагов, нужна видимость, таймеры и компенсации в обратном порядке, оркестратор, свой или движок. Часто начинают с хореографии и заводят оркестратор, когда сторож таймаутов и проекция процесса становятся половиной кода.
Коротко
- Долгие процессы (согласования, платежи, приём сотрудников) живут днями, ждут людей и таймеров, требуют ретраев и откатов — стейт-машина в памяти не переживёт перезапуск, а состояние в базе руками означает много однообразного кода. BPM-движок берёт это на себя: персистентное состояние, ожидание событий и таймеров, ретраи, история выполнения; процесс описывают исполняемой BPMN-схемой.
- Camunda превращает BPMN-схему в работающую логику; версия 7 — встраиваемая библиотека, версия 8 (на ядре Zeebe) — отдельный масштабируемый движок. Аналоги различаются подходом: Temporal — процесс как код, Zeebe — облачно-нативная оркестрация микросервисов, Flowable/Activiti и jBPM — встраиваемые BPMN-движки, AWS Step Functions — управляемый облачный оркестратор.
- В распределённой системе BPM-движок работает оркестратором saga: ведёт шаги по сервисам и запускает компенсации при неудаче — это способ достичь согласованности без общей транзакции.
- Выбор: короткий процесс в одном сервисе — своя стейт-машина; долгий межсервисный процесс с людьми, таймерами и потребностью в видимости — BPM-движок.
- Хореография это сага без координатора: участники реагируют на события и держат отмену у себя; работает на двух-трёх шагах при идентификаторе корреляции, стороже таймаутов и проекции процесса; дальше оркестратор.
- «Код, который переживает перезапуски», оплачен детерминизмом: время, случайность и любые обращения наружу — только через движок, а менять код летящего процесса нельзя без версионирования.
- Лицензия сравнивается не с нулём, а с двумя-тремя человеко-месяцами своего решения плюс эксплуатация; отдельно считают цену ухода от поставщика.
- Транзакция есть внутри участка между точками ожидания, а между участками — только идемпотентность и компенсации; у отдельного движка общей транзакции нет вовсе.
- Версии схемы — самая дорогая часть эксплуатации: экземпляр привязан к своей версии, код обязан поддерживать все живые, исправление не доезжает до летящих; их дают дожить, миграцию готовят как проект, а долгие процессы режут на короткие.
- Между перечислением в колонке и движком лежит свой оркестратор на таблице экземпляров и шагов: он даёт надёжность, таймеры, повторы, историю и компенсации, но не даёт схему для бизнеса и задачи для людей.
Что почитать дальше
- Своя стейт-машина, хранение состояния и журнал переходов: реализация в Java и в Go.
- Реализация автомата в Go — та же стейт-машина на Go: таблица переходов, библиотеки
statelessиlooplab/fsm, гонки. - Распределённые паттерны — сага, outbox и идемпотентный потребитель с кодом.
- Интеграция контекстов в DDD — process manager и события между контекстами.