В мире микросервисов часто возникает необходимость сделать согласованные изменения в сервисах. Один из надёжных способов добиться этого — использовать паттерн «Сага», который помогает выполнять распределённые транзакции и при сбоях корректно откатывать изменения. Но, как всегда, есть нюансы, начиная от нереалистичных материалов на эту тему и заканчивая реальным опытом использования.
Алексей Бакин ведёт канал «Заботливый разработчик» и занимается разработкой внутренних продуктов. Один из них — это API-прокси, предоставляющее внешние API для внутренних сервисов. Для реализации этого решения использовали паттерн «Сага».
Хореография и оркестрация в сагах: как использовать
Представим, что хотим собраться с друзьями и поиграть в настолки. Чтобы договориться, создаём общий чат. В сагах это называется «хореографией»:
Все сервисы понимают, что они участники какого-то общего процесса.
Каждый сервис генерирует события и делает рассылку.
Остальные участники видят эти события, реагируют, и в итоге получается общий «танец».
Второй подход — «оркестрация». Это когда кто-то один берёт на себя организацию: всем пишет, звонит, индивидуально с каждым договаривается. Такой подход напоминает дирижёра оркестра: есть план, и он строго ему следует.
Для реализации задачи мы выбрали оркестрацию, и вот почему:
Меньше переделывать.
При хореографии сервисы должны работать определённым образом: рассылать события, слушать их и так далее. А у нас уже много всего написано на gRPC-сервисах, которые работают по схеме «запрос-ответ». Это совсем другая модель, пришлось бы всё менять.
При оркестрации тоже нужны доработки, но их меньше. Главное — обеспечить идемпотентность. А ещё иногда требуются точечные изменения, например, добавить двухфазный коммит или кое-что дополнительно подправить.
Проще дебажить в будущем.
Когда есть оркестратор, логирующий всё, что происходит, становится легче найти в логах то, что нужно. В общем чате, куда активно что-то пишут, наоборот — разобраться бывает непросто.
Из чего состоит Сага
Чтобы у всех сложилось общее представление, особенно если вы ни разу с Сагой не сталкивались, расскажу об основных компонентах. В оркестрации есть набор шагов, которые должен выполнить оркестратор. На 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, которые с помощью дженерика преобразуют обобщённый результат к нужному типу. Затем можно использовать эту информацию и написать Мише сообщение, что сбор будет в таком-то месте, и дождаться его ответа.
Следующий важный аспект саги — это компенсация. Представьте: мы нашли помещение, договорились с Мишей, пишем Васе, а он говорит: «Сорян, не могу». И что делать? Ведь Мише мы уже сообщили, придётся и ему сказать, что встреча отменяется, чтобы все могли расходиться.
Чтобы это реализовать, в каждом шаге мы добавляем ещё одну функцию. У нас есть:
Функция, которая описывает, как выполнить действие.
Функция, которая описывает, как отменить действие.
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)
dph
30.01.2025 13:35А почему использовали такой подход, а не подход Cadence/Temporal? Сценарии при этом получаются гораздо более удобные и понятные и не нужно думать лишний раз о компенсациях (которые вообще не очень обязательны в сагах и сильно усложняют использование, особенно если в компенсации случилась ошибка).
powerman
30.01.2025 13:35В статье забыли упомянуть, что плюс ко всему описанному любые (не важно, реализованные как в статье или через Temporal) саги добавляют в проект очень много дополнительной сложности. Что ещё хуже, нередко это accidental complexity, а не essential complexity:
Саги в рамках одного монолита/микросервиса обычно появляются как следствие DDD. И, вполне возможно, что DDD просто избыточен (особенно если речь о микросервисе), и если его не использовать то ясность бизнес-логики пострадает незначительно, но зато уйдут все саги и реализация станет на порядок проще.
Саги в рамках нескольких микросервисов одного проекта нередко являются признаком неудачного дизайна микросервисной архитектуры и/или чрезмерно жёстких требований бизнеса. В таких ситуациях нередко возможно согласовать с бизнесом небольшие ослабления требований к консистентности и/или передизайнить границы ответственности микросервисов чтобы появилась возможность все транзакции выполнять в рамках одного микросервиса и избавиться от всех или хотя бы большинства саг.
Саги в рамках не связанных проектов (классический пример саги: нужно купить авиабилеты в одной компании, забукать отель в другой и заказать такси в третьей) обычно являются основным кейсом, в которых без саг обойтись невозможно. К сожалению, именно в этих ситуациях нередко всё складывается ещё сложнее, потому что не все сторонние проекты предоставляют идемпотентное API. Иногда в таких ситуациях тоже получается увернуться от использования саг договорившись с бизнесом об ослаблении требований к консистентности.
Резюмируя, уметь реализовывать саги важно, но ещё важнее уметь их избегать.
domix32
Переспрашиваем один и тот же вопрос у n человек звучит довольно неэффективно, так что несколько странный пример. Как-то по всей этой саге нельзя передавать некоторый набор контейнеров, чтобы не тратить процессорные такты или подразумевается, что шаги должны быть независимы друг от друга по аналогии с облачными лямбдами?