О мутациях в микросервисной архитектуре
Исторический контекст
У нас было два пакетика травы монолита, развитие бизнеса и сложности работы с большими кусками кода. Команды наступали друг-другу на пятки, конфликты на реквестах и т.п. В итоге, как и многие, мы пришли к микросервисной архитектуре — стали делить бизнес-логику на отдельные сервисы сильно связанные внутри, но слабо связанные между собой. При этом каждый сервис выставляет GraphQL схему, которая мержится в единое API, к которому уже обращаются frontend-клиенты и другие сервисы.
Всё шло более-менее хорошо, пока не появилась задача выноса биллинга в отдельный микросервис с отдельной базой. Основная сложность была в том, что при разбиении транзакций требуется не только разнести INSERT-ы и UPDATE-ы, но также обеспечить их гарантированное выполнение (или не выполнение). Консистентность тут очень важна т.к. кривые записи в БД в лучшем случае грозят протухшими заказами на доске, а в худшем — тёрками с налоговой.
Есть две задачи
Надёжные мутации
Распиливая монолиты на микросервисы, мы неминуемо приносим в жертву надёжность вызовов между компонентами в пользу гибкости разработки. Проблема возникает не только в мутациях, но и при любых запросах. Однако, именно при падении мутаций с высокой вероятностью возникают битые данные — не загрузился файл, не сбросился кеш, заказ не попал в индекс, не отправилось сообщение в чат и т.п.
В первом приближении задача звучит так: реализовать и внедрить механизм, который максимально повысит надёжность мутаций в микросервисной архитектуре.
Надёжные транзакции
Эта задача по сути является продолжением предыдущей т.к. транзакция — это список мутаций, которые в результате выполнения переводят данные в некоторое консистентное состояние, но есть нюансы:
В общем случае мутации должны выполняться последовательно.
После очередного шага может возникнуть ошибка или логический останов, а значит нужно предусмотреть возможность «отката».
Данные, изменяемые в рамках транзакции, находятся в soft state и перейдут в консистентное состояние не атомарно.
Критерии оценки решения
Архитектурные задачи часто не имеют универсального решения и по-любому придётся идти на компромиссы. Поэтому важно заранее определить критерии, основанные на реальности и легаси. Вот что получилось у нас:
Development friendly
Максимальная простота для основных потребителей — разработчиков,
визуально код легко конвертируется в продуктовые сценарии и обратно,
легко заводится на стендовом и локальном окружении,
поддержка TypeScript, PHP и Python,
не создаёт сильных проблем с обратной совместимостью кода и данных,
если complexity, то в основном для авторов и оунеров решения.
Reuse oriented
Одновременно решает задачу надёжных мутаций и надёжных транзакций,
при этом максимально переиспользует существующую архитектуру и технологии.
Fault tolerant
Уменьшает связность между сервисами,
самостоятельно восстанавливается после падений и повторяет операции в случае ошибок,
старается прощать косяки разработчиков: баги не кладут всё к чертям, но система настойчиво сигнализирует о проблеме.
Safe
В случае нецелевого использования рекомендует исправить ситуацию, при этом не позволяет выйти за рамки возможностей и причинить вред,
умеет безопасно работать с персональными данными.
Предварительные варианты
Хореографическая сага
Интересно, но скорее всего нет т.к. там явно проблемы с development friendly и прозрачностью бизнес сценариев. Красивые доменные события, отличный декаплинг, возможность легко встраиваться в бизнес процесс — всё это, конечно, даёт невероятную теоретическую гибкость.
Однако, чтобы сделать достойный DX, нужно сильно упороться, не говоря уже про компенсирующие шаги, ключи идемпотентности и «наблюдателя», который, в случае чего, запустит ретрай или откат. Плюс возникают сложности «фиксации сценариев» например, для А/Б тестирования — крайне не желательно, чтобы кто-то втихаря вешал дополнительную логику во время проведения теста.
Оркестрационная сага
Кажется хорошим вариантом т.к. позволяет держать «описание» транзакции в одном месте, при этом сохраняя декаплинг на приличном уровне. Надо копать дальше и смотреть конкретные реализации.
Transactional Outbox
Техника, позволяющая «атомарно» записать данные в БД и отправить об этом событие в шину. А что если мы заменим «отправку события» на вызов API и добавим всякие retry policy? Получается что-то типа бинлога операций в БД — пишем всё, что нужно сделать, а далее выполняем операции условно «до талого». Интересно. Правда смущает то, что каждый инициатор транзакции должен иметь свою БД и message relay.
Workflow Engine
Это такие комплексные системы, которые позволяют надёжно запускать бизнес-логику в виде последовательности шагов. Обычно, говоря о workflow engine, подразумевают long running tasks, при этом «long» здесь не буквальное и может быть относительно коротким. Следовательно, можно попробовать приспособить такую штуку под задачу надёжных мутаций. Надо только выбрать сам workflow engine, которых расплодилось довольно много.
Дальнейший план
Формулируем 2–3 решения, которые наиболее полно соответствуют выбранным критериям.
Собираем прототипы, хотя бы просто «на бумаге», чтобы можно было оценить применимость решений к конкретной задаче выноса биллинга из монолита.
Пробуем масштабировать решение на другие задачи — запуск асинхронных заданий, обработка заказов и т.п.
Делаем сводную таблицу и экспертно выбираем вариант.
Подробности вариантов и финальное решение я выложу в следующих статьях. Спасибо, что дочитали!
P.S. В комментариях было бы интересно услышать про ваш опыт работы с консистентностью данных в микросервисах.