📜 Паттерн Saga: Как откатить то, что откатить нельзя Представьте классическую задачу: клиент нажимает кнопку «Купить тур».
Представьте классическую задачу: клиент нажимает кнопку «Купить тур». Вашему бэкенду нужно сделать три вещи:
1. Забронировать рейс (через API авиакомпании).
2. Забронировать отель (через микросервис отелей).
3. Списать деньги (через платежный шлюз).
В монолите с одной базой вы бы просто открыли транзакцию BEGIN ... COMMIT и спали спокойно. Но у нас микросервисы. Вы не можете повесить лок на базу данных авиакомпании.
Что если рейс забронирован, отель подтвержден, а на карте клиента нет денег? Вы не можете просто сказать ROLLBACK. Рейс уже куплен. Вы потеряли деньги компании.
Здесь на сцену выходит Паттерн Saga.
Суть Саги: Распределенная транзакция разбивается на серию локальных. И самое главное: для каждого шага (Execute) вы обязаны написать шаг отмены (Compensate).
Если на третьем шаге происходит ошибка, Сага разворачивается и вызывает функции отмены для второго и первого шагов в обратном порядке.
В архитектуре есть два пути: Хореография (микросервисы кидаются событиями через Kafka) и Оркестрация (один сервис управляет всем). В Go элегантнее всего пишется Оркестратор.
Реализация Оркестратора на Go:
type SagaStep struct {
Name string
Execute func(ctx context.Context) error
Compensate func(ctx context.Context) error
}
func RunSaga(ctx context.Context, steps []SagaStep) error {
var completedSteps []SagaStep
// 1. Идем вперед
for _, step := range steps {
err := step.Execute(ctx)
if err == nil {
completedSteps = append(completedSteps, step)
continue
}
log.Printf("Ошибка на шаге %s: %v. Начинаем откат...", step.Name, err)
// 2. Ошибка! Идем назад и откатываем
for i := len(completedSteps) - 1; i >= 0; i-- {
rollbackStep := completedSteps[i]
if compErr := rollbackStep.Compensate(ctx); compErr != nil {
// Это катастрофа. Подробнее в Senior Tips.
log.Printf("КРИТИЧЕСКАЯ ОШИБКА отката %s: %v", rollbackStep.Name, compErr)
}
}
return fmt.Errorf("saga failed on step %s: %w", step.Name, err)
}
return nil
}
Как это выглядит при вызове:
steps := []SagaStep{
{
Name: "BookFlight",
Execute: func(ctx context.Context) error { return flightAPI.Book(userID) },
Compensate: func(ctx context.Context) error { return flightAPI.Cancel(userID) },
},
{
Name: "BookHotel",
Execute: func(ctx context.Context) error { return hotelAPI.Book(userID) },
Compensate: func(ctx context.Context) error { return hotelAPI.Cancel(userID) },
},
// ... и так далее
}
err := RunSaga(ctx, steps)
🔥 Нюансы:
1. Компенсации не имеют права падать. Если функция Execute упала, это нормально (нет денег на карте, сеть моргнула). Но если упала функция Compensate (не смогли отменить бронь отеля) - ваша система переходит в неконсистентное состояние. Отель ждет гостя, а клиент ничего не оплатил.
Решение: Компенсирующие действия должны отправляться в надежную очередь (Dead Letter Queue) и ретраиться до посинения (или до вмешательства саппорта).
2. Идемпотентность - царица Саги.
Так как функции компенсации могут ретраиться бесконечно, они обязаны быть идемпотентными (вспоминаем прошлый пост!). Если мы 5 раз вызовем hotelAPI.Cancel, бронь должна отмениться один раз, а остальные 4 вызова должны вернуть 200 OK.
3. Грязное чтение (Lack of Isolation).
Сага не обладает свойством "I" из ACID (Изоляция). Между бронированием отеля и списанием денег могут пройти секунды. В этот момент другой сервис может увидеть, что отель забронирован. Если сага откатится, другой сервис останется с неверными данными. Учитывайте это при проектировании (например, вводите статусы PENDING).
Сага - это сложно. Не тяните её в проект, пока можете обойтись одной таблицей в монолите. Но если уж распределили базу, будьте готовы платить за это кодом.
👉 @golang_lib