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

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

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

У некоторых в команде уже был опыт работы с xa-транзакциями.
Довольно удобный способ, когда у вас есть несколько ACID СУБД. Процесс состоит из двух фаз.

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

Во второй фазе происходит сбор ответов от всех участников и решение коммита или отката.

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

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

Также при росте участников падает производительность. Чем больше мы горизонтально масштабируем нашу систему, тем больше будет падать производительность.

С saga мы были знакомы в теории из внешних источников, ни разу не сталкиваясь на практике.

Общее определение алгоритма звучит так: «Сага —это алгоритм, обеспечивающий согласованность данных в микросервисной архитектуре».

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

Фреймворки, имплементирующие паттерн Сага (Saga)

А вот примеры приложений с использованием саги:

Как видите, проблема не новая, и так или иначе люди ищут пути ее решения. Eventuate Tram завязан сильно на Kafka, Axon тянет очень много проприетарной логики, некоторые требовали поднятия своего сервера. Проблемы, которые несли данные фреймворки, мы не готовы были решать в тот момент времени, нам нужно было простое решение, удовлетворяющее  требованиям и свободное от вендор-лока.

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

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

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

  • сложность настройки (введение нового шага или новой саги требовали ручных действий как в базе данных, так и в коде);

  • чтобы понять процесс, нужно было посмотреть в разных источниках — файлах и таблицах;

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

А еще нам хотелось бы иметь это решение в виде библиотеки для переиспользования в разных продуктах

Запланировали рефакторинг и поискали, что есть в сети по Saga,

Оказалось, что наиболее распространенные реализации Saga сделаны по следующим принципам.

Варианты саги

Хореография (Choreography)

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

Достоинства:

  • Микросервисы, участвующие в саге, не должны знать обо всех участниках саги.

  • Обеспечивает принцип слабой связанности.

Недостатки:

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

  • Возможность появления циклических зависимостей.

  • Трудно понять, на каком шаге выполнения находится сага.

  • Не может быть гарантирован порядок выполнения компенсационных (откатных) транзакций.

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

Оркестрация (Orchestration)

Централизованный подход к управлению сагой, сервис оркестратор управляет и координирует процесс выполнения саги.

Достоинства:

  • Позволяет устранить недостатки, присущие методу хореографии

  • Гораздо проще понять, на каком шаге выполнения находится сага

  • Гораздо проще поменять порядок действий в саге

  • Гораздо проще код в микросервисах-участниках саги

Недостатки:

  • Есть единый центр координации саги и, как следствие, единая точка отказа

Метод оркестрации по нашему опыту хорошо показал себя для большинства случаев практического применения саги.

