Мы в компании Леруа Мерлен активно используем микросервисную архитектуру для построения нашего IT-ландшафта. Для начала я бы хотел рассказать какую проблему мы решаем с помощью микросервисов. Для этого рассмотрим пример.

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

  • при расчете места под хранение на складе;

  • при планировании транспорта для доставки от поставщика

  • при планировании доставки клиенту;

  • при расчете себестоимости товара;

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

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

Таким образом, данные становятся пригодными для повторного использования в рамках множества систем/бизнес-процессов, а микросервисы можно разрабатывать и масштабировать независимо.

Распределенные транзакции

Рисунок 1 - Саги

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

Существует два основных подхода организации взаимодействия микросервисов: оркестрация и хореография.

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

Гарантии согласованности данных

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

Atomicity теперь подразумевает атомарность в рамках одного шага саги, поскольку один шаг саги изменяет данные только в одном микросервисе и изменяет их атомарно.

Consistency теперь для нас значит eventually consistency, т.е. в отдельный момент времени данные могут быть неконсистентны, но через какое-то время данные должны прийти в целостное состояние

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

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

Хореография

Рисунок 2 - Хореография

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

У этого подхода есть свои преимущества, например, производительность (низкий latency). Однако, у данного подхода есть существенный минус - сага размазана между различными микросервисами, ее сложно разрабатывать, отлаживать и поддерживать. Поэтому в некоторых случаях оркестрация является более выгодным решением. В особенности это проявляется когда взаимодействующих микросервисов много.

Поскольку цель этой статьи рассказать об использовании оркестраторов, то далее я сосредоточусь на паттерне оркестрации.

Оркестрация

Рисунок 3 - Оркестрация

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

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

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

Camunda и bpmn-нотация

Рисунок 4 - Bpmn-нотация

Camunda - это платформа для автоматизации бизнес-процессов и принятия решений, поддерживающая исполнение бизнес-процессов, описанных в BPMN-нотации. Camunda написана на Java, хранит состояние в БД (можно использовать несколько вариантов БД, мы используем Postgres). BPMN-нотация позволяет описывать бизнес-процессы с помощью схем. Элементы схемы показаны слева, это далеко не полный список доступных элементов. Из этих элементов можно собрать сагу и отдать ее на исполнение Camunda, она же, в свою очередь, будет исполнять эту схему по очереди запуская обработчики, сверять статусы и выполнять переходы.

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

На рисунке 4 справа показана одна такая очень простая сага, которая отвечает за выполнение двух задач:

  • создание транспортной заявки

  • запуск бизнес-процесса для созданной заявки;

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

Передача данных между обработчиками

Рисунок 5 - Передача данных между обработчиками

Теперь рассмотрим подробнее взаимодействие обработчиков serviceTask-ов. Поскольку serviceTask-и могут быть зависимы между собой, им необходимо обмениваться данными. Сервисный таск при получении задачи на исполнение, получает также и набор переменных variables. Он может читать данные из variables и по завершении  писать данные в variables и отправлять обратно в Camunda. Эти данные будут передаваться дальше по процессу другим сервисным таскам. Т.е. они будут существовать на протяжении всей жизни процесса.

Запуск Camunda-процесса из Java-кода

Рисунок 6 - Запуск процесса из Java-кода

Рассмотрим как будет исполняться сага если бы мы разрабатывали оркестратор на Java встраиваемый в Camunda. Мы должны были бы создать SpringBoot-приложение, в которое интегрировалась бы Camunda, написать два Java-класса-делегата, указать их названия в интерфейсе Camunda-modeler-а в качестве делегатов serviceTask-ов, создать роут, который будет принимать пользовательский запрос в SpringBoot-приложении и запускать в нем процесс Camunda. Как это будет выполняться показано на рисунке. После запуска процесса, Camunda запустит первый serviceTask, он отработает, затем второй, после успешной работы управление вернется обратно в Java-код.

Такой подход к разработке оркестратора в виде Standalone-приложения возможен только на Java (JVM-языках). Для остальных же языков необходимо поднимать Camunda как отдельное приложение и использовать ServiceTask-и с типом external, о которых поговорим ниже. В принципе, ничего не мешает таким же образом работать и на Java-приложениях, чтобы масштабировать по отдельности Camunda и Java-приложение с обработчиками тасков.

Запуск Camunda-процесса через HTTP

Рисунок 7 - Запуск процесса через HTTP

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

Camunda в таком случае должна быть запущена как отдельное приложение. В camunda-modeler-е вы указывается в качестве типа serviceTask “external” и указываются названия топиков для ServiceTask-ов. Топик - это такая очередь, в которую Camunda при попадании на external serviceTask пишет сообщение.

