Модуль 4. Распределённые транзакции: Saga
Цель модуля: Научить студента проектировать распределённые бизнес-процессы, сохраняющие целостность данных при сбоях. Освоить паттерн Saga (Orchestration), реализовать стейт-машину, идемпотентные компенсирующие транзакции и защитить оркестратор от гонок при масштабировании. Понять, что компенсация — это НЕ ROLLBACK, а новая бизнес-операция, и что для необратимых операций (например, git merge) компенсация = «зафиксировать провал + точечный retry».
Стадия роста проекта: Сложные процессы. Проект пережил MVP, интеграции и Outbox. Теперь бизнес-операции требуют нескольких шагов в разных сервисах: зарезервировать товар, списать деньги, отправить уведомление. И первое, что ломается — шаги зависают на полпути. Деньги списаны, товар не зарезервирован.
Перед началом: Модули 1–3 (Trade-offs, Идемпотентность, Outbox) Инструменты: PostgreSQL, Kafka, pgx v5, k6, Toxiproxy Трудоёмкость: ~8 ч (теория 2 ч · практика 5 ч · ADR 1 ч)
🔥 Инцидент: перевод, который завис на полпути
Пользователь запускает P2P-перевод: зарезервировать средства → списать → зачислить → уведомить. Шаги выполняются в разных сервисах. Деньги зарезервированы, и… процесс остановился. В базе saga_instances статус RESERVING, время идёт, списания нет. Деньги заморожены.
Сервис уведомлений упал с 503 и не поднимался несколько минут. Оркестратор отправил команду, получил таймаут и не знает, что делать: считать шаг выполненным? Повторить? Компенсировать? А ещё — в K8s поднялось два пода оркестратора, и оба одновременно взяли одну Saga. Один записал WITHDRAWING, второй перезаписал обратно в RESERVING. Стейт «поехал», и теперь непонятно, на каком мы шаге.
Ты решаешь «просто откатить» через ROLLBACK — но это не работает: шаг Reserve уже закоммичен в другом сервисе, в другой БД. Чтобы вернуть деньги, нужна отдельная бизнес-операция — RefundWithdraw. И если запустить её без идемпотентности (Модуль 2), при ретраях можно вернуть деньги дважды.
И ещё один сценарий — из Git-хостинга. Пользователь нажимает Merge PR. Шаги: validate CI checks → git merge → notify watchers → update search index. CI проверку прошли, git merge сделали, но индексация упала. PR остаётся в статусе merging бесконечно. Команда предлагает «просто перезапустить Saga с начала». Но git merge уже прошёл: повторный merge даст конфликт или Already up to date, а откатить коммит в master значит переписать историю всех, кто успел git pull. Git merge нельзя отменить — значит, компенсация должна быть другой.
В распределённой системе «всё или ничего» нельзя получить одной транзакцией. Нужна Saga: последовательность шагов с компенсирующими операциями, явная стейт-машина и защита от гонок (FOR UPDATE). Компенсация — это не ROLLBACK, а новая бизнес-операция. Для обратимых операций (платёж) — отмена эффекта. Для необратимых (git merge) — фиксация провала + точечный retry упавшего шага.
📖 Теория: Раскрытые термины
Глоссарий модуля:
- Saga (Orchestration) — последовательность шагов с компенсирующими действиями; оркестратор ведёт стейт-машину.
- Компенсация — бизнес-операция, отменяющая эффект уже выполненного шага (например,
RefundWithdraw); это НЕROLLBACKБД. Для необратимых операций (git merge) — фиксация провала + точечный retry. - Стейт-машина — модель с конечным набором состояний и переходов между ними по событиям.
FOR UPDATE— блокировка строки от конкурентных транзакций; основа защиты оркестратора от гонок.- Таймаут-джоб — отдельный воркер, ищущий «зависшие» Saga в статусах
*INGи запускающий компенсацию. - 2PC / XA — две фазы: Prepare (блокировки) + Commit; зависание на Prepare — антипаттерн для микросервисов.
4.1. Почему 2PC/XA мертвы в микросервисах?
2PC (Two-Phase Commit) — протокол распределённой транзакции в две фазы. XA — стандарт, реализующий 2PC в базах данных и брокерах.
Как работает 2PC:
Координатор Участник A Участник B
│ │ │
├── Phase 1: PREPARE ───>│ │
├── Phase 1: PREPARE ────────────────────────>│
│<── OK (locked) ────────│ │ ← удерживают блокировки
│<── OK (locked) ─────────────────────────────│
│ │ │
├── Phase 2: COMMIT ────>│ │ ← снимают блокировки
├── Phase 2: COMMIT ────────────────────────>│
│<── DONE ───────────────│ │
│<── DONE ────────────────────────────────────│
Фаза 1 (Prepare): координатор спрашивает каждого участника «готов?». Каждый блокирует данные и отвечает OK или ABORT. Пока все не ответят — данные заблокированы.
Фаза 2 (Commit/Abort): если все ответили OK — координатор шлёт COMMIT. Если хоть один ABORT — ROLLBACK всем.
Почему это убивает микросервисы:
| Проблема | Что происходит | Последствие |
|---|---|---|
| Блокировки во время Prepare | Каждый участник удерживает блокировки до получения Commit | Другие транзакции ждут → лавина latency |
| Координатор упал после Prepare | Участники заблокировали данные и ждут решения координатора, который «молчит» | Вечная блокировка — данные недоступны |
| Сеть упала между фазами | Координатор отправил Commit участнику A, но не доставил участнику B | A закоммичен, B заблокирован — расщепление |
| Участник упал на Prepare | Координатор ждёт ответа, таймаут → отмена всем | Транзакция отменена, но пользователю уже сказано «перевод принят» |
| Синхронность | Каждый шаг ждёт ответа; N участников = N последовательных сетевых задержек | p99 растёт линейно с числом сервисов |
Почему XA не спасает: XA-транзакции работают только если все участники поддерживают XA. В реальности сторонние API (KYC, платёжный шлюз), Redis, MongoDB — не поддерживают XA. Даже PostgreSQL + Kafka через XA — хрупкая конструкция.
Где 2PC ещё жив: внутри одной БД (PostgreSQL сам использует 2PC-подобный механизм для распределённых транзакций внутри кластера), maintenance windows, Spanner/CockroachDB (с Paxos/Raft для координатора).
Вывод: 2PC/XA работает внутри одного хранилища, но не работает между микросервисами. Для распределённых бизнес-транзакций нужен Saga — без блокировок, с компенсациями.
4.2. Паттерн Saga (Orchestration vs Choreography)
Saga — это последовательность локальных транзакций. Для FinTech мы используем Orchestration: выделенный сервис (Saga Orchestrator) управляет процессом как стейт-машиной. Он отправляет команды сервисам через Outbox → Kafka и слушает их ответы. Это даёт единую точку истины (статус Saga в БД) и явную логику отката.
Choreography — альтернатива: сервисы реагируют на события друг друга без центрального оркестратора. Плюс: нет SPOF. Минус: сложно отследить общий статус, риск гонок между событиями. Для финтеха — Orchestration (явные гарантии, аудит). Для менее критичных потоков — Choreography допустима.
4.3. Компенсация — это НЕ ROLLBACK
Компенсирующая транзакция — это новая бизнес-операция (например, RefundWithdraw), которая отменяет эффект предыдущей.
Она обязана быть идемпотентной (через compensation_id).
Она сама должна использовать Outbox и иметь retry-логику. Иначе вы получите «компенсацию, которая дублирует возврат денег».
Для обратимых операций (финтех):
- Компенсация =
RefundWithdraw— отмена эффекта. - Деньги возвращаются, статус меняется.
Для необратимых операций (Git merge):
- Компенсация ≠ ROLLBACK:
git mergeне отменяем. - Компенсация = пометить PR
merge-failed+ записатьfailed_stepиlast_error+ ручной retry только упавшего шага. - Git не трогаем (merge уже прошёл и валиден).
Принцип тот же: компенсация — новая бизнес-операция, не SQL ROLLBACK. Но для Git это «зафиксировать провал + точечный retry», а не «отменить действие».
4.4. Связь Saga и Outbox (Модуль 3)
Оркестратор не делает HTTP-запросы к сервисам. Он обновляет свой стейт и в той же транзакции пишет команду в outbox_events.
go// internal/saga/orchestrator.go func (o *Orchestrator) transition(ctx context.Context, sagaID int64, to State, cmd string) error { tx, err := o.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return err } defer tx.Rollback(ctx) // no-op после Commit // (1) Защита от гонок: блокируем строку Saga var s Saga err = tx.QueryRow(ctx, `SELECT saga_id, state, aggregate_id FROM saga_instances WHERE saga_id = $1 FOR UPDATE`, sagaID).Scan(&s.ID, &s.State, &s.AggregateID) if err != nil { return err } // Идемпотентность: если стейт уже сменился — выходим if s.State != stateExpectedFor(to) { return ErrAlreadyAdvanced } // (2) Переход в новое состояние if _, err = tx.Exec(ctx, `UPDATE saga_instances SET state = $2, updated_at = NOW() WHERE saga_id = $1`, sagaID, to); err != nil { return err } // (3) Команда следующего шага — в Outbox (в той же транзакции) if cmd != "" { if _, err = tx.Exec(ctx, `INSERT INTO outbox_events(aggregate_id, event_type, payload, status) VALUES ($1, $2, $3, 'pending')`, s.AggregateID, cmd, []byte("{}")); err != nil { return err } } return tx.Commit(ctx) // одна транзакция — стейт + команда }
4.5. Гонки при масштабировании Оркестратора
Если запущено несколько инстансов Оркестратора (Kubernetes), они могут одновременно взять одну и ту же Saga и начать менять её стейт. Решение: При чтении Saga использовать SELECT ... FOR UPDATE (блокировка строки) или pg_advisory_lock(saga_id).
go// Защита от гонок: FOR UPDATE при чтении Saga err = tx.QueryRow(ctx, `SELECT saga_id, state, aggregate_id FROM saga_instances WHERE saga_id = $1 FOR UPDATE`, sagaID).Scan(&s.ID, &s.State, &s.AggregateID)
Плюс идемпотентная проверка текущего state: если уже сменился — ErrAlreadyAdvanced, без повторного перехода.
4.6. Таймауты: джоб поиска «зависших» Saga
Если Saga висит в статусе WITHDRAWING более 2 минут, значит сервис-исполнитель упал или не ответил. Таймаут-джоб ищет такие Saga и запускает компенсацию.
go// Таймаут-джоб: ищет зависшие Saga и запускает компенсацию func (j *TimeoutJob) Run(ctx context.Context) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: j.findAndCompensate(ctx) } } } func (j *TimeoutJob) findAndCompensate(ctx context.Context) { rows, err := j.pool.Query(ctx, `SELECT saga_id, state FROM saga_instances WHERE state LIKE '%ING' AND updated_at < NOW() - INTERVAL '2 minutes' LIMIT 10`) if err != nil { return } defer rows.Close() for rows.Next() { var sagaID int64 var state State rows.Scan(&sagaID, &state) // Запускаем компенсацию для зависшей Saga j.orchestrator.compensate(ctx, sagaID, state) } }
4.7. Этот паттерн в архетипах
Saga проявляется по-разному в четырёх архетипах проектов:
| Архетип | Шаги Saga | Компенсация | Обратимость |
|---|---|---|---|
| Финтех (P2P) | Reserve → Withdraw → Notify | RefundWithdraw | Обратимая |
| Git-хостинг | Validate → Merge → Notify → Index | Mark merge-failed + retry шага | Необратимая (git merge) |
| Мессенджер | Send → Deliver → Read | Mark delivery-failed + retry | Частично обратимая |
| Лента / чтение | N/A (нет многошаговых процессов) | N/A | N/A |
Общий принцип: каждый шаг Saga — локальная транзакция, компенсация — новая бизнес-операция. Различается только обратимость: финтех компенсирует отменой, Git компенсирует фиксацией провала.
4.8. Компромиссы (Trade-offs)
| Подход | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| 2PC / XA | атомарность всех шагов (all-or-nothing) | блокировки на этапе Prepare → зависание при сбое; НЕ подходит для микросервисов | антипаттерн для распределённых систем |
| Saga Orchestration | единый стейт-машина; явные компенсации; простота аудита | оркестратор = SPOF; требуется его отказоустойчивость (Multi-AZ) | когда важна согласованность шагов и понятный аудит |
| Saga Choreography | нет единой точки отказа; сервисы автономны | сложно отследить общий статус; риск гонок между событиями | когда сервисы слабо связаны |
| Компенсация = отмена эффекта (финтех) | возвращает систему в исходное состояние | пользователь может временно увидеть «отменено» | обратимые операции (платежи, резервирования) |
| Компенсация = фиксация провала + retry (Git) | не трогает историю; точечный повтор | PR временно в merge-failed (ручной шаг) | необратимые операции (git merge, публикация) |
Вывод для курса: Orchestration — для финтеха (явные гарантии, компенсации); Choreography — для менее критичных потоков. Для Git merge — компенсация = mark merge-failed + retry упавшего шага, НЕ ROLLBACK. См. ADR-4.
🛠 Практические задания
Сценарий: Перевод денег (P2P). Шаги: Reserve (счет отправителя) → Withdraw (счет отправителя) → Notify (пользователь).
Примерный план работы:
- Спроектировать
saga_instances→ индексы по state + updated_at (для таймаут-джоба). - Реализовать обработчик
handleReserveSuccess:SELECT … FOR UPDATE+ переход + команда в Outbox (одна транзакция). - Компенсация:
NotifyFailed/Timeout → COMPENSATING_WITHDRAW → RefundWithdrawчерез Outbox. - Таймаут-джоб: cron ищет
*INGсupdated_at < NOW() - 2 min→ запуск компенсации. - Тест Toxiproxy: деньги возвращаются, RTO < 1 мин, Saga не висит.
- ADR-4: «Orchestration vs Choreography».
Задача 1: Проектирование БД и Индексов
Создать таблицу saga_instances:
sqlCREATE TABLE saga_instances ( saga_id UUID PRIMARY KEY, aggregate_id UUID NOT NULL, state VARCHAR(50) NOT NULL, last_event_type VARCHAR(100), amount NUMERIC(18, 2) NOT NULL, source_account VARCHAR(255) NOT NULL, target_account VARCHAR(255) NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); -- Индексы для быстрого поиска таймаутов и переходов CREATE INDEX idx_saga_state ON saga_instances(state); CREATE INDEX idx_saga_updated ON saga_instances(updated_at); CREATE INDEX idx_saga_aggregate ON saga_instances(aggregate_id);
✅ Готово, когда:
- Таблица
saga_instances:saga_id UUID PK,state,amount,aggregate_id,updated_at - Индексы:
state(для таймаут-джоба),updated_at(для поиска просроченных),aggregate_id - ADR-4 «Orchestration vs Choreography» написан
Задача 2: Реализация Saga Orchestrator (Атомарность и Блокировки)
Студенту необходимо реализовать обработчик события (например, ReserveSuccessEvent).
Go (pgx): Использовать tx.QueryRow(ctx, "SELECT ... FOR UPDATE"). Запись команды в Outbox в той же tx.
go// Блокировка Saga + переход + команда в Outbox — одной транзакцией func (o *Orchestrator) HandleReserveSuccess(ctx context.Context, sagaID string) error { tx, err := o.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return err } defer tx.Rollback(ctx) // no-op после Commit // 1. Защита от гонок: блокируем строку Saga var state string if err := tx.QueryRow(ctx, "SELECT state FROM saga_instances WHERE saga_id=$1 FOR UPDATE", sagaID).Scan(&state); err != nil { return err } // Идемпотентность: если стейт уже сменился — выходим if state != "RESERVING" { return nil } // 2. Переход в новое состояние if _, err := tx.Exec(ctx, "UPDATE saga_instances SET state='RESERVED', updated_at=NOW() WHERE saga_id=$1", sagaID); err != nil { return err } // 3. Команда следующего шага — в Outbox (в той же tx) _, err = tx.Exec(ctx, `INSERT INTO outbox_events (event_type, aggregate_id, payload, status) VALUES ('WithdrawCommand', $1, $2, 'pending')`, sagaID, withdrawPayload) if err != nil { return err } return tx.Commit(ctx) // COMMIT для стейта и Outbox одновременно }
✅ Готово, когда:
-
SELECT ... FOR UPDATEпри поиске Saga (защита от гонок) - Идемпотентность: если
stateуже сменился — код выходит без повторного перехода - Переход стейта + запись команды в Outbox — одним
tx.Commit()
Задача 3: Компенсирующие транзакции и Таймауты
Сервис уведомлений упал. Оркестратор получает NotifyFailed (или срабатывает таймаут).
Оркестратор переводит Saga в COMPENSATING_WITHDRAW и отправляет команду RefundWithdraw (через Outbox).
Таймауты: Cron-задача ищет Saga в статусе *ING с updated_at < NOW() - INTERVAL '2 minutes' (используя индекс idx_saga_state). Если найдена — принудительно запускает компенсацию.
✅ Готово, когда:
- При
NotifyFailedSaga →COMPENSATING_WITHDRAW→ командаRefundWithdrawчерез Outbox - Cron-задача ищет
*INGстарше 2 минут (по индексу) и принудительно запускает компенсацию - Toxiproxy: при отказе сервиса уведомлений деньги возвращаются, RTO < 1 мин, Saga не висит
Задача 4: Тест Toxiproxy + k6
Toxiproxy отбрасывает 50% запросов к сервису уведомлений. k6 шлёт 100 переводов. SLO: деньги возвращаются при отказе, RTO < 1 мин, Saga не зависает.
✅ Готово, когда:
- Toxiproxy: 50% timeout между оркестратором и сервисом уведомлений
- k6: 100 переводов параллельно
- Деньги возвращаются при отказе; RTO < 1 мин; Saga не висит
⚠️ Антипаттерн для разбора
Студенту дается этот код для анализа.
go// Go Orchestrator (Антипаттерн) func (h *Handler) Transfer(w http.ResponseWriter, r *http.Request) { // (1) Резервируем средства h.reserveService.Reserve(r.Context(), amount, sourceAccount) // (2) Списываем средства h.withdrawService.Withdraw(r.Context(), amount, sourceAccount) // (3) Уведомляем пользователя h.notifyService.Notify(r.Context(), userID) // ❌ упало → деньги списаны, уведомление не ушло // «просто перезапустить» → повторное списание writeJSON(w, map[string]string{"status": "ok"}) }
Вопросы студенту:
- Что произойдет, если
notifyServiceбудет недоступен (HTTP 503)? - Что произойдет, если сервис списал деньги, но сеть оборвалась до получения HTTP-ответа? В каком статусе останется Saga?
- Почему «просто перезапустить» недопустимо, если
Withdrawуже прошёл?
✅ Правильное решение
Saga Orchestration с компенсациями. Каждый шаг — локальная транзакция. Компенсация — новая бизнес-операция, идемпотентная через compensation_id.
gofunc (o *Orchestrator) compensate(ctx context.Context, sagaID string, step string) error { tx, err := o.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return err } defer tx.Rollback(ctx) // no-op после Commit // Защита от гонок + идемпотентность var state string if err := tx.QueryRow(ctx, "SELECT state FROM saga_instances WHERE saga_id=$1 FOR UPDATE", sagaID).Scan(&state); err != nil { return err } // Если уже компенсируем или компенсировали — выходим if state == "COMPENSATING" || state == "COMPENSATED" { return nil } // Переход в COMPENSATING + команда компенсации в Outbox if _, err := tx.Exec(ctx, "UPDATE saga_instances SET state='COMPENSATING', updated_at=NOW() WHERE saga_id=$1", sagaID); err != nil { return err } _, err = tx.Exec(ctx, `INSERT INTO outbox_events (event_type, aggregate_id, payload, status) VALUES ('RefundWithdraw', $1, $2, 'pending')`, sagaID, compensationPayload) if err != nil { return err } return tx.Commit(ctx) }
📐 Задание по визуализации (Mermaid)
Студенту необходимо нарисовать диаграмму состояний (State Diagram) для Saga, включая пути компенсации и ветку игнорирования (Notify).
Шаблон для ориентира:
🧩 Проверь себя
🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 4
Привет! Я начинаю Модуль 4 курса «Архитектура распределённых систем».
Дай короткий ответ (2–3 предложения): почему компенсирующая транзакция в Saga — это не то же самое, что ROLLBACK в SQL, и почему она обязана быть идемпотентной?
Объясни, зачем при чтении Saga в Оркестраторе нужно использовать
SELECT ... FOR UPDATE(или advisory lock), если мы запускаем несколько подов Оркестратора?Предоставь мне SQL-миграцию для таблицы
saga_instancesс правильными индексами для таймаут-джобов.Покажи мне код Антипаттерна (синхронный HTTP из Оркестратора) и попроси меня объяснить, при каких сценариях Saga зависнет или потеряет данные.
Дай описание сценария P2P-перевода (Reserve -> Withdraw -> Notify).
НЕ ПИШИ за меня реализацию State Machine и компенсаций. Дождись моего ответа с архитектурным планом. После этого дай фидбек по чек-листу ментора и укажи на слепые зоны (например, «ты не учел таймауты», «нет блокировки Saga при чтении»).
📝 Чек‑лист ментора
-
Студент аргументирует, почему Orchestration, а не Choreography, подходит для финансов.
-
Оркестратор не делает синхронных HTTP-вызовов: команды идут через
outbox_events→ Kafka. -
Обновление стейта Saga и запись команды в Outbox происходят в одной транзакции БД.
-
Оркестратор использует
FOR UPDATEили advisory lock для защиты от гонок при масштабировании. -
Стейт-машина покрывает все переходы, включая компенсации и Ignore-ветку для Notify.
-
Критично: Компенсирующие операции реализованы как новые идемпотентные бизнес-операции (через Outbox и compensation_id).
-
Для Git merge: компенсация = mark
merge-failed+ retry упавшего шага, НЕ ROLLBACK. -
Реализован механизм таймаутов (Cron / delayed message), который находит «зависшие» Saga (используя индекс
idx_saga_state) и запускает компенсацию. -
Оркестратор корректно обрабатывает дубли событий (проверяет текущий стейт перед применением).
-
Тест с Toxiproxy доказывает, что при обрыве сети деньги возвращаются, Saga не зависает, RTO < 1 минута.
-
Mermaid-диаграмма корректно отображает все пути (happy path, компенсации, Ignore).
-
Студент понимает, как Saga проявляется в разных архетипах (финтех, Git, мессенджер, лента).
-
ADR-4: Student сформулировал ADR «Orchestration vs Choreography для Saga» (контекст, альтернативы, решение, последствия).
🔗 Мостик к Модулю 5 (Resilience)
В конце ревью обязательно скажи студенту:
- «Отлично, мы научились откатывать транзакции, если сервис упал. Но что если внешний сервис (например, KYC) не падает, а просто начинает тормозить на 30 секунд? Наша Saga будет висеть, ожидая таймаута, забивая пулы соединений. В Модуле 5 мы научим систему не ждать, а быстро «размыкать цепь» (Circuit Breaker) и переходить в режим деградации (Fallback). При этом мы свяжем Circuit Breaker с Saga: если сервис в состоянии "открыт", мы сразу переводим Saga в компенсацию.»
Если ты прошёл курс «Git Hosting: Serverless MVP», ты уже видел этот паттерн на упрощённом стеке: YDB Serverless с атомарным UPDATE вместо PostgreSQL с FOR UPDATE, Cloud Function Timer Trigger вместо goroutine-оркестратора. Паттерн тот же — инфраструктура разная. Здесь мы разбираем его на полном стеке, с глубиной и trade-offs.