class ApplicationSagaDefinition () : SagaDefinition() {
    init {
        sagaDefinition {
            step {
                state = ApplicationSagaState.PREFILL
                transitions {
                    transition {
                        event = ApplicationSagaEventType.APP_CREATED
                        action = creditAppPrefillAction
                        transitionAction = TransitionAction { _, _ ->
                            ApplicationSagaState.APP_VALIDATE
                        }
                    }
                    transition {
                        event = ApplicationSagaEventType.APP_FAILED
                        action = failApplicationAction
                        transitionAction = TransitionAction { _, _ ->
                            ApplicationSagaState.FINAL
                        }
                    }
                }
                catch {
                    withCondition {
                        errorCondition = { it.isRetrievable() }
                        recoveryPolicy = defaultRecoveryProgressivePolicy()
                        recoveryAction = failedApplicationRecoveryAction
                    }
                    withCondition {
                        errorCondition = { !it.isRetrievable() }
                        recoveryPolicy = RecoveryPolicy.None
                        recoveryAction = failedApplicationRecoveryAction
                    }
                }
            }
            step {
                state = ApplicationSagaState.APP_VALIDATE
                transitions {
                    transition {
                        event = ApplicationSagaEventType.CREDIT_APP_PREFILLED
                        action = validateApplicationAction
                        transitionAction = TransitionAction { _, _ ->
                            ApplicationPrefillingSagaState.SCORING_APPLICATION
                        }
                    }
                    transition {
                        event = ApplicationSagaEventType.APP_FAILED
                        action = failApplicationAction
                        transitionAction = TransitionAction { _, _ ->
                            ApplicationSagaState.FINAL
                        }
                    }
                }
                catch {
                    withCondition {
                        errorCondition = { it.isRetrievable() }
                        recoveryPolicy = defaultRecoveryProgressivePolicy()
                        recoveryAction = failedApplicationRecoveryAction
                    }
                    withCondition {
                        errorCondition = { !it.isRetrievable() }
                        recoveryPolicy = RecoveryPolicy.None
                        recoveryAction = failedApplicationRecoveryAction
                    }
                }
            }

Параллельное выполнение саг

Саги не гарантируют изоляцию (I в ACID) и, как следствие, действия, выполненные в шагах саги, становятся тут же видны другим сагам, которые могут выполняться параллельно. Для предотвращения потенциальных проблем, связанных с отсутствием свойства изоляции в сагах, нужно предпринимать дополнительные меры.

Возможные варианты методов по обеспечению изоляции в сагах

Замыкания (Short-circuiting)

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

Блокировки (Locking)

Сага устанавливает блокировки на те объекты, которые она изменяет в БД, чтобы предотвратить доступ других саг к ним. Возникает опасность возникновения дедлоков.

Прерывание (Interruption)

Прерывание выполнения саги в случае обнаружения, что другая сага (которая еще не закончилась) уже сделала изменения в объектах БД. В отличие от метода блокировок, тут нет опасности возникновения дедлоков.

Предотвращение аномалий в транзакциях саги

Возможные проблемы/аномалии при параллельном выполнении саг:

  • Lost update. Одна сага переписывает изменения, внесенные другой, не читая их.

  • Dirty read. Сага читает незавершенные обновления другой саги

  • Unrepeatable read. Разные этапы саги читают одни и те же данные, но получают разные результаты, так как были внесены изменения другой сагой.

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

Рекомендованные контрмеры

  • Семантическое блокирование на уровне приложения. Например, введение статусной модели.

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

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

  • Повторное чтение значений при выполнении транзакции — дополнительная проверка, чтобы убедиться, что данные не изменились другой транзакцией и не будет dirty read. Если данные изменились, то сагу надо откатить.

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

Идемпотентность компенсирующих (откатывающих изменений) транзакций.
Компенсирующая транзакция не может быть не выполнена, она должна повторяться, пока не выполнится успешно. Компенсирующие транзакции должны быть коммутативными с операциями, то есть их выполнение в разном порядке должно приводить к одному результату.

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

Например:

  • первыми выполнять операции, которые имеют наибольшую вероятность отказа;

  • выполнять операции, которые труднее всего откатить, в последнюю очередь.

Для обеспечения атомарности транзакции в базе данных и отправки сообщения могут применяться паттерны:

  • Transactional Outbox pattern — запись сообщения как часть основной транзакции в базе данных. Далее отдельный процесс перемещает сообщение в брокер сообщений.

  • Event Sourcing pattern — апдейт базы и сохранение сообщения выполняются как одна операция.

В итоге для нашего продукта мы решили остановиться на следующем :

  • библиотека на основе оркестратора;

  • процесс описывается императивно, состоящим из отдельных шагов;

  • при выполнении шага сохраняется результат выполнения для дальнейшего воспроизведения;

  • при проблемах с шагом можем его повторить, есть поддержка resilience pattern (retry);

  • при падении приложения повторяем весь процесс;

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

  • результат выполнения шага может быть использован в других шагах;

  • есть механизм ручного запуска экземпляра с нужного статуса.

На текущий момент поддерживается БД Postgres

Пример сценария создания заявки на кредит:

 statefulWorkflowFactory.createAndRegister { workFlowMaker ->
            workFlowMaker.body<CreateApplicationRequest, Unit>(CREATION_APPLICATION_WORKFLOW_NAME) { request->
              
            //шаг создания заявки с стандартными настройками политики восстановления шага

            val application = step(1, RecoveryPolicyDefinition.defaultProgressivePolicy()) {
                applicationService.createApplication(request)
            }

            /* 
             шаг создания клиента на основе заявки, полученной на предыдущем шаге
             в случае исключительной ситуации повторять выполнение логики в шаге не более 5 раз с интервалом в 15 секунд
             */
     
              val client =  step(2, RecoveryPolicy(
                  maxAttempts = 5,
                  waitDuration = Duration.ofSeconds(15))
              ) {
                   clientService.createClient(application) 
                   .also{applicationService.mergeApplicationClientUid(application.uid, client.uid)}
                }

                
                step(3, RecoveryPolicyDefinition.defaultProgressivePolicy()) {
                    applicationService.markNew(application)
                }
                
                // если не смогли завершить успешно все шаги, можем выполнить нужную логику в данном блоке
            }.onError {
                log.error("Failed process create application, [applicationUid = ${application.uid}].", it)

                step(1, RecoveryPolicyDefinition.defaultProgressivePolicy()) {
                    applicationService.failApplication(applicationUid, it)
                }
            }
                .make()
        // асинхронно выполняем наш процесс
        }.async(applicationUid.toString(), applicationUid)

Бизнес-логика находится в одном месте. Код пишется в императивном стиле, его несложно читать и понимать, что происходит в нужном сценарии. 

Стандартное решение resilience4j позволяет задавать различные варианты логики восстановления шага при сбоях. Для использования нужно только подключить библиотеку и настроить подключение к БД.

В случае если нужно добавить еще один шаг, возможны разные варианты:

  1. Если логика шага не зависит от других шагов, при выполнении кода система увидит, что шаг не выполнялся, и выполнит его.

  2. Предусмотрели сервис для выполнения логики с нужного шага, например, со второго (результат других шагов отклоняется).

  3. Удалить результат выполнения шагов, вызвав нужную процедуру в БД.

  4. Возможность версионирования. 

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


  1. SpiderEkb
    07.09.2023 11:20

    А не проще собрать список записей для изменения (добавления, удаления), а затем разом одной транзакцией их вкатить?

    Единственное что тут потребуется - журнал, в котором хранится два образа записи - "до" и "после" (это в случае изменения записи, в случае удаления только "до", добавления - только "после").

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

    Если в процессе наката что-то пошло не так - отменяем транзакцию (хотя наличие журнала позволяет просто развернуться и вернуть все взад руками - вся история есть).


    1. qw1
      07.09.2023 11:20

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


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


      1. SpiderEkb
        07.09.2023 11:20

        Можно и не отказываться. У нас так (правда, причины не совсем те, но...)

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

        Имя рабочей станции
        Дата
        Время
        Сиквенс
        Флаг образа (B - before, A - after)

        Также для каждой таблицы есть "модуль внешнего ввода" - ему передается на вход запись и флаг A - append, M - modify, D - delete. Он уже разберется что делать. Есть, например, изменение или удаление записи, он прочитает запись из таблицы, сравнит ее с образом B в журнале и есть они совпадут - изменит или удалит. Если нет - выдаст ошибку что запись была кем-то изменена в промежутке между тем, как вы захотели ее поменять и тем как дело дошло до конкретных изменений.

        И еще есть головной журнал. В нем содержится ссылка на соотв. журнал и ключ для записей.

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

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

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

        И тут возможен "накат с разворотом" - вам надо вкатить 100 записей в 10 таблиц. Идете по головному журналу. на 50-й записи сломались - просто разворачиваетесь и идете обратно меняя местами образы A и B. Все восстановилось.

        У нас десятки тысяч таблиц, тысячи одновременно работающих с ними процессов (это не совсем микросервисы, скорее, акторы) и все это работает.

        Это банк. Там есть еще такое понятие как "ночной ввод". Перед закрытием опердня создается копия основного юнита в "юнит ночного ввода". Все операции переключаются туда, а основной юнит начинает переходить на новый день (это порядка 4-х часов занимает). Когда переход закончен, все изменения из ночного юнита по журналам вкатываются в основной и операции все переключаются обратно в дневной юнит. И это тоже работает.


        1. Ivan22
          07.09.2023 11:20
          +3

          отличная имитация работы редолога транзакций в оракле


          1. SpiderEkb
            07.09.2023 11:20

            Не совсем. Журналы это несколько большее чем транзакция. Это еще и историчность.

            Вот у нас есть ситуация. Росфин присылает нам списки всяких злодеев. Мы их парсим и раскладываем информацию в удобном для нас формате по своей базе. На их основе потом всякие стоплисты формируются, риски и прочее.

            И возможна такая ситуация - пришел список, мы его обработали, разложили (а там около десятка таблиц разных). А через три дня приходит "последний список ошибочный, вот правильная версия". И чтобы вкатить правильную версию, надо сначала откатить предыдущую, неправильную, к тому состоянию, которое было до ее наката (накат одной и той же версии списка запрещен т.к. там есть некоторые моменты которые могут оказаться нерешаемыми). Так вот по журналу мы можем последнюю версию откатить хоть через день, хоть через неделю, хоть через месяц. Пока хранятся журналы с последнего наката. Не уверен что транзакционный механизм такое позволит.

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


            1. Ivan22
              07.09.2023 11:20

              Oracle undo log как раз для этих целей есть. Там все операции отмены лежат. Если транзакция хочет прочитать старую версию - берет последнее состояние таблицы и накатывает undo однин за одиним пока не дойдет до нужной версии транзакции


        1. qw1
          07.09.2023 11:20

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


          Например, гипотетический сервис продажи билетов, продающий не более Rk билетов на дату k. Допустим, есть таблица с двумя столбцами: первичный ключ — дата k, доступно к продаже — R. Два агента параллельно продают билет на одну дату. Они оба получают остаток, видят, что его хватает, и передают в журнал записи на вливание? Так они при вливании получат конфликт, что запись изменена, и вторая продажа ошибочно отменится.


          1. SpiderEkb
            07.09.2023 11:20

            У вас это работает, потому что все процессы спроектированы под такую архитектуру.

            Да.

            Любой произвольный бизнес-процесс со своей историей, не так просто перевести.

            Не совсем. Я просто привел пример того, как можно подготовить все изменения и влить их в рамках одной транзакции одним модулем наката.

            Например, гипотетический сервис продажи билетов, продающий не более Rk билетов на дату k.

            Пример неудачен. Представьте, что два агента пытаются одновременно продать один последний билет.

            Такие проблемы решаются иными способами. Или через систему холдов, или через систему квот агентам.

            Одновременное редактирование записи двумя сторонами - это всегда коллизия. Конфликт, требующий ручного разрешения.


            1. qw1
              07.09.2023 11:20
              +1

              Такие проблемы решаются иными способами

              для небольших масштабов, не банковских — это SELECT FOR UPDATE.


              через систему холдов, или через систему квот агентам

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


              Как будто в такой системе невозможно создавать аггрегаты, а всё должно вливаться/меняться атомарными строчками, а аггрегаты не могут храниться, а должны всегда считаться на лету по фактам. Шаблон Event Sourcing в чистом виде?


              1. SpiderEkb
                07.09.2023 11:20

                для небольших масштабов, не банковских — это SELECT FOR UPDATE

                А если речь о 10-ти таблицах по которым надо раскидать данные?

                Пытаюсь сообразить, как это может выглядеть.

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

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

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

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

                Считать по фактам в hi-load системах такое себе. И "атомарная строчка" может раскладываться по 10-ти таблицам.

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


                1. mayorovp
                  07.09.2023 11:20

                  Холд — это когда агент начинает продажу и количество проданных единиц сразу уменьшается на 1. В момент запроса на начало продажи. Т.е. продаваемая единица "ставится на холд" (как платеж по банковской карте).

                  Если продажа прошла — все ок. Если нет — холд снимается и количество доступных единиц обратно увеличивается на 1. Это быстрые операции, тут коллизии маловероятны.


                  Маловероятны-то да, но возможны. А их надо исключить в принципе.


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

                  Система хорошая, но когда осталась последняя единица товара — не работает.


                1. qw1
                  07.09.2023 11:20

                  Холд — это когда агент начинает продажу и количество проданных единиц сразу уменьшается на 1

                  Это понятно. Вопрос, как это реализуется на уровне БД.
                  Уменьшение на 1 значения в какой-то строчке таблицы? Тогда это конфликт.


                  Квоты — это когда у каждого агента есть своя строчка с выделенным ему количество единиц

                  Если агент последовательно продал 3 билета, в "ночной" пакет обновлений войдёт 3 последовательных апдейта одной строки, и тогда реализуется негативная ветка сценария ниже?


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


                1. qw1
                  07.09.2023 11:20

                  А если речь о 10-ти таблицах по которым надо раскидать данные?

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


                  1. Ivan22
                    07.09.2023 11:20

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

                    Второй вариант - оптимистический. Который подразумевает что скорее всего никому наш ресурс не появится и поэтому блокировать его не нужно, а в конце когда надо фиксировать транзакцию - надо делать проверку не изменилось состояние нашего объекта - если нет, все норм, накатываем обновления, ну а если изменилось - отваливаемся с ошибкой (snapshot too old или "извините, пока вы думали, этот билет кто-то уже купил").

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

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


                    1. qw1
                      07.09.2023 11:20

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


                      Так делает Firebird или MS SQL Server в режиме версионной БД (READ_COMMITTED_SNAPSHOT ON, ALLOW_SNAPSHOT_ISOLATION ON)


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


                      1. Ivan22
                        07.09.2023 11:20

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


                      1. qw1
                        07.09.2023 11:20

                        Для меня конец транзакции — это исполнение оператора COMMIT (или ROLLBACK).


  1. comerc
    07.09.2023 11:20
    +1

    Решили написать свое решение. 

    Что помешало научиться готовить temporal.io (единорог, на минуточку)? Пилить инфраструктурный велосипед - это значит откладывать на потом "business value".

    Ещё один пример в мою коллекцию:
    https://habr.com/ru/companies/oleg-bunin/articles/418235/
    https://habr.com/ru/companies/ozontech/articles/590709/


  1. FirsofMaxim
    07.09.2023 11:20

    Тема с Saga также хорошо описана в книге - Крис Ричардсон. Микросервисы. Паттерны разработки и рефакторинга


    1. dph
      07.09.2023 11:20

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


  1. dph
    07.09.2023 11:20

    А какую производительность получили? А то PG не очень удачное решение для подобных задач. А вообще получилось очень похоже на то, что я описывал в https://youtu.be/hXuyT6T3fNU?t=1471, даже код описания сценария похожий.