Отдельно поднимается nodejs-приложение, которое подписывается на каждый из топиков своим обработчиком. Взаимодействие между Camunda и приложением происходит через longPolling, одновременно можно получать множество задач на исполнение. Оркестратор на nodejs принимает запрос от клиента и через POST-запрос запускает процесс в Camunda на исполнение и тут же получает ответ. Затем обработчик получает задачу на создание транспортной заявки, запускает код с бизнес-логикой, в нашем случае - создание транспортной заявки, при завершении обработки отправляет Camunda сообщение об успешной обработке, после чего аналогичным образом запускается следующий обработчик. 

Как мы видим, тут есть очень существенное отличие от работы из Java-кода. Если в случае с Java-кодом мы запускаем процесс и его исполнение происходит синхронно, то в случае с nodejs, исполнение происходит асинхронно. Мы отправляем POST-запрос, он запускает процесс, видит, что первый serviceTask является external, кладет сообщение в топик и тут же отвечает нам в Response, что процесс запущен.

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

К сожалению, подписки на событие завершения процесса в Camunda нет и это большая боль.

Запуск Camunda-процесса через HTTP с ожиданием завершения

Рисунок 8 - Запуск процесса через HTTP + ожидание завершения

Самое простое решение этой проблемы в лоб выглядит так: мы пытаемся опрашивать Camunda на предмет статуса процесса. При запуске процесса мы получаем уникальный идентификатор процесса (но также можем и задавать его самостоятельно в виде businessKey). Как только статус процесса изменится на complete, это будет значить что процесс отработал.

Это решение рабочее, но создает излишнюю нагрузку на Camunda, поскольку запуск каждого процесса плодит множество запросов на проверку статуса, а мы запускаем параллельно множество процессов. Хотелось бы иметь более оптимальное решение, поэтому рассмотрим альтернативный вариант.

Запуск Camunda-процесса через HTTP с ожиданием завершения v2

Рисунок 9 - Запуск процесса через HTTP с ожиданием завершения v2

Будем использовать redis в качестве хранителя статуса и механизма оповещения. Перед тем, как запустить процесс в Camunda, оркестратор создает запись в redis. Сам процесс же в Camunda модифицируется таким образом, что в самом конце добавляется еще один обработчик sendMessage, который должен оповестить redis о завершении исполнения процесса. Оркестратор же может подписаться на изменение записи и при проставлении флага завершения из обработчика, он будет узнавать об этом и сможет ответить клиенту информацией о результате исполнения процесса-саги.

Давайте рассмотрим всю последовательность действий, которая происходит на уровне оркестратора при исполнении операции создания транспортной заявки.

  • оркестратор принимает post запрос с параметрами заявки;

  • далее он генерирует uid и создает запись с таким uid в качестве ключа в redis и подписывается у redis-а на изменение ключа uid;

  • затем оркестратор запускает на исполнение процесс в Camunda, указывая в качестве ключа сгенерированный uid;

  • обработчик external serviceTask-а "Create Transport Order" получает задание на исполнение, выполняет запрос в object-сервис и создает сущность транспортной заявки;

  • после этого обработчик external serviceTask-а "Start transport order process" получает задание на исполнение и запускает процесс по согласованию транспортной заявки;

  • обработчик sendMessage получает сообщение с businessKey, отправляется в redis и помечает соответствующее ключу businessKey значение на completed;

  • оркестратор получает от redis-а уведомление о том, что значение изменилось и отвечает клиенту статусом 201;

Обработчики исключений

Рисунок 10 - Обработчики исключений

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

Когда обработчик serviceTask-и получает запрос на обработку, он может его успешно обработать, но может и отреагировать ошибкой.

Camunda предоставляет два основных типа исключений:

  • HandleBpmnError;

  • handleFailure;

Поговорим о каждом из них подробнее.

Ситуация №1

Рассмотрим очень простой процесс, для которого не определены обработчики исключений.

Допустим на первом же serviceTask-е возникает ошибка.

- Если мы используем HandleBpmnError, процесс сразу же завершится, в хранилище incidents упадет сообщение об ошибке. Также мы можем при генерации handleBpmnError положить ошибку в variables.

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

Ситуация №2

