Путеводитель по техническим транзакциям с Camunda 8 и Spring

Интересно, как работают технические транзакции с Camunda и фреймворком Spring? Узнайте больше о транзакционном поведении в этом блоге.

Мы регулярно отвечаем на вопросы о том, как работают технические транзакции при использовании Camunda (в последней версии 8.x) и фреймворка Spring. Например, что происходит, если у вас есть две сервисные задачи, и второй вызов завершается с ошибкой? В этом блоге я опишу типичные сценарии, чтобы сделать поведение более наглядным. Я буду использовать примеры кода на Java 17, Camunda 8.3, Spring Zeebe 8.3, Spring Boot 2.7 и Spring Framework 5.3.

Давайте возьмем простой BPMN процесс ниже:

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 1

У каждой сервисной задачи есть воркер, и каждый воркер будет записывать две разных JPA-сущности в одну базу данных, используя два разных Spring Data репозитория:

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 2

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

Сценарий A: Воркер вызывает репозитории напрямую

Воркер является Spring-бином и получает репозитории через инжекцию зависимостей. Воркер использует эти репозитории для сохранения новых сущностей:

@Autowired
private SpringRepository1 repository1;
@Autowired
private SpringRepository2 repository2;

@JobWorker(type = "taskA")
public void executeServiceALogic() {
  repository1.save(new EntityA());
  repository2.save(new EntityB());
}

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

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 3

Завершение задания в Zeebe происходит после того, как обе транзакции зафиксированы. Zeebe не нужен менеджер транзакций.

Если вам больше нравятся диаграммы последовательности (sequence diagrams), вы можете увидеть ту же информацию в другом формате:

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 4

Что это значит для вас? Чтобы понять последствия транзакционного поведения, необходимо рассмотреть кейсы с ошибками. В приведенном выше примере могут возникнуть следующие интересные сценарии ошибок:

  1. Воркер падает после активации задания.

  2. Воркер падает после того, как первый репозиторий успешно сохранил свою сущность.

  3. Воркер падает после того, как второй репозиторий успешно сохранил свою сущность.

  4. Что-то падает после того, как команда о завершении задания была отправлена в Zeebe.

Варианты ошибок показаны на диаграмме ниже:

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 5

Давайте рассмотрим эти сценарии по очереди.

#1 Воркер падает после активации задания

На данный момент ничего особенного не произошло. Задание заблокировано для конкретного работника задач на некоторое время, но после истечения этого времени задание будет подхвачено любым другим экземпляром воркера. Это нормальная попытка повторного выполнения задания, и в этом нет никаких проблем.

#2 Воркер падает после того, как первый репозиторий успешно сохранил свою сущность

Транзакция в Repository1 уже зафиксирована, поэтому EntityA уже сохранена в базе данных. Это не будет отменено. Поскольку воркер упал, EntityB никогда не будет записана, и задание не будет завершено в Zeebe. Теперь логика повторного выполнения Zeebe обеспечит, что другое задание будет выполнено снова (после истечения времени блокировки).

Это имеет два важных последствия:

  1. Из-за повторной попытки метод repository1.save будет вызван снова. Это означает, что мы должны убедиться, что это не создаст проблем, то есть, должна быть обеспечена идемпотентность. Мы вернемся к этому позже.

  2. У нас может быть несогласованное состояние бизнеса в течение (короткого) периода времени, так как бизнес может ожидать, что EntityA и EntityB всегда должны существовать вместе. Рассмотрим более актуальный для бизнеса пример, когда вы можете вычесть кредитные баллы в первой транзакции для продления подписки во второй транзакции. Несогласованность заключается в том, что у клиента уменьшилось количество кредитов, но старая подписка осталась прежней. Это также известно как конечная согласованность и является типичной проблемой в средах микросервисов. Суть в том, что у вас есть две возможности: (1) решить, что это неприемлемо, и скорректировать границы вашей транзакции, о чем я расскажу позже, или (2) смириться с этой несогласованностью, так как повторные попытки гарантируют, что она будет разрешена в конечном итоге.

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

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

Идемпотентность

Давайте вернемся к идемпотентности. Я вижу два простых способа решить эту проблему (также смотрите на три распространенные ошибки в интеграции микросервисов — и как их избежать):

  • Естественная идемпотентность. Некоторые методы можно вызывать столько раз, сколько вам нужно, потому что они просто изменяют какое-то состояние. Пример: confirmCustomer().

  • Бизнес-идемпотентность. Иногда у вас есть бизнес-идентификаторы, которые позволяют обнаруживать дублирующие вызовы. Пример: createCustomer(email).

  • Если эти подходы не сработают, вам нужно будет добавить собственную обработку идемпотентности:

  • Уникальный идентификатор. Вы можете сгенерировать уникальный идентификатор и добавить его к вызову. Пример: charge(transactionId, amount). Он должен быть создан на раннем этапе цепочки вызовов.

  • Хэш запроса. Если вы используете обмен сообщениями, вы можете сделать то же самое, храня хэши ваших сообщений.

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

@JobWorker(type = "taskA-alternative-idempotent")
public void executeServiceALogic(@Variable String someRequestUuid) {
  repository1.save(new EntityA().setSomeUuid(someRequestUuid));
  repository2.save(new EntityB().setSomeUuid(someRequestUuid));
}

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

#3 Воркер падает после того, как второй репозиторий успешно сохранил свою сущность

