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

Алексей Бакин ведёт канал «Заботливый разработчик» и занимается разработкой внутренних продуктов. Один из них — это API-прокси, предоставляющее внешние API для внутренних сервисов. Для реализации этого решения использовали паттерн «Сага». 

Хореография и оркестрация в сагах: как использовать

Представим, что хотим собраться с друзьями и поиграть в настолки. Чтобы договориться, создаём общий чат. В сагах это называется «хореографией»:

  • Все сервисы понимают, что они участники какого-то общего процесса.

  • Каждый сервис генерирует события и делает рассылку.

  • Остальные участники видят эти события, реагируют, и в итоге получается общий «танец».

Второй подход — «оркестрация». Это когда кто-то один берёт на себя организацию: всем пишет, звонит, индивидуально с каждым договаривается. Такой подход напоминает дирижёра оркестра: есть план, и он строго ему следует.

Для реализации задачи мы выбрали оркестрацию, и вот почему:

  1. Меньше переделывать.

При хореографии сервисы должны работать определённым образом: рассылать события, слушать их и так далее. А у нас уже много всего написано на gRPC-сервисах, которые работают по схеме «запрос-ответ». Это совсем другая модель, пришлось бы всё менять.

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

  1. Проще дебажить в будущем.

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

Из чего состоит Сага

Чтобы у всех сложилось общее представление, особенно если вы ни разу с Сагой не сталкивались, расскажу об основных компонентах. В оркестрации есть набор шагов, которые должен выполнить оркестратор. На Go это можно выразить так:

type Step struct {
    Name string
    Do   StepFn
}
type StepFn func(ctx sagaexec.Context) StepResult

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

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

Работа с результатом одного шага из другого может выглядеть следующим образом:

{
    Name: "lookup_place",
    Do: func(sagaexec.Context) sagaexec.StepResult {
        // ...
    },
},
{
    Name: "ask_misha",
    Do: func(sCtx sagaexec.Context) sagaexec.StepResult {
        place := StepResultAs[PlaceInfo](sCtx, "lookup_place")
        // ...
    },
},

В этом псевдо-коде сага выполнила шаг — «Найти помещение», сохранила результат, а потом использовала его в другом шаге — «Спросить Мишу».

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

Следующий важный аспект саги — это компенсация. Представьте: мы нашли помещение, договорились с Мишей, пишем Васе, а он говорит: «Сорян, не могу». И что делать? Ведь Мише мы уже сообщили, придётся и ему сказать, что встреча отменяется, чтобы все могли расходиться.

Чтобы это реализовать, в каждом шаге мы добавляем ещё одну функцию. У нас есть:

  1. Функция, которая описывает, как выполнить действие.

  2. Функция, которая описывает, как отменить действие.

type Step struct {
    Name       string
    Do         StepFn
    Compensate StepFn
}

Последний аспект — это воркфлоу. Все описанные шаги можно объединить в последовательность. Например: найти помещение, всем написать, забронировать и так далее.

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

func NewGatherFriends() []sagaexec.Step {
    return []sagaexec.Step{
        {Name: "lookup_place", Do: ...},
        {Name: "ask_misha",    Do: ..., Compensate: ...},
        {Name: "ask_vasya",    Do: ..., Compensate: ...},
        {Name: "ask_kolyan",   Do: ..., Compensate: ...},
        {Name: "book_place",   Do: ..., Compensate: ...},
    }
}

Короткий рекап основных понятий:

  • Воркфлоу саги — обязательно последовательный набор шагов.

  • Шаг саги — транзакционное действие: мы знаем, как его сделать и как в случае чего отменить.

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

Такой воркфлоу либо полностью завершается, когда все шаги выполнены, либо при сбое на одном из шагов мы откатываем всё назад. По сути, это транзакция, а сага — один из способов реализовать её в распределённом виде. Если вы изучали саги, то точно встречали термин «распределённые транзакции». И если разбирались в распределённых транзакциях, то наверняка слышали о сагах.

Сага и необходимость в ветвлении

Мы уже говорили, что воркфлоу саги — это последовательный набор шагов. Но в реальности редко всё идёт по одному пути: обычно хочется добавить какое-то ветвление. Воркфлоу саги получается примерно таким:

  • lookup_place

  • if place is in the north of the city

    • ask_misha

    • ask_vasya

  • else

    • ask_petya

    • ask_nikita

  • ask_kolyan

  • book_place

Если место находится на севере, то туда согласны поехать Миша и Вася, а в другое место — нет, они не мобильны. Поэтому, если место не на севере, нужно приглашать Петю и Никиту. Колян же поедет куда угодно. Так что у него отдельной ветви нет.

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

Посмотрим пример кода:

