Исторический контекст

У нас в Профи было два пакетика травы монолита, развитие бизнеса и сложности работы с большими кусками кода. Команды наступали друг-другу на пятки, конфликты на реквестах и т.п. В итоге, как и многие, мы пришли к микросервисной архитектуре — стали делить бизнес-логику на отдельные сервисы сильно связанные внутри, но слабо связанные между собой. При этом каждый сервис выставляет 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. В комментариях было бы интересно услышать про ваш опыт работы с консистентностью данных в микросервисах.

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


  1. Sipaha
    26.10.2023 17:27
    +1

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


    1. em1nx Автор
      26.10.2023 17:27

      Было бы интересно почитать подробности :) Обычно же как раз не советуют использовать 2PC.


  1. olku
    26.10.2023 17:27
    +1

    Вот более обстоятельная статья, опубликованная четыре дня назад - https://habr.com/ru/articles/769102/


    1. ogregor
      26.10.2023 17:27

      Там как раз про 2pc только вводные


  1. titan_pc
    26.10.2023 17:27
    -1

    Коротко о транзакциях и их распределении - берёшь и выкидываешь их нафиг. На секунду помешаешь в голову мысль, что человечество их ещё не изобрело. Стало страшно? Поборол страх, поместил эту мысль ещё раз, теперь на минуту. Подумал хм, а может быть собрать всё как-то иначе. И придумал какую-нибудь квантовую асинхронную мульти-супер позицию состояния данных. Родил что-то новое в общем.

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


    1. em1nx Автор
      26.10.2023 17:27
      +1

      Транзакция здесь — не способ построения, а абстракция от реального бизнес-процесса покупка->товар, например. Без транзакции ты физически можешь потерять данные о покупке или товаре и тогда никакие кеши тебя не спасут— спасёт только философия :) Типа, извини, друг, не повезло — потеряли мы твой "товар" и это есть суть вещей. Низведём же в конструкте человеческого сознания страх потери!


      1. titan_pc
        26.10.2023 17:27
        +2

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

        Зайдём с другого угла. Ну был у тебя монолит, разделил его на сервисы. Один сервис - одна бд. Вроде всё сделал правильно, НО именно путь один сервис - одна бд и привёл тебя к необходимости распределения транзакций. Ты в итоге не туда свернул и решил проблему У. Вместо решения проблемы Х. То есть тебя что-то не устраивало в монолите и ты его поменял, словил проблему ХУ.

        Ты просто вспомни свой монолит. Наверное там не нужны были всякие саги... Но почему не нужны были? Вот и ключ. А сделай ты одну бд и пусть все сервисы пишут туда и смотрят туда. И сделай кое что весьма полезное одна сущность - один запрос на запись в бд. Если запрос не удался, то и ладно ты ничего не потерял. А если удался - то всё круто, все данные за один запрос зашли куда надо.

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


        1. peterpro
          26.10.2023 17:27
          +2

          И на любой чих регрессим весь монолит. И релизы собираем полгода.


          1. LbISS
            26.10.2023 17:27

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

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