9. Message choreography vs orchestration

Хореография против оркестрации: битва архитектурных подходов

«Любая работающая сложная система неизменно эволюционирует из работающей простой системы. Сложная система, спроектированная с нуля, никогда не заработает, и никакие заплатки ей не помогут. Придется начать заново с работающей простой системы».
— Джон Галл (Закон Галла)


Две философии распределенной координации

Когда бизнес-процесс выходит за границы одного сервиса, перед архитектором встает фундаментальный вопрос: кто управляет ходом выполнения?

Индустрия предлагает два противоположных ответа:

TEXT
    Джазовый ансамбль (Хореография):
    Музыканты слушают друг друга и импровизируют в такт. Саксофон слышит финал 
    партии рояля и вступает сам. Нет дирижера. Если музыкантов четверо — это магия. 
    Если на сцену выпустить 50 музыкантов — начнется оглушительная какофония.

    Симфонический оркестр (Оркестрация):
    На подиуме стоит Дирижер с полной партитурой. Скрипка не смотрит на литавры; 
    музыканты смотрят на взмах дирижерской палочки. Дирижер точно знает, 
    кто играет сейчас, кто вступает через 4 такта, и что делать, если флейта сфальшивила.

Суть Оркестрации

В Оркестрации в архитектуру вводится выделенный компонент — Оркестратор (Saga Orchestrator / Process Manager).

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

  1. Отправляет сервисам явные директивы — Команды (DebitAccountCommand, ReserveInventoryCommand);
  2. Дожидается от сервисов асинхронных ответов — Событий-результатов об успехе или неудаче (PaymentSucceeded, InventoryFailed);
  3. Принимает решение о переходе к следующему шагу или запуске цепочки компенсаций.
flowchart TD
    O["Оркестратор Заказов<br>(State Machine в Go)"]

    O -->|"1. Команда: Списать деньги"| P["Сервис Оплаты"]
    P -.->|"2. Ответ: Деньги списаны"| O

    O -->|"3. Команда: Резерв товара"| I["Сервис Склада"]
    I -.->|"4. Ответ: Ошибка (Нет на складе)"| O

    O -->|"5. Команда: Вернуть деньги"| P
    P -.->|"6. Ответ: Возврат выполнен"| O

    O -->|"7. Фиксация статуса: CANCELED"| DB[/"БД Оркестратора"/]

    style O fill:#1e293b,stroke:#6366f1,stroke-width:2px,color:#f8fafc
    style DB fill:#0f172a,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    style P fill:#131d33,stroke:#10b981,stroke-width:2px,color:#f8fafc
    style I fill:#131d33,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
  

В этой модели подчиненные микросервисы (Оплата, Склад, Доставка) становятся предельно простыми воркерами: они ничего не знают о глобальном контексте заказа и лишь надежно выполняют точечные поручения.


Mechanical Sympathy: Цена централизации

Переход от децентрализованной хореографии к централизованной оркестрации влечет за собой серьезные аппаратные и сетевые последствия.

1. Сетевые прыжки (Network Hops) и Latency

Сравним физический путь пакетов по сети:

  • Хореография: Сервис Оплаты публикует событие в брокер $\to$ Сервис Склада вычитывает его из брокера.
    Итог: 1 сетевой переход (Network Hop) через брокер.
  • Оркестрация: Сервис Оплаты завершает работу $\to$ шлет ответ в брокер $\to$ Оркестратор вычитывает ответ $\to$ Оркестратор выполняет транзакцию в своей СУБД $\to$ Оркестратор отправляет команду в брокер $\to$ Сервис Склада вычитывает команду.
    Итог: 2 сетевых перехода + синхронный I/O в базу данных Оркестратора.

Оркестрация всегда увеличивает совокупную сетевую задержку (End-to-End Latency) бизнес-процесса!

2. Узкое место БД (Database Bottleneck)

Оркестратор обязан быть надежным конечным автоматом (State Machine). Чтобы не потерять состояние процесса при падении контейнера в Kubernetes, Оркестратор обязан фиксировать текущий шаг в реляционной базе данных при каждом переходе:

SQL
UPDATE sagas SET state = 'AWAITING_STOCK', updated_at = NOW() WHERE saga_id = $1;

Если бизнес-процесс состоит из 10 шагов, Оркестратор совершит 10 транзакционных записей в СУБД на каждый заказ. При нагрузке в 10 000 заказов в секунду это создает поток в 100 000 UPDATE/сек в одну базу данных! Это приводит к жесткой конкуренции за блокировки строк, вытеснению страниц из буферного пула (Buffer Pool) и риску деградации дисковой подсистемы.


Идиоматичный Go: Как выглядит Оркестратор под капотом

На Go надежный Оркестратор реализуется как конечный автомат (State Machine), чьи переходы синхронизированы с базой данных и очередями через паттерн 6. Outbox pattern:

Go
package orchestrator

import (
    "context"
    "database/sql"
    "fmt"
)

