Как обеспечить консистентность данных при выполнении распределенных транзакций в микросервисной архитектуре?

Ответ

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

Основной паттерн для управления распределенными транзакциями — Saga.

Паттерн Saga

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

Существует два способа координации саги:

  1. Хореография (Choreography): Сервисы общаются напрямую друг с другом через события. Один сервис выполняет свою транзакцию и публикует событие в брокер сообщений. Другие сервисы подписываются на это событие, выполняют свои транзакции и публикуют новые события.

    • Плюсы: Простота, отсутствие единой точки отказа.
    • Минусы: Сложно отслеживать полный флоу транзакции, риск циклических зависимостей.
  2. Оркестрация (Orchestration): Центральный сервис-оркестратор управляет всем процессом. Он говорит каждому участнику, какую локальную транзакцию выполнить. Если что-то идет не так, оркестратор отвечает за запуск компенсирующих транзакций.

    • Плюсы: Централизованная логика, ясный флоу, проще в отладке.
    • Минусы: Дополнительный сервис, который может стать единой точкой отказа.

Концептуальный пример оркестратора на Go:

type Step struct {
    Execute    func(ctx context.Context) error
    Compensate func(ctx context.Context) error
}

// runSaga выполняет шаги и откатывается при ошибке.
func runSaga(ctx context.Context, steps []Step) error {
    completedSteps := 0
    for _, step := range steps {
        if err := step.Execute(ctx); err != nil {
            // Ошибка на текущем шаге, запускаем компенсацию
            for i := completedSteps - 1; i >= 0; i-- {
                // Логгируем ошибки компенсации, но продолжаем откат
                if compErr := steps[i].Compensate(ctx); compErr != nil {
                    log.Printf("Saga compensation for step %d failed: %v", i, compErr)
                }
            }
            return fmt.Errorf("saga execution failed: %w", err)
        }
        completedSteps++
    }
    return nil
}

Другие важные паттерны:

  • Transactional Outbox: Гарантирует, что событие будет отправлено хотя бы один раз после успешного коммита транзакции в БД (см. связанный вопрос).
  • Двухфазный коммит (2PC): Классический, но редко используемый в микросервисах паттерн из-за его блокирующей природы и чувствительности к сбоям координатора.

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