Боль согласованности в UseCase 25.12.2023

Что такое UseCase?

В общем случае UseCase переводят как «вариант использования». Это некий сценарий, где описывается взаимодействие участников друг с другом. Или, проще говоря, конкретный бизнес-процесс.

Когда мы говорим про предметно-ориентированное проектирование, UseCase бывают двух уровней: доменные (domain) и прикладные (application).

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

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

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

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

Прикладные UseCase

Совершенно не важно, как выглядят наши прикладные службы для прикладных UseCase, будь то обработчик команды при использовании CQRS и шины команд или отдельная сервис-функция. Важно, чтобы все изменения, совершенные в рамках этого бизнес-процесса, попали в хранилище единовременно. Мы также ожидаем, что если произойдет какая-либо ошибка, в хранилище не будет прерванных на середине изменений. Нам нужна согласованность (консистентность) данных.

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

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

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

Для обеспечения механизма транзакционности можно использовать любой доступный инструмент и, в идеале, он должен быть отделен от ядра приложения (application core), потому что транзакции — это про инфраструктуру (secondary adapters). Если мы используем шину команд, то в ней можно инкапсулировать как саму транзакцию, так и много других инфраструктурных вещей, например очистку внутренней кучи (heap) EntityManager после фиксации изменений в хранилище.

Немного о потоках

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

EntityManager, а конкретно Unit of Work в нем сам по себе не является потокобезопасным. Это значит, что, если сторонний поток получит доступ на изменение (а изменение внутри EntityManager происходит практически всегда), то второй поток также получит эти изменения. И это, в свою очередь, приведет к тому, что первый поток отработает не так, как предполагалось. Да и второй, вполне вероятно, тоже.

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

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

Вернемся к нашим UseCase

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

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

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

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

Звучит очень классно, но так ли все радужно на самом деле?

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

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

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

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

Итоги

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

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

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

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

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

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

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

Но, к сожалению, бывают низкопроизводительные системы без согласованности данных.