Вам нужно интегрировать несколько компонентов без помощи менеджеров транзакций с поддержкой ACID (атомарность, согласованность, изоляция и долговечность)? Тогда этот пост для вас.

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

Что такое менеджер транзакций?

Вы можете знать о транзакциях из работы с реляционными базами данных. Например, в Java вы могли бы написать (используя Spring) следующий код, использующий реляционную базу данных для обработки платежей и заказов:

@Transactional
public void processOrder(Order order) {
    paymentService.processPayment(order.getPayment());
    orderRepository.save(order);
}

Spring будет обеспечивать использование одного менеджера транзакций, который гарантирует одну атомарную операцию. Если возникает какая-либо проблема или исключение, ни платеж не сохраняется, ни заказ не отмечается как оплаченный. Это также известно как сильная согласованность, так как на уровне базы данных вы обеспечиваете, что состояние всегда согласовано. Не может быть заказа, отмеченного как оплаченный, если соответствующий платеж не сохранён в базе данных.

В Java абстракции Spring для управления транзакциями довольно распространены, так же, как и Jakarta Transactions (JTA). Это позволяет вам использовать аннотации для простого обозначения границ транзакций, такие как @Transactional, @Required, @RequiresNew. Эта модель программирования является очень удобной и считается лучшей практикой разработке программного обеспечения.

В движке процессов Camunda (в этом посте говорим про версию 7.x) мы также используем менеджеры транзакций. Поскольку вы можете запускать движок процессов как встроенную библиотеку в своём приложении, это позволяет использовать следующий дизайн (который на самом деле довольно распространён среди пользователей Camunda): движок процессов совместно использует соединение с базой данных и менеджер транзакций с бизнес-логикой. Некоторый код на Java (реализованный в виде так называемых JavaDelegates) выполняется непосредственно в контексте движка процессов, вызывая ваш бизнес-код, всё через локальные вызовы методов внутри одной Java Virtual Machine (JVM).

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

Не полагайтесь слишком сильно на менеджеры транзакций!

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

Как только вы начинаете хранить данные движка процессов в отдельной базе данных или у вас есть отдельные базы данных для микросервиса платежей и микросервиса выполнения заказов, менеджер транзакций не может больше использовать транзакции базы данных. В этот момент начинаются проблемы. Возможно, вы слышали об использовании распределённых транзакций и двухфазных коммитов. Однако эти протоколы следует считать неработающими. Я не хочу углубляться в детали, но, если вам интересно, вы можете обратиться к статье Пэта Хелланда (Pat Helland) «Жизнь за пределами распределённых транзакций: мнение отступника».

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

Практически каждой системе необходимо покинуть это уютное место взаимодействия только с одной физической базой данных. Обращаетесь ли вы к какому-либо удалённому сервису через REST? Ни один менеджер транзакций не поможет вам. Хотите использовать Apache Kafka? Никакого менеджера транзакций. Отправляете сообщения через AMQP? Ну, вы, вероятно, уже догадались, что никакого менеджера транзакций (если придерживаться моего утверждения о том, что распределённые транзакции не работают).

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

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

Я рассмотрю сценарии сбоев ниже, но в свете обсуждений так называемого паттерна внешних задач в Camunda, я хочу обозначить еще один важный момент. С внешними задачами движок процессов больше не вызывает Java-код напрямую. Вместо этого воркер подписывается на задания и выполняет их вне контекста движка. Поскольку мы также рекомендуем пользователям Camunda использовать удалённый движок, связь реализуется через REST. Воркер больше не использует транзакцию совместно с движком процессов, поэтому картина будет выглядеть немного иначе:

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

Жизнь без менеджера транзакций

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

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

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

  2. Сетевое соединение с компонентом B выходит из строя, и A получает исключение: локальный откат в компоненте A. Возможно, соединение могло бы успешно завершиться, и B мог бы зафиксировать некоторые изменения. Потенциальная несогласованность.

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

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

  5. Компонент A получил результат, который B уже зафиксировал, но не может зафиксировать свою локальную транзакцию из-за проблем. Несогласованность.

