Шаблон Saga в распределённых транзакциях — с примерами на Go

Транзакции в микросервисах с использованием паттерна Saga

Содержимое страницы

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

Вместо того чтобы полагаться на распределенные блокировки, которые могут блокировать операции между сервисами, Saga обеспечивает eventual consistency (конечную согласованность) через последовательность обратимых шагов, что делает его идеальным решением для длительных бизнес-процессов.

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

Данное руководство демонстрирует реализацию шаблона Saga на языке Go с практическими примерами, охватывающими как подход оркестрации, так и хореографии. Если вам нужна быстрая справочная информация по основам Go, Шпаргалка по Go предоставляет полезный обзор.

строительный работник с распределенными транзакциями Это красивое изображение сгенерировано AI-моделью Flux 1 dev.

Понимание шаблона Saga

Шаблон Saga был впервые описан Хектором Гарсия-Молиной и Кеннетом Сэйлем в 1987 году. В контексте микросервисов это последовательность локальных транзакций, где каждая транзакция обновляет данные внутри одного сервиса. Если любой шаг завершается ошибкой, выполняются компенсирующие транзакции для отмены эффектов предыдущих шагов.

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

Ключевые характеристики

  • Отсутствие распределенных блокировок: Каждый сервис управляет своей собственной локальной транзакцией
  • Компенсирующие действия: Каждая операция имеет соответствующий механизм отката
  • Конечная согласованность: Система в конечном итоге достигает согласованного состояния
  • Длительное выполнение: Подходит для процессов, занимающих секунды, минуты или даже часы

Подходы к реализации Saga

Существует два основных подхода к реализации шаблона Saga: оркестрация и хореография.

Шаблон оркестрации

При оркестрации центральный координатор (оркестратор) управляет всем потоком транзакций. Оркестратор отвечает за:

  • Вызов сервисов в правильном порядке
  • Обработку ошибок и запуск компенсирующих действий
  • Поддержание состояния saga
  • Координацию повторных попыток и таймаутов

Преимущества:

  • Централизованный контроль и видимость
  • Проще понять и отлаживать
  • Лучшая обработка ошибок и восстановление
  • Более простое тестирование общего потока

Недостатки:

  • Единая точка отказа (хотя это можно смягчить)
  • Дополнительный сервис для поддержки
  • Может стать узким местом для сложных потоков

Пример на Go:

type OrderSagaOrchestrator struct {
    orderService    OrderService
    paymentService  PaymentService
    inventoryService InventoryService
    shippingService ShippingService
}

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    sagaID := generateSagaID()
    
    // Шаг 1: Создать заказ
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // Шаг 2: Зарезервировать инвентарь
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Компенсация
        return err
    }
    
    // Шаг 3: Обработать платеж
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // Компенсация
        o.orderService.Cancel(orderID)          // Компенсация
        return err
    }
    
    // Шаг 4: Создать отгрузку
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // Компенсация
        o.inventoryService.Release(order.Items) // Компенсация
        o.orderService.Cancel(orderID)          // Компенсация
        return err
    }
    
    return nil
}

Шаблон хореографии

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

Преимущества:

  • Децентрализация и масштабируемость
  • Отсутствие единой точки отказа
  • Сервисы остаются слабо связанными
  • Естественная интеграция с событийно-ориентированными архитектурами

Недостатки:

  • Сложнее понять общий поток
  • Трудно отлаживать и отслеживать
  • Сложная обработка ошибок
  • Риск циклических зависимостей

Пример с событийно-ориентированной архитектурой:

// Сервис заказов
type OrderService struct {
    eventBus EventBus
    repo     OrderRepository
}

func (s *OrderService) CreateOrder(order Order) (string, error) {
    orderID, err := s.repo.Save(order)
    if err != nil {
        return "", err
    }
    
    s.eventBus.Publish("OrderCreated", OrderCreatedEvent{
        OrderID:    orderID,
        CustomerID: order.CustomerID,
        Items:      order.Items,
        Total:      order.Total,
    })
    
    return orderID, nil
}

// Примечание: s.repo.Save, за которым следует s.eventBus.Publish, является двойной записью.
// В продакшене замените это на паттерн транзакционного outbox, чтобы событие
// записывалось атомарно вместе со строкой заказа и публиковалось через реле.