Это очень похоже на сценарий #2, но на этот раз обе сущности были записаны в базу данных до сбоя. Таким образом, при повторной попытке оба вызова будут выполнены снова. Поэтому вызов к repository2 должен быть идемпотентным.

#4 Воркер или сеть падает после того, как команда о завершении задания была отправлена в Zeebe

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

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

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

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

Сценарий B: JobWorker вызывает @Transactional бин

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

@Autowired
private TransactionalBean transactionalBean;

@JobWorker(type = "taskB")
public void executeServiceBLogic() {
  transactionalBean.doSomeBusinessStuff();
}

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

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

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 6

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

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

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

Сценарий C: Завершение задания происходит в границах транзакции

Теперь давайте рассмотрим третий возможный сценарий: вы можете отключить автоматическое завершение задания Spring Zeebe, но выполнить вызов API для завершения задания самостоятельно. Теперь вы можете влиять на точный момент времени, когда этот вызов выполняется, что позволяет вызывать его из вашего транзакционного бина.

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 7

Для сценариев ошибок 2 и 3, упомянутых выше (воркер падает после добавления сущности A или B), ничего не изменилось: ошибка приведет к обычному откату, ничего не произошло, и повторные попытки все уладят.

Но рассмотрим сценарий ошибки 4, где поведение меняется значительно. Предположим, команда о завершении задания была правильно зафиксирована в процессном движке, но сеть не смогла доставить результат обратно в ваше клиентское приложение. В этом случае блокирующий вызов completeCommand.send().join() приведет к исключению. Это, в свою очередь, приведет к прерыванию и откату транзакции Spring. Это означает, что сущности не будут записаны в базу данных.

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

«По крайней мере один раз» против «не более одного раза»

Итак, мы только что изменили поведение на то, что известно как семантика at-most-once semantic («не более одного раза»): мы можем гарантировать, что бизнес-логика будет вызвана один раз, но не больше. Загвоздка в том, что она может никогда не быть вызвана (в противном случае она была бы вызвана ровно один раз).

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

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 8

Вы можете вернуться к вопросу о достижении согласованности без менеджеров транзакций, чтобы узнать больше о семантиках at-least-once и at-most-once, а также о том, почему exactly-once («ровно один раз») не является практическим способом достижения согласованности в типичных распределенных системах.

Обратите внимание, что есть еще одно интересное последствие сценариев at-most-once, которое, вероятно, неочевидно: процессный движок может продолжить выполнение до того, как бизнес-логика будет зафиксирована. Таким образом, в приведенном выше примере воркер для сервисной задачи B может быть фактически запущен до того, как изменения сервисной задачи A будут зафиксированы, например, видимые в базе данных. Если B ожидает увидеть данные там, это может привести к проблемам, о которых вам следует знать.

Подводя итог, это изменение может не иметь смысла в нашем примере. Сценарии для семантики at-most-once действительно редки; одним из примеров могут быть уведомления клиентов, которые вы предпочли бы потерять, чем отправлять их несколько раз и запутывать клиента. По умолчанию используется семантика at-least-once, поэтому автоматическое завершение Spring Zeebe также имеет смысл.

Размышления о границах транзакций

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

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 9

Рисунок уже дает вам подсказку; это не только технически невозможно с Camunda 8, но и нежелательно. Как упоминалось, Camunda не понимает никакие транзакции, поэтому это невозможно. Но позвольте мне быстро объяснить, почему вы также не захотите этого.

1.           Это связывает модель процесса с технической средой

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

Путеводитель по техническим транзакциям с Camunda 8 и Spring - 10

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

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

2.           Это возможно только в крайних случаях

Такая транзакционная интеграция будет работать только тогда, когда вы используете компоненты, которые либо работают только в одной базе данных, либо поддерживают двухфазный коммит, также известный как XA-транзакции (Extended Architecture Transactions). В этот момент я хочу процитировать Пата Хелланда из Amazon: «Взрослые не используют распределенные транзакции». Распределенные транзакции не масштабируются, и многие современные системы все равно не обеспечивают поддержку этого (например, подумайте о REST, Apache Kafka или AWS SQS). Подводя итог: в реальной жизни я не вижу успешного использования XA-транзакций в распределенных системах.

Если вас интересуют такие обсуждения, сообщество, занимающееся the domain-driven design или микросервисами, имеет много материалов по этой теме. В статье «Lost in transaction» я также рассматриваю, как определить границы согласованности (= транзакции), которые обычно связаны с одним доменом. Переведя это на рассматриваемую проблему, я бы утверждал, что если что-то должно происходить транзакционно, это, вероятно, должно происходить в одном вызове сервиса, что сводится к одному транзакционному Spring бину. Я понимаю, что это может немного упростить ситуацию — но общая направленность мысли полезна.

Резюме

Итак, это было много информации, давайте подведем итоги:

  1. Camunda 8 не участвует в транзакциях, управляемых Spring. Это обычно приводит к семантике «at-least-once« из-за повторных попыток движка.

  2. Вы можете иметь транзакционное поведение в рамках одной сервисной задачи. Поэтому делегируйте выполнение в метод @Transactional в вашем собственном Spring бине.

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

  4. Сценарии ошибок по-прежнему четко определены и могут быть обработаны; отказ от использования XA-транзакций и двухфазного коммита не означает, что мы возвращаемся к хаосу!

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

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

Автор: stas_makarov

Источник

Оставить комментарий