Следующий кейс - на serviceTask навешен обработчик ошибок.

  • в случае handleFailure все отработает точно также как и раньше, перехода по новой ветке не возникнет

  • а вот в случае с handleBpmnError произойдет переход по альтернативной ветке (на которую указывает обработчик исключений. При этом, в следующем сервисном таске будут доступны variables и инциденты и в случае необходимости можно получить информацию о них.

Есть и другие обработчики исключений, но для externalTask-ов они не работают, поэтому в этой статье они не рассматриваются.

Компенсация выполненной операции

Что ж, мы подобрались к самой интересной теме - компенсации.

Рисунок 11 - Работа с компенсациями

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

Для этого мы можем воспользоваться механизмом BPMN-исключений, которые нам предоставляет Camunda и пустить ход выполнения  процесса по альтернативной ветке, как показано на слайде.

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

Давайте посмотрим как будет выглядеть такая сага.

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

Полная последовательность шагов при этом выглядит следующим образом:

  • контроллер в оркестраторе принимает post запрос с параметрами transportId и transportRequestId;

  • далее он генерирует uid и создает запись pending с uid в качестве ключа в redis и подписывается в redis-е на изменение ключа uid;

  • затем запускает на исполнение процесс в Camunda, указывая в качестве businessKey сгенерированный uid;

  • обработчик external serviceTask-а "Book Transport" получает задание на исполнение, выполняет запрос в object-сервис с транспортом и бронирует транспорт на указанное время;

  • обработчик serviceTask-а "close Transport Request" получает задание на исполнение, выполняет запрос в микросервис с транспортными заявками и видит, что заявка уже закрыта. В этом случае он кладет в variables переменную с именем error и генерирует исключение;

  • обработчик исключений на уровне процессов Camunda ловит это исключение и отправляет исполнение по альтернативной ветке;

  • вызывается serviceTask "Book Transport Revert" и он удаляет бронь транспорта для данной transportRequestId;

  • и, наконец, вызывается обработчик sendMessage, он проверяет наличие переменной error, видит, что она есть и отправляет в redis значение по ключу businessKey ошибку в виде json-объекта;

  • контроллер, который подписался на изменения в redis для данного ключа получает ошибку и отправляет её клиенту;

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

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

Если присмотреться внимательно к этому слайду, мы можем увидеть здесь проблему. Сначала транспорт перешел в статус "Забронирован", а потом вышел из этого статуса. А что делать если мы должны как-то реагировать на такие сообщения и отсылать их в другую систему? Что тогда? Отправлять туда сообщения об отмене? Или же допустим, в подобной схеме после первого сервисного таска, кто-то изменил сущность дальше по процессу, что делать? Как теперь в Book Transport Revert не откатить новые изменения?

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

Компенсация выполненной операции v2

Рисунок 12 - Резервирование

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

И также важно помнить: как только в системе появляются lock-и, вместе с ними появляются и deadlock-и. Важно уметь мониторить заявки, зависшие в статусе reserved на длительное время и эскалировать инциденты об этом.

Retry-политика на случай частичных отказов

Рисунок 13 - Ретраи

И последний пример. При создании транспортной заявки мы должны одновременно запустить бизнес-процесс по этой заявке.

Поскольку эти две операции выполняются в разных сервисах, это тоже будет распределенной транзакцией.

  • на первом этапе в сервисе с транспортными заявками мы создаем сущность заявки и кладем идентификтор созданной заявки в variables;

  • на втором мы запускаем процесс в Camunda и в качестве businessKey указываем идентификатор созданной заявки;

  Однако, здесь отсутствует компенсирующее действие. Дело в том, что нет никакой объективной причины почему процесс может быть не запущен. Внутри обработчика external serviceTask-а мы также выполняем POST-запрос в Camunda для запуска процесса. Если нам в ответ пришел не 200-ый статус, а 503-ий, мы можем сгенерировать не исключения выхода, а исключение, которое запустит serviceTask через определенный интервал времени повторно с помощью handleFailure. Таким образом, мы можем записать сообщение в лог и запланировать повторное выполнение задачи.

  Далее возможны несколько сценариев:

  • после нескольких повторных запусков ей все же удастся выполниться (например, восстановится сеть или поднимется сервис);

  • дежурный посмотрит alert, вмешается и устранит причину проблемы;

  • исчерпается количество попыток повторного запуска и процесс станет на паузу, ожидая ручного вмешательства дежурного;

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

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

Zeebe как альтенратива Camunda

Когда мы проводили исследование оркестраторов, наиболее перспективным решением выглядела Camunda. Однако, то, что она довольно неудобно и неэффективно работает с другими языками кроме Java, вызывало некоторые страдание. Надо отдать должное разработчикам Camunda, они тоже это хорошо понимали и поэтому начали работать над альтернативным решением, лишенным этих недостатков. Так появился Zeebe. Zeebe - это тоже оркестратор, но заточенный именно под оркестрацию микросервисов. У него очень бедный набор BPMN-инструкций, которые он может выполнять, но зато он хорошо масштабируется, рассчитан на высокие нагрузки, взаимодействует через grpc и умеет отдавать результаты работы процессов без дополнительных хаков. Zeebe получил статус стабильной версии только этой весной и пока что в проде мы его не использовали, но он выглядит как отличный кандидат для дальнейшего использования.

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