func (s *OrderService) HandlePaymentFailed(event PaymentFailedEvent) error {
    return s.repo.Cancel(event.OrderID) // Компенсация
}

// Сервис платежей
type PaymentService struct {
    eventBus EventBus
    client   PaymentClient
}

func (s *PaymentService) HandleOrderCreated(event OrderCreatedEvent) {
    paymentID, err := s.client.Charge(event.CustomerID, event.Total)
    if err != nil {
        s.eventBus.Publish("PaymentFailed", PaymentFailedEvent{
            OrderID: event.OrderID,
        })
        return
    }
    
    s.eventBus.Publish("PaymentSucceeded", PaymentSucceededEvent{
        OrderID:   event.OrderID,
        PaymentID: paymentID,
    })
}

func (s *PaymentService) HandleInventoryReservationFailed(event InventoryReservationFailedEvent) error {
    // Компенсация: возврат платежа
    return s.client.Refund(event.PaymentID)
}

Стратегии компенсации

Компенсация — это сердце шаблона Saga. Каждая операция должна иметь соответствующую компенсацию, способную отменить ее эффекты.

Типы компенсации

  1. Обратимые операции: Операции, которые можно напрямую отменить

    • Пример: Освобождение зарезервированного инвентаря, возврат платежей
  2. Компенсирующие действия: Другие операции, которые достигают обратного эффекта

    • Пример: Отмена заказа вместо его удаления
  3. Пессимистичная компенсация: Предварительное выделение ресурсов, которые можно освободить

    • Пример: Резервирование инвентаря перед списанием платежа
  4. Оптимистичная компенсация: Выполнение операций и компенсация при необходимости

    • Пример: Сначала списать платеж, вернуть, если инвентарь недоступен

Требования к идемпотентности

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

func (s *PaymentService) Refund(paymentID string) error {
    // Проверить, был ли уже выполнен возврат
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // Уже возвращено, идемпотентно
    }
    
    // Обработать возврат
    return s.processRefund(paymentID)
}

Лучшие практики

1. Управление состоянием Saga

Поддерживайте состояние каждого экземпляра saga для отслеживания прогресса и обеспечения восстановления. При сохранении состояния saga в базе данных выбор правильного ORM имеет решающее значение для производительности и поддерживаемости. Для реализаций на базе PostgreSQL рассмотрите сравнение в Сравнение Go ORM для PostgreSQL: GORM vs Ent vs Bun vs sqlc, чтобы выбрать лучший вариант для ваших потребностей в хранении состояния saga:

type SagaState struct {
    ID           string
    Status       SagaStatus
    Steps        []SagaStep
    CurrentStep  int
    CreatedAt    time.Time
    UpdatedAt    time.Time
}

type SagaStep struct {
    Service     string
    Operation   string
    Status      StepStatus
    Compensated bool
    Data        map[string]interface{}
}

2. Обработка таймаутов

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

type SagaOrchestrator struct {
    timeout time.Duration
}

func (o *SagaOrchestrator) ExecuteWithTimeout(step SagaStep) error {
    ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
    defer cancel()
    
    done := make(chan error, 1)
    go func() {
        done <- step.Execute()
    }()
    
    select {
    case err := <-done:
        return err
    case <-ctx.Done():
        // Произошел таймаут, компенсировать
        if err := step.Compensate(); err != nil {
            return fmt.Errorf("compensation failed: %w", err)
        }
        return fmt.Errorf("step %s timed out after %v", step.Name(), o.timeout)
    }
}

3. Логика повторных попыток

Реализуйте экспоненциальное отставание (backoff) для временных сбоев:

func retryWithBackoff(operation func() error, maxRetries int) error {
    backoff := time.Second
    for i := 0; i < maxRetries; i++ {
        err := operation()
        if err == nil {
            return nil
        }
        
        if !isTransientError(err) {
            return err
        }
        
        time.Sleep(backoff)
        backoff *= 2
    }
    return fmt.Errorf("operation failed after %d retries", maxRetries)
}

4. Event Sourcing для состояния Saga

Используйте event sourcing для ведения полного аудиторского следа. При реализации хранилищ событий и механизмов воспроизведения Go generics могут помочь создать типобезопасный, переиспользуемый код обработки событий. Для продвинутых паттернов с использованием generics в Go см. Go Generics: Случаи использования и паттерны.