Вы также можете интерпретировать проблемы с соединением как сбои приложений в неудачный момент, что приведёт к тем же сценариям. Это реальность, с которой вам придется столкнуться. Отличная новость заключается в том, что эти сценарии на самом деле не являются большой проблемой в большинстве случаев. Наиболее распространённой стратегией, которая используется, является повторная попытка, и она использует так называемую семантику «at least once» / «хотя бы один раз».

Повторные попытки (retrying) и семантика «at least once»

Что это означает: всякий раз, когда компонент A сомневается в том, что произошло, он повторяет то, что только что сделал, до тех пор, пока не получит чёткий результат, который также может быть зафиксирован локально. Это единственный сценарий, в котором компонент A может быть уверен, что B также выполнил свою работу. Эта стратегия гарантирует, что компонент B будет вызван как минимум один раз; не может быть такого, чтобы он никогда не был вызван без чьего-либо уведомления. На самом деле, он может быть вызван несколько раз. Из-за этого у компонента B должна быть обеспечена идемпотентность.

В примере с JavaDelegate в Camunda эта стратегия может быть легко применена. Если JavaDelegate вызывает REST-эндпоинт, он будет повторять этот вызов, пока не получит успешный результат (который также может быть сообщением об ошибке, но это должен быть корректный HTTP-ответ, который чётко указывает состояние сервиса платежей). Эта функциональность встроена в движок процессов.

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

Как правило, вы обычно применяете семантику «at least once» ко всем вызовам, которые выходят за пределы границы вашей транзакции. Это также означает, что в каждой распределённой системе возникают моменты несогласованности (например, когда компонент B успешно зафиксировал что-то, но A об этом ещё не знает). Моменты несогласованности просто неизбежны в сложных системах и на самом деле не являются большой проблемой, если вы понимаете эту проблему и принимаете её. Термин, который используется для описания такого поведения, — это конечная согласованность, и его следует принимать во внимание в каждой архитектуре с определённой степенью сложности (но это тема для отдельного поста).

Одно примечание: обеспечение надёжных повторных попыток обычно включает в себя некоторую форму устойчивости. Это то, что вы получаете автоматически с Camunda, но вы также можете использовать системы обмена сообщениями (например, RabbitMQ или Apache Kafka) для этой цели.

В заключение, конечная согласованность, повторные попытки (retrying) и семантика «at least once» — это концепции, которые важно понимать в любом случае.

Ситуации из практики с Jakarta EE

Десять лет назад я работал над множеством проектов, использующих JTA. Одна из распространённых проблем, с которой мы сталкивались (и которая по-прежнему регулярно обсуждается среди пользователей Camunda), заключалась в следующем: движок процессов и некоторая бизнес-логика (в то время реализованная как Enterprise Java Beans) совместно использовали одну транзакцию. Любой компонент мог пометить транзакцию как неудавшуюся («только откат»). Это невозможно отменить. Ни один компонент в цепочке не может зафиксировать изменения после этого, что и требуется для атомарных операций.

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

Единственный способ обойти это ограничение — запустить отдельный Enterprise Java Bean, настроенный на открытие новой транзакции («requires new»). Хотя это может быть легко реализовано в вашем собственном коде, добиться подобного поведения в продукте, таком как движок процессов Camunda, который создан для работы в различных транзакционных сценариях, не так просто.

Даже если существуют решения для устранения этой проблемы, этот сценарий всё равно подчеркивает несколько важных моментов:

  • Необходимо понимать транзакционное поведение.

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

  • Ситуации с ошибками могут быть трудно диагностируемыми.

Дополнительное чтение

Чтобы более глубоко изучить транзакции и конечную согласованность, я также рекомендую ознакомиться с продвинутыми стратегиями обработки несогласованностей на уровне бизнеса. Например, прочитайте девятую главу книги Practical Process Automation и изучите паттерн сага, который особенно интересен. Он демонстрирует, что многие решения о согласованности на самом деле должны быть подняты на уровень бизнеса.

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

Заключение

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

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

Подписывайтесь на Telegram канал BPM Developers. Рассказываем про бизнес процессы: новости, гайды,  полезная информация и юмор.

5 февраля в 14:00 мск пройдет презентация новой open source платформы OpenBPM. Регистрируйтесь и приходите.

Комментарии (0)