{
    Name: "ask_misha",
    Do: func(sCtx sagaexec.Context) sagaexec.StepResult {
        place := StepResultAs[PlaceInfo](sCtx, "lookup_place")
        if place.GetRegion() != "north" {
            // Если место не на севере, пропускаем шаг
            return sagaexec.EmptyStepResult()
        }
        // Здесь логика для приглашения Миши
        // ...
    },
}

Есть шаг «Спросить Мишу». Мы берём результат шага «Поиск места» и смотрим, где это место находится. Если понимаем, что Миша туда не поедет, то просто возвращаем пустой результат и фактически пропускаем действие. Такую же логику можно задать для каждого человека, и тогда получается плоский список:

  • lookup_place

  • ask_misha

  • ask_vasya

  • ask_petya

  • ask_nikita

  • ask_kolyan

  • book_place

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

Когда шагов много, хочется одним взглядом оценить, кто где участвует. Именно для этого мы ввели новый тип шага и назвали его Select. Он берёт результаты предыдущих шагов, анализирует их и возвращает слайс шагов, т.е. по сути, запускает новый воркфлоу.

{
    Name: "select_friends",
    Select: func(sCtx sagaexec.Context) []sagaexec.Step {
        place := StepResultAs[PlaceInfo](sCtx, "lookup_place")
        if place.GetRegion() != "north" {
            return []sagaexec.Step{}
        }
        return []sagaexec.Step{}
    },
}

Мы сделали шаг «Найти помещение», затем попали в шаг Select, который решает, какой воркфлоу запустить дальше. В результате получается уже не слайс шагов, а дерево списков. Этот набор воркфлоу хоть и ограниченный, но даёт понимание всей логики. Если когда-нибудь у нас дойдут руки до визуализации, можно будет строить красивые схемы — и тогда всё станет ещё понятнее.

Plain Go или Structured Go: выбираем подход для саги

Когда я решаю какую-то задачу, всегда спрашиваю себя: «А что конкретно мне нужно сделать?» Изначально мне была нужна сага. Но почему-то я ввёл понятие шага как абстракции структуры, стал объединять эти шаги в слайсы, и когда понадобился if, пришлось решать эту проблему уже в рамках существующего решения, хотя к сагам это напрямую не относится. Если бы я просто писал на Go, то  беспроблемно использовал обычные if и switch.

Мы так и попробовали сделать. Давайте сравним, как выглядит код на Go и «структурный» вариант. Код на Go:

place, err := Do(sCtx, "lookup_place", ...)
if err != nil {
    return nil, fmt.Errorf("lookup place: %w", err)
}
_, err = Do(sCtx, "ask_misha", ...)
if err != nil {
    return nil, fmt.Errorf("ask misha: %w", err)
}
defer func() {
    _err := Compensate(sCtx, "ask_misha", ...)
    if _err != nil {
    }
}()

В Go есть функция Do для выполнения шага, которая возвращает ошибку. Приходится писать типичный код с if err != nil и так далее. На следующем шаге снова вызываем Do и проверяем ошибку. Причём во втором шаге, если что-то пошло не так, у нас есть компенсация. Компенсации нужно выполнять в обратном порядке. В Go мы подобные действия обычно делаем это через defer. В итоге получается довольно длинное полотно кода из if и defer, и читать его не очень удобно.

Для сравнения структурный вариант:

{
    {
        Name: "lookup_place",
        Do: ...,
    },
    {
        Name: "ask_misha",
        Do: ...,
        Compensate: ...,
    },
}

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

Сравним два варианта:

Plain Go

Structured Go

Никак не ограничивает

Загоняет в рамки

• Писать просто и быстро

• Писать сложнее

• Флоу неочевидно

• Флоу сразу понятен

«Plain Text» не накладывает ограничений: пишем, что хотим, да ещё и быстро, без лишних проблем. В «Structured Go» приходится следовать правилам. Иногда эти ограничения приводят к странным или неочевидным шагам, чтобы уложиться в структуру. Поэтому писать бывает ощутимо сложнее.

Однако есть и плюс. В «Plain Text» бывает сложно понять воркфлоу, выцепить названия шагов, а в «Structured Go» всё сразу «лежит на поверхности»: достаточно взглянуть на названия шагов, чтобы понять логику процесса, не вчитываясь детально в код.

Ещё один аргумент в пользу структурного варианта — сложность правильного использования defer для компенсации. Представим, что у нас есть такой код:

defer func() {
    err := Compensate(sCtx, "ask_misha", ...)
    retErr = multierr.Append(retErr, err)
}()

В defer вызывается Compensate: она берёт sCtx, то есть контекст саги проверяет, не «сломана» ли сага. Если да — делаем компенсацию. Но как узнать, что сага сломалась? Для этого в каком-то шаге нужно вернуть специальную ошибку, например, AbortError, и переключить контекст в это состояние.