// Состояния жизненного цикла бизнес-процесса заказа
const (
    StatePending         = "PENDING"
    StateAwaitingPayment = "AWAITING_PAYMENT"
    StateAwaitingStock   = "AWAITING_STOCK"
    StateCompleted       = "COMPLETED"
    StateCompensating    = "COMPENSATING"
    StateFailed          = "FAILED"
)

type OrderSaga struct {
    OrderID string
    State   string
}

type PaymentResultMessage struct {
    OrderID string `json:"order_id"`
    Success bool   `json:"success"`
    Reason  string `json:"reason"`
}

type ReserveCommand struct {
    OrderID string `json:"order_id"`
}

type Orchestrator struct {
    db     *sql.DB
    outbox OutboxDispatcher
}

type OutboxDispatcher interface {
    Save(tx *sql.Tx, topic string, payload any) error
}

// HandlePaymentResult обрабатывает асинхронный ответ от Сервиса Оплаты
func (o *Orchestrator) HandlePaymentResult(ctx context.Context, msg PaymentResultMessage) error {
    tx, err := o.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback()

    // 1. Блокируем строку саги (FOR UPDATE) для защиты от гонок параллельных ответов
    var saga OrderSaga
    query := `SELECT order_id, state FROM sagas WHERE order_id = $1 FOR UPDATE`
    err = tx.QueryRowContext(ctx, query, msg.OrderID).Scan(&saga.OrderID, &saga.State)
    if err != nil {
        return fmt.Errorf("load saga state: %w", err)
    }

    // 2. Проверка инвариантов State Machine (Идемпотентность)
    if saga.State != StateAwaitingPayment {
        // Дубликат ответа или нарушение хронологии: безопасно игнорируем
        return nil 
    }

    // 3. Выбор ветки перехода конечного автомата
    if msg.Success {
        // Успех: переходим к резервированию товаров на складе
        _, err = tx.ExecContext(ctx, `UPDATE sagas SET state = $1 WHERE order_id = $2`, StateAwaitingStock, saga.OrderID)
        if err != nil {
            return err
        }

        // Атомарно планируем отправку команды складу через Outbox
        cmd := ReserveCommand{OrderID: saga.OrderID}
        if err := o.outbox.Save(tx, "inventory.commands", cmd); err != nil {
            return fmt.Errorf("outbox save: %w", err)
        }
    } else {
        // Провал оплаты: переводим сагу в статус FAILED (компенсировать пока нечего)
        _, err = tx.ExecContext(ctx, `UPDATE sagas SET state = $1 WHERE order_id = $2`, StateFailed, saga.OrderID)
        if err != nil {
            return err
        }
    }

    return tx.Commit()
}

Битва подходов: Хореография vs Оркестрация

В архитектуре нет абсолютного добра и зла: каждый выбор — это компромисс между автономностью и контролем:

Критерий Хореография (События) Оркестрация (Команды)
Связность (Coupling) Минимальная. Сервисы полностью независимы и не знают о клиентах. Высокая. Оркестратор обязан знать контракты команд всех подчиненных сервисов.
Наблюдаемость (Observability) Низкая. Картина процесса размазана по логам десятка микросервисов. Идеальная. Текущее состояние процесса видно в реальном времени в СУБД Оркестратора.
Управление ошибками Сложное. Логика компенсаций децентрализована. Риск зацикливаний. Прозрачное. Сценарии отката и ретраев собраны в единой кодовой базе.
Узкое место (Bottleneck) Пропускная способность брокера сообщений. База данных Оркестратора (State Store).
Сложность внедрения Низкая на старте, экспоненциально растет с числом шагов. Высокая на старте, остается стабильной при росте системы.
Оптимально для... Компактных флоу (2–4 шага), интеграции независимых доменов. Сложных процессов (много шагов, таймауты, циклы, ветвления if-else).


Итог раздела "Паттерны и архитектура"

Мы прошли колоссальный путь по систематизации асинхронных паттернов:

  1. Pub/Sub (1. Pub Sub) развязывает сервисы для рассылки событий на многих (Fan-out), а Work Queue (2. Work Queue) распределяет тяжелую вычислительную работу строго одному воркеру;
  2. Event-Driven Architecture (3. Event Driven Architecture) освобождает ресурсы CPU и планировщика Go от синхронных I/O-блокировок;
  3. Event Sourcing (4. Event sourcing и брокеры) и CQRS (5. CQRS и брокеры) решают конфликт аппаратных профилей записи и чтения;
  4. Transactional Outbox (6. Outbox pattern) и Transactional Inbox (7. Inbox pattern) обеспечивают надежность класса Effectively-Once без мертвого протокола 2PC;
  5. Saga (8. Saga через брокеры) объединяет разрозненные сервисы в распределенные бизнес-транзакции через хореографию или централизованную оркестрацию.

Однако в любых асинхронных распределенных архитектурах возникает критический вопрос эксплуатации: как отслеживать путь запроса, когда сообщения перепрыгивают через брокеры, воркеры и отложенные очереди?

В завершающей лекции подраздела мы разберем фундаментальный инструмент надежности: 10. Distributed tracing в async системах.