1. Saga pattern
Транзакции в микросервисах: Проблема распределенного состояния
"В распределенных системах нет магии. Если вы попытаетесь притвориться, что сеть мгновенна, а чужая база данных — это часть вашей локальной памяти, расплата наступит в виде зависших блокировок и мертвого продакшена."
— Брайан Керниган
В классической монолитной архитектуре с реляционной базой данных (будь то PostgreSQL или MySQL) разработчики привыкли к комфорту и математической строгости ACID-транзакций. Мы открываем транзакцию через BEGIN, вносим изменения в три разные таблицы, и если на четвертом шаге что-то пошло не так — база данных мгновенно выполняет спасительный ROLLBACK. За эту атомарность и изоляцию отвечают низкоуровневые механизмы СУБД: журнал упреждающей записи (Write-Ahead Log, WAL), блокировки строк (row-level locks) и многоверсионное управление конкурентным доступом (MVCC).
Однако в микросервисной архитектуре этот уютный монолитный рай исчезает. Золотой стандарт изоляции гласит: Database per Service (у каждого сервиса — собственная независимая база данных). Сервис заказов не имеет прямого доступа к таблицам сервиса платежей или склада.
Представьте хрестоматийный сценарий оформления покупки в интернет-магазине:
- Сервис Orders создает запись о заказе в статусе ожидания.
- Сервис Payments списывает денежные средства с банковской карты покупателя.
- Сервис Inventory резервирует запрашиваемые товары на складе.
Что произойдет, если на третьем шаге сервис Inventory падает с фатальной ошибкой: «Товар только что закончился на складе»?
Деньги у пользователя уже списаны! В монолите один системный вызов СУБД выполнил бы ROLLBACK, откатив списание и создание заказа. Но в распределенной системе у нас нет единого WAL, нет общих блокировок и нет единого координатора транзакций. Вызывающий сервис не может просто послать ROLLBACK в чужую базу данных.
Если мы ничего не предпримем, система окажется в рассогласованном состоянии: деньги у клиента украдены, а товар не выдан. Нам необходим инженерный паттерн, гарантирующий согласованность данных в конечном итоге (Eventual Consistency) без использования тяжелых и блокирующих сетевых протоколов.
Этот паттерн носит имя Saga.
Что такое Saga?
Saga — это архитектурный паттерн проектирования распределенных систем, при котором длинная распределенная бизнес-транзакция (Long Lived Transaction, LLT) разбивается на упорядоченную цепочку независимых локальных транзакций.
Каждая локальная транзакция обновляет состояние строго в рамках одной изолированной базы данных своего сервиса и публикует во внешнюю шину (Kafka, RabbitMQ, NATS) сообщение или событие, которое инициирует выполнение следующей локальной транзакции в соседнем сервисе.
Фундаментальный постулат Saga: если какой-либо промежуточный шаг распределенной цепочки завершается бизнес-ошибкой или фатальным сбоем, система обязана запустить строго определенную цепочку компенсирующих транзакций (Compensating Transactions), которые семантически нейтрализуют все изменения, внесенные предыдущими успешными шагами.
Принцип работы
Формально Saga $T$ представляется в виде последовательности дискретных шагов:
$$T = \{T_1, T_2, T_3, \dots, T_n\}$$
Для каждого прямого шага $T_i$ (за исключением финального, подтверждающего) проектируется парное компенсирующее действие $C_i$:
- $T_i$ — локальная транзакция, двигающая процесс вперед (например, списание баланса);
- $C_i$ — компенсирующая транзакция, откатывающая последствия $T_i$ (например, возврат списанных средств).
Алгоритм выполнения:
- Выполняется шаг $T_1$. Если успешно — инициируется шаг $T_2$.
- Если $T_2$ завершился успешно — запускается $T_3$.
- Если на шаге $k$ ($T_k$) происходит сбой (нехватка товара, отказ валидации):
- Прямой ход прерывается;
- Координатор запускает обратную волну компенсаций: $C_{k-1}, C_{k-2}, \dots, C_1$.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Start["Начало Saga"]:::process --> Step1["T1. Создать заказ"]
Step1 --> Step2["T2. Списать деньги"]
Step2 -- "Успех" --> Step3["T3. Забронировать товар"]
Step3 -- "Ошибка! Нет товара" --> Comp2["C2. Вернуть деньги"]
Step2 -- "Ошибка! Нет средств" --> Comp1["C1. Отменить заказ"]
Step3 -- "Успех" --> End["Saga завершена успешно"]
Comp2 --> Comp1
Comp1 --> Fail["Saga завершена с откатом"]
Важно понимать: компенсация — это не физический откат памяти или строк БД к исходному состоянию (как в WAL). Это новое прямое действие, компенсирующее бизнес-эффект. Например, если $T_2$ списал 1000 рублей, то $C_2$ зачисляет 1000 рублей обратно, оставляя в истории аудита две явные бухгалтерские проводки.
Два способа координации: Хореография vs Оркестрация
Выбор способа координации участников саги — это классический инженерный компромисс между автономностью компонентов и когнитивной сложностью системы.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Choreography["Хореография (Event-driven)"]
direction TB
C1["Order Service"]:::process -- "OrderCreated" --> C2["Payment Service"]
C2 -- "PaymentProcessed" --> C3["Inventory Service"]
C3 -. "InventoryFailed" .-> C2
C2 -. "RefundIssued" .-> C1
end
subgraph Orchestration["Оркестрация (Command-driven)"]
direction TB
O["Saga Orchestrator<br>(State Machine)"]
O -- "1. Command: Pay" --> S1["Payment Service"]
S1 -- "Reply: Paid" --> O
O -- "2. Command: Reserve" --> S2["Inventory Service"]
S2 -- "Reply: OutOfStock" --> O
O -- "3. Command: Refund" --> S1
end
1. Хореография (Choreography)
Хореография — это полностью децентрализованный подход. В системе нет начальника или центрального дирижера. Сервисы общаются исключительно языком фактов — бизнес-событий (Events) через общую шину сообщений:
- Шаг 1: Сервис Order Service создает запись в БД и публикует событие
OrderCreated. - Шаг 2: Сервис Payment Service подписан на события заказов. Услышав
OrderCreated, он списывает деньги и публикует событиеPaymentProcessed. - Шаг 3: Сервис Inventory Service слушает событие
PaymentProcessed, пытается зарезервировать складские остатки, обнаруживает пустую полку и публикует событиеInventoryFailed. - Откат: Payment Service подписан на событие
InventoryFailed. Получив его, он немедленно выполняет возврат средств и публикуетRefundIssued, по которому Order Service переводит заказ в статусCancelled.
Преимущества хореографии:
- Предельная простота на ранних этапах (для цепочек из 2–3 сервисов);
- Слабая связность (сервисы ничего не знают об адресах и интерфейсах друг друга, они знают только контракты событий);
- Отсутствие дополнительной инфраструктуры в виде сервиса-координатора.
Недостатки хореографии:
- Spaghetti Logic: Бизнес-логика транзакции размазана по кодовым базам разных команд. Чтобы понять, как работает жизненный цикл заказа, инженеру приходится собирать мозаику из десятка репозиториев;
- Риск циклических зависимостей: Сервис A слушает события от B, а сервис B подписан на побочные события A;
- Сложность мониторинга и отладки: Без сквозного Distributed Tracing невозможно восстановить текущую стадию выполнения саги.
2. Оркестрация (Orchestration)
Оркестрация — это централизованный подход. За координацию процесса отвечает специализированный компонент — Saga Orchestrator (дирижер). Оркестратор отправляет участникам асинхронные команды («сделай действие X») и ожидает от них ответов о статусе выполнения:
- Оркестратор отправляет команду сервису платежей: «Списать $100 за заказ #42».
- Получает статус-ответ: «Успешно списано».
- Отправляет команду сервису склада: «Забронировать 1 шт. товара #99».
- Получает статус-ответ: «Ошибка: товара нет на складе».
- Принимает управленческое решение о запуске компенсации: отправляет сервису платежей команду «Вернуть $100 за заказ #42».
Преимущества оркестрации:
- Единый центр управления: весь сценарий транзакции и логика ветвлений зафиксированы в одном месте (в коде конечного автомата оркестратора);
- Легкость модификации: добавление нового шага (например, отправка SMS-уведомления) требует изменения только кода оркестратора, а не всех сервисов цепочки;
- Простота мониторинга: статус любой транзакции можно мгновенно узнать по ID саги из базы оркестратора.
Недостатки оркестрации:
- Появление выделенного сервиса-оркестратора, требующего поддержки отказоустойчивости и репликации;
- Опасность превращения оркестратора в «умного бога» (God Object), отбирающего бизнес-логику у доменных сервисов (сервисы рискуют выродиться в анемичные CRUD-хранилища).
Реализация Оркестратора на Go
В языке Go надежный оркестратор проектируется как персистентный конечный автомат (State Machine). Состояние каждого экземпляра саги обязано сохраняться в транзакционном хранилище оркестратора (PostgreSQL / SQLite) перед отправкой каждой сетевой команды. Это гарантирует, что при внезапном падении pod'а оркестратор после перезапуска прочитает журнал и продолжит сагу с прерванного шага.
Структуры данных состояния саги
package saga
import (
"time"
"github.com/google/uuid"
)
type SagaStatus string
const (
StatusRunning SagaStatus = "RUNNING"
StatusCompleted SagaStatus = "COMPLETED"
StatusCompensating SagaStatus = "COMPENSATING" // Идет откат предыдущих шагов
StatusFailed SagaStatus = "FAILED" // Сага завершилась с бизнес-ошибкой
)
// SagaStep описывает состояние конкретного этапа
type SagaStep struct {
Name string `json:"name"`
Status SagaStatus `json:"status"`
Compensate bool `json:"compensate"` // Требуется ли компенсация при откате
}
// SagaInstance представляет персистентный слепок транзакции
type SagaInstance struct {
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
CurrentStep int `json:"current_step"`
Status SagaStatus `json:"status"`
Steps []SagaStep `json:"steps"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SagaReply struct {
SagaID uuid.UUID
Step int
Success bool
Error string
}
Обработчик шагов (упрощенно)
Оркестратор обрабатывает асинхронные ответы от сервисов-исполнителей и переводит автомат в следующее состояние:
package saga
import (
"log/slog"
"time"
)
type Repository interface {
GetSaga(id uuid.UUID) (*SagaInstance, error)
Save(saga *SagaInstance) error
}
type Orchestrator struct {
repo Repository
}
func (o *Orchestrator) HandleReply(reply SagaReply) {
// Загружаем состояние Saga из БД (чтобы быть устойчивыми к падениям)
saga, err := o.repo.GetSaga(reply.SagaID)
if err != nil {
slog.Error("Critical: saga state retrieval failed", "saga_id", reply.SagaID, "err", err)
return
}
saga.UpdatedAt = time.Now().UTC()
if reply.Success {
saga.Steps[saga.CurrentStep].Status = StatusCompleted
saga.CurrentStep++
// Проверяем, был ли это финальный шаг
if saga.CurrentStep == len(saga.Steps) {
saga.Status = StatusCompleted
if err := o.repo.Save(saga); err != nil {
slog.Error("Failed to persist completed saga", "err", err)
}
slog.Info("Saga successfully completed", "saga_id", saga.ID)
return
}
// Запускаем исполнение следующей команды
o.executeStep(saga, saga.CurrentStep)
} else {
// Ошибка шага! Переходим в режим компенсации
slog.Warn("Step failed, initiating compensation",
"saga_id", saga.ID,
"failed_step", saga.CurrentStep,
"error", reply.Error,
)
saga.Status = StatusCompensating
o.compensate(saga)
}
if err := o.repo.Save(saga); err != nil {
slog.Error("Failed to save saga state", "err", err)
}
}
func (o *Orchestrator) compensate(saga *SagaInstance) {
// Двигаемся в строгом обратном порядке от последнего завершенного шага
for i := saga.CurrentStep - 1; i >= 0; i-- {
step := &saga.Steps[i]
if step.Status == StatusCompleted && step.Compensate {
slog.Info("Dispatching compensation command", "saga_id", saga.ID, "step", step.Name)
o.sendCompensateCommand(saga.ID, *step)
}
}
saga.Status = StatusFailed
}
func (o *Orchestrator) executeStep(saga *SagaInstance, stepIdx int) {
// Отправка команды в шину сообщений (Kafka/RabbitMQ)
}
func (o *Orchestrator) sendCompensateCommand(sagaID uuid.UUID, step SagaStep) {
// Отправка компенсирующей команды в шину сообщений
}
Проблемы и аномалии Saga
Классические базы данных гарантируют уровень изолированности транзакций (Isolation в ACID). Saga не обеспечивает изолированности на глобальном уровне.
Каждая локальная транзакция коммитит свои изменения в базу данных немедленно. Это означает, что промежуточное состояние системы видно другим параллельным процессам. Это порождает классические распределенные аномалии:
1. Lost Updates (Потерянные обновления)
Возникает, когда две параллельные саги одновременно читают и модифицируют один и тот же агрегат без координации:
- Сага A читает баланс кошелька клиента:
$100. - Сага B параллельно читает тот же баланс:
$100. - Сага A выполняет списание
$10и записывает:$90. - Сага B выполняет списание
$10и записывает:$90. - Итог: со счета списано $20, но итоговый баланс равен $90 вместо $80. Одно списание бесследно утеряно.
Решение: Отказ от неявных перезаписей в пользу оптимистичных блокировок (Optimistic Concurrency Control) с инкрементом версии:
UPDATE accounts
SET balance = balance - 10, version = version + 1
WHERE id = $1 AND version = $2;
Если строка уже изменена другой транзакцией, запрос обновит 0 строк, и приложение повторит операцию с актуальным балансом.
2. Dirty Reads (Грязное чтение)
Внешний наблюдатель видит промежуточный результат незаконченной саги:
- Сага заказа списала $100 с карты покупателя (Шаг $T_2$).
- Пользователь открывает личный кабинет и видит уведомление: «Оплата подтверждена».
- На шаге $T_3$ склад сообщает об отсутствии товара. Запускается компенсация $C_2$.
- Деньги возвращаются, но пользователь в течение нескольких секунд наблюдал ложное состояние успешной покупки.
Решение: Использование семантических блокировок (Semantic Locks). Сервисы не переводят сущности в терминальные состояния до завершения саги. Заказ создается с промежуточным статусом: status: PENDING. Интерфейсы и внешние системы не показывают клиенту статус «Успешно» до получения финального события подтверждения от оркестратора.
Semantic Lock и идемпотентность
В ненадежной распределенной сети сообщения могут теряться, приходить с задержкой или дублироваться. Если оркестратор отправил команду списания, но из-за сетевого сбоя не получил ответ вовремя, он обязан повторить команду.
Если хэндлер сервиса не обладает идемпотентностью, повторный запрос спишет деньги еще раз!
Для обеспечения надежности в Go используется связка уникального ключа идемпотентности (Idempotency-Key), генерируемого оркестратором, и локальной транзакции базы данных:
package payment
import (
"context"
"database/sql"
"fmt"
)
type WithdrawCommand struct {
IdempotencyKey string
AccountID string
Amount int64
}
type PaymentService struct {
db *sql.DB
}
func (s *PaymentService) Withdraw(ctx context.Context, cmd WithdrawCommand) error {
// Открываем локальную ACID транзакцию
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return fmt.Errorf("failed to begin tx: %w", err)
}
defer tx.Rollback()
// 1. Пытаемся зафиксировать IdempotencyKey в служебной таблице
// PRIMARY KEY гарантирует, что дубликат команды будет отклонен СУБД
const insertKeyQuery = `
INSERT INTO processed_commands (idempotency_key, created_at)
VALUES ($1, NOW())
ON CONFLICT (idempotency_key) DO NOTHING;
`
res, err := tx.ExecContext(ctx, insertKeyQuery, cmd.IdempotencyKey)
if err != nil {
return fmt.Errorf("idempotency check query failed: %w", err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
// Если ключ уже существовал в БД — мы уже обрабатывали этот запрос!
if rowsAffected == 0 {
// Идемпотентно завершаем: возвращаем nil, дубликат безопасно проигнорирован
return nil
}
// 2. Выполняем бизнес-логику списания
const withdrawQuery = `
UPDATE accounts
SET balance = balance - $1
WHERE id = $2 AND balance >= $1;
`
res, err = tx.ExecContext(ctx, withdrawQuery, cmd.Amount, cmd.AccountID)
if err != nil {
return fmt.Errorf("failed to withdraw funds: %w", err)
}
affected, err := res.RowsAffected()
if err != nil || affected == 0 {
return fmt.Errorf("insufficient funds or account not found")
}
// Фиксируем локальную транзакцию
return tx.Commit()
}
Сравнение с 2PC (Two-Phase Commit)
Почему индустрия практически отказалась от классического двухфазного коммита (2PC / XA) в микросервисах в пользу Saga?
| Характеристика | 2PC (Two-Phase Commit) | Saga Pattern |
|---|---|---|
| Уровень согласованности | Строгая согласованность (Linearizability / ACID) | Согласованность в конечном итоге (Eventual Consistency / BASE) |
| Изоляция транзакций | Строгая изоляция (Serializability) | Отсутствует на уровне всей саги (требуются семантические блокировки) |
| Блокировки ресурсов | Удерживает тяжелые блокировки строк в БД на всё время сетевого раунда | Никаких распределенных блокировок; локальные транзакции коммитятся мгновенно |
| Производительность | Катастрофически низкая при росте числа узлов (зависит от самого медленного узла) | Высокая пропускная способность за счет асинхронного обмена сообщениями |
| Отказоустойчивость | Низкая: падение координатора замораживает ресурсы в неопределенном состоянии (In-doubt) | Высокая: сервисы автономны, состояние саги персистентно сохраняется |
| Реализация | Встроена в протоколы СУБД (тяжело масштабируется) | Реализуется кодом приложения и компенсирующими транзакциями |
Инженерный вывод: В мире высоконагруженных распределенных сервисов, где сетевые задержки колеблются от единиц до сотен миллисекунд, 2PC превращает систему в хрупкий карточный домик. Saga жертвует мгновенной изолированностью ради высокой доступности и линейной масштабируемости.
Итог
- Saga решает фундаментальную проблему распределенного состояния при паттерне Database per Service, заменяя недостижимый глобальный ACID на цепочку локальных транзакций с компенсацией.
- В случае бизнес-ошибки или аппаратного сбоя координатор не может выполнить аппаратный
ROLLBACK, поэтому он разворачивает процесс вспять серией компенсирующих транзакций. - Хореография идеальна для коротких взаимодействий (2–3 сервиса) без сложного ветвления, тогда как Оркестрация на базе конечного автомата необходима для прозрачного управления сложными процессами.
- Отсутствие строгой изоляции требует превентивных мер: применения семантических блокировок (
status: PENDING), оптимистичной конкурентности (версионирование) и обязательной идемпотентности всех обработчиков команд.
В следующей статье мы разберем паттерн, который разделяет архитектуру чтения и изменения данных и часто работает рука об руку с Saga и Event Sourcing — 2. CQRS.