Это обязательно должно происходить внутри Do, чтобы координатор саги изменил состояние sCtx:

_, err = Do(sCtx, "ask_vasya", func() {
   if smth {
      return nil, sagaexec.NewAbortError(...)
  }
})

В принципе, когда мы пишем код на Go, у нас нет ограничений, мы можем написать так:

if smth {
    return nil, sagaexec.NewAbortError(...)
}

Вроде всё также возвращается AbortError, но координатор саги этого не видит, и компенсации не запустятся. Это реальный баг из нашего кода. Поэтому мы остановились на том, что структурный подход для нас надёжнее. В купе со всеми остальными проблемами в «Plain Text» очень просто сделать ошибку.

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

У структурированного кода есть ещё один плюс — его проще тестировать. Если у вас есть отдельные шаги, вы можете каждый протестировать отдельно. Если же эти шаги объединены в одну большую функцию, придётся тестировать всё разом, что приведёт к «взрыву» количества тест-кейсов.

Plain Go

Structured Go

Никак не ограничивает

Загоняет в рамки

• Писать просто и быстро

Писать сложнее

Флоу неочевидно

• Флоу сразу понятно

Накосячить проще

• Накосячить сложнее

• Гораздо легче тестировать

Структурный подход на практике

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

Насколько хорошо для этого подходит описанное решение?

Развитие воркфлоу

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

Если же захочется что-то поменять в текущем воркфлоу, сделать это просто так не получится. Представьте: воркфлоу из нескольких шагов, и они распределены по двум серверам — один выполняет первый шаг, другой — второй. Если каждый сервер использует разную версию кода, мы теряем консистентность. Нужно либо обеспечить обратную совместимость и понять, как разные версии смогут работать вместе, либо, если это невозможно, создать новый воркфлоу. Аналогично тому, как мы работаем с API-версионированием v1, v2 и так далее: вся новая логика идёт во вторую версию, а старая продолжает работать по-старому, пока мы постепенно не мигрируем на новый вариант.

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

Самый простой путь — поддержать функцию DoParallel, которая принимает контекст саги, но возвращает не результат, а «набор» шагов. Используя те же абстракции, которые мы уже используем для описания последовательных шагов, сможем описать их параллельное выполнение.

{
    Name: "ask_everyone",
    DoParallel: func(sCtx sagaexec.Context) []sagaexec.Step {
        ...
    },
},

Другой важный термин из мира саг — это Pivot, точка невозврата. Принцип такой: сага выполняется и доходит до определённого шага. Если этот шаг выполнен, то откатываться назад уже нельзя.

{
    Name: "book_place",
    Pivot: true,
    Do: ...
}

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

Ещё одна очень важная вещь в саге — это механизмы повторных попыток или ретраи. Допустим, мы выполнили какой-то шаг, скажем, отправили gRPC-запрос, но сервер не ответил. Тогда мы через некоторое время пробуем ещё раз. Опять же, благодаря структурному подходу можно расширять это как угодно. Например, вот так можно добавить простой back-off:

{
    Name: "ask_misha",
    RetryIn: []time.Duration{"1m", "5m", "10m"},
    Do: ...
}

Я упоминал в начале статьи, что для сервисов в саге важна идемпотентность. Причина как раз в ретраях. Представьте, мы вызываем внешнюю систему и говорим ей: «Добавь на счёт 100 рублей», но не получаем подтверждение, что операция прошла. Мы не знаем, нужно ли компенсировать действие, ведь деньги могли уже быть зачислены, а нам просто не пришёл ответ. Если мы сделаем компенсацию «Отними 100 рублей», то можем забрать у человека средства, которых у него вообще не было.

Поэтому, если нужно ретраить шаг, то в сагах обычно написано: «Ретраить, пока не получится», то есть нужно бесконечно повторять попытку, пока шаг не выполнится. Но это нереальная ситуация.

Какие ещё могут возникать нереальные ситуации? Вот примеры:

  • Ретрай не помогает, а бесконечно повторять попытки невозможно.

  • Случился Abort после Pivot: мы указали, что «дальше возвращаться нельзя», а тут вдруг надо откатываться.

  • Шаг не найден по имени или не нашлось его результата.

  • Результат шага не того типа: мы ожидали конкретный enum, но вдруг пришло неизвестное значение.

Что в таких случаях делать? Это похоже на проблему с ретраями: нужно что-то предпринять, а что — непонятно. Мы приняли решение — ставить сагу на паузу, чтобы разработчики могли разобраться.

Panic в Go: зачем он нужен на самом деле