type SagaEvent struct {
    SagaID    string
    EventType string
    Payload   []byte
    Timestamp time.Time
    Version   int64
}

type SagaEventStore struct {
    store EventRepository
}

func (s *SagaEventStore) AppendEvent(sagaID string, eventType string, payload interface{}) error {
    data, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("failed to marshal payload: %w", err)
    }
    
    version, err := s.store.GetNextVersion(sagaID)
    if err != nil {
        return fmt.Errorf("failed to get version: %w", err)
    }
    
    event := SagaEvent{
        SagaID:    sagaID,
        EventType: eventType,
        Payload:   data,
        Timestamp: time.Now(),
        Version:   version,
    }
    
    return s.store.Save(event)
}

func (s *SagaEventStore) ReplaySaga(sagaID string) (*Saga, error) {
    events, err := s.store.GetEvents(sagaID)
    if err != nil {
        return nil, fmt.Errorf("failed to get events: %w", err)
    }
    
    saga := NewSaga()
    for _, event := range events {
        if err := saga.Apply(event); err != nil {
            return nil, fmt.Errorf("failed to apply event: %w", err)
        }
    }
    
    return saga, nil
}

5. Мониторинг и наблюдаемость

Реализуйте всестороннее логирование и трассировку:

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    span := tracer.StartSpan("saga.create_order")
    defer span.Finish()
    
    span.SetTag("saga.id", sagaID)
    span.SetTag("order.id", order.ID)
    
    logger.WithFields(log.Fields{
        "saga_id": sagaID,
        "order_id": order.ID,
        "step": "create_order",
    }).Info("Saga started")
    
    // ... выполнение saga
    
    return nil
}

Общие паттерны и антипаттерны

Паттерны, которым следует следовать

  • Паттерн координатора Saga: Используйте выделенный сервис для оркестрации
  • Паттерн Outbox: Обеспечьте надежную публикацию событий
  • Ключи идемпотентности: Используйте уникальные ключи для всех операций
  • Машина состояний Saga: Моделируйте saga как машину состояний

Антипаттерны, которых следует избегать

  • Синхронная компенсация: Не ждите завершения компенсации
  • Вложенные Sagas: Избегайте sagas, вызывающих другие sagas (вместо этого используйте под-sagas)
  • Общее состояние: Не разделяйте состояние между шагами saga
  • Длительные шаги: Разбивайте шаги, которые занимают слишком много времени

Инструменты и фреймворки

Несколько фреймворков могут помочь в реализации шаблонов Saga:

  • Temporal: Платформа оркестрации рабочих процессов с встроенной поддержкой Saga
  • Zeebe: Двигатель рабочих процессов для оркестрации микросервисов
  • Eventuate Tram: Фреймворк Saga для Spring Boot
  • AWS Step Functions: Serverless-оркестрация рабочих процессов
  • Apache Camel: Фреймворк интеграции с поддержкой Saga

Для сервисов-оркестраторов, которым нужны интерфейсы командной строки для управления и мониторинга, Построение CLI-приложений на Go с Cobra & Viper предоставляет отличные паттерны для создания инструментов командной строки для взаимодействия с оркестраторами saga.

При развертывании микросервисов на базе saga в Kubernetes реализация service mesh может значительно улучшить наблюдаемость, безопасность и управление трафиком. Реализация Service Mesh с Istio и Linkerd охватывает то, как service meshes дополняют паттерны распределенных транзакций, предоставляя сквозные возможности, такие как распределенная трассировка и circuit breaking.

Когда использовать шаблон Saga

Используйте шаблон Saga, когда:

  • ✅ Операции охватывают несколько микросервисов
  • ✅ Длительные бизнес-процессы
  • ✅ Приемлемая конечная согласованность
  • ✅ Вам нужно избегать распределенных блокировок
  • ✅ Сервисы имеют независимые базы данных

Избегайте, когда:

  • ❌ Требуется строгая согласованность
  • ❌ Операции простые и быстрые
  • ❌ Все сервисы используют одну и ту же базу данных
  • ❌ Логика компенсации слишком сложна

Заключение

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

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

Полезные ссылки

Подписаться

Получайте новые материалы про системы, инфраструктуру и AI engineering.