Чаще всего в Go мы придерживаемся правила «Не паниковать!». Но в по-настоящему исключительных ситуациях panic как раз вполне уместен.

Panic in truly exceptional situations!

И описанные выше примеры — как раз эти самые ситуации — они «нереальные» потому что при нормальной разработке они в принципе не должны возникать.

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

Отладка саг в распределённой системе

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

Оркестратор всё записывает. У нас есть API-прокси: каждый входящий запрос оборачивается в сагу, и сага всегда начинается с какого-то запроса. В логе это выглядит так:

[SAGA] request=[platform.api.GatherFriendsRequest]:{}
[STEP] id=lookup_place result=[geo.api.PlaceInfo]:{region:"north"}
[STEP] id=ask_misha result=[google.protobuf.Empty]:{}
[STEP] id=ask_vasya result=[google.protobuf.Empty]:{}
[STEP] id=ask_kolyan result=[google.protobuf.Empty]:{}
[STEP] id=book_place result=[geo.api.BookPlaceResponse]:{}
[RESP] [platform.api.GatherFriendsResponse]:{}

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

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

[SAGA] request=[platform.api.GatherFriendsRequest]:{}
[STEP] id=lookup_place result=[geo.api.PlaceInfo]:{region:"north"}
[STEP] id=ask_misha result=[google.protobuf.Empty]:{}
[STEP] id=ask_vasya result=[google.protobuf.Empty]:{}
[ABRT] message="Vasya is busy at sunday."

Если сагу нужно поставить на паузу, пользователю вернётся сообщение вида «Что-то внутри сломалось, разработчики бегут чинить». В логах же сохраняется внутренняя причина ошибки, например, что пропущен какой-то шаг:

[SAGA] request=[platform.api.GatherFriendsRequest]:{}
[STEP] id=lookup_place result=[geo.api.PlaceInfo]:{region:"north"}
[STEP] id=ask_misha result=[google.protobuf.Empty]:{}
[STEP] id=ask_vasya result=[google.protobuf.Empty]:{}
[STEP] id=ask_kolyan result=[google.protobuf.Empty]:{}
[PAUS] reason="step 'book_place' is missed"

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

Итоги

Сага — это гораздо больше, чем паттерн из Интернета. Её интересно изучать и удобно использовать.

Саги можно использовать для GET-запросов

Изначально сага — это про распределённую транзакцию, создание изменений и умение их откатывать. А в GET-запросах мы только запрашиваем данные. Зачем же тогда заворачивать их в сагу?

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

  • Запрашиваем данные параллельно и склеиваем ответ из разных кусочков. Когда нужно собрать ответ из нескольких сервисов, можно сходить к каждому параллельно и затем объединить результат.

Сага классно подходит, чтобы делать асинхронные API

Сама природа саг подразумевает асинхронность: мы запускаем сагу, делаем записи в БД, постепенно выполняем шаги. Если нужен синхронный API, просто дожидаемся конца саги. Если же хотим асинхронный — возвращаем клиенту идентификатор саги, а затем даём возможность проверять статус выполнения по этому идентификатору.

Сага развивает навыки в архитектуре и разработке

Написать MVP саги самому — интересная задача выходного дня, которая помогает прокачаться в архитектурных решениях.

  • Не залезать в vendor lock. Существуют готовые инструменты вроде Temporal, но, используя их, приходится мириться с чужими багами и подходами. Мы решили контролировать всё сами.

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

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

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


  1. domix32
    30.01.2025 13:35

    Такую же логику можно задать для каждого человека, и тогда получается плоский список:

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


  1. dph
    30.01.2025 13:35

    А почему использовали такой подход, а не подход Cadence/Temporal? Сценарии при этом получаются гораздо более удобные и понятные и не нужно думать лишний раз о компенсациях (которые вообще не очень обязательны в сагах и сильно усложняют использование, особенно если в компенсации случилась ошибка).


  1. powerman
    30.01.2025 13:35

    В статье забыли упомянуть, что плюс ко всему описанному любые (не важно, реализованные как в статье или через Temporal) саги добавляют в проект очень много дополнительной сложности. Что ещё хуже, нередко это accidental complexity, а не essential complexity:

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

    • Саги в рамках нескольких микросервисов одного проекта нередко являются признаком неудачного дизайна микросервисной архитектуры и/или чрезмерно жёстких требований бизнеса. В таких ситуациях нередко возможно согласовать с бизнесом небольшие ослабления требований к консистентности и/или передизайнить границы ответственности микросервисов чтобы появилась возможность все транзакции выполнять в рамках одного микросервиса и избавиться от всех или хотя бы большинства саг.

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

    Резюмируя, уметь реализовывать саги важно, но ещё важнее уметь их избегать.