8. Distributed transactions
В предыдущих главах мы научились обеспечивать консистентность отдельного ключа, регистра или документа. Мы изучили кворумы, векторные часы и структуры CRDT. Но в реальной бизнес-архитектуре операции редко замыкаются на одной строчке таблицы.
Представьте классический сценарий покупки товара на маркетплейсе:
Billing Service: Списать 5 000 рублей с кредитной карты пользователя.Inventory Service: Зарезервировать последнюю пару кроссовок на складе.Order Service: Создать заказ со статусом «Оплачен» и сформировать чек.
В эпоху монолитных систем это решалось элементарно: вы открывали локальную транзакцию в PostgreSQL, выполняли три SQL-запроса и вызывали COMMIT. Если на шаге склада товар заканчивался, база данных откатывала транзакцию (Rollback), возвращая систему в исходное идеальное состояние. База данных гарантировала свойства ACID (Atomicity, Consistency, Isolation, Durability).
Но в современном облачном бэкенде действует парадигма Database-per-Service: у каждого микросервиса — своя собственная, физически изолированная база данных.
Если Billing Service успешно списал средства, а сетевой запрос к Inventory Service упал по таймауту из-за перегрузки склада — бизнес сталкивается с катастрофой: деньги у клиента списаны, а товара нет!
Распределенная транзакция — это набор согласованных операций над несколькими независимыми базами данных или микросервисами, обеспечивающий неделимость бизнес-процесса («все или ничего»).
1. Проблема 2PC (Two-Phase Commit)
Историческим стандартом обеспечения атомарности между несколькими узлами является протокол двухфазной фиксации — Two-Phase Commit (2PC), стандартизированный консорциумом X/Open (спецификация XA).
Как это работает:
В протоколе участвуют Координатор (Coordinator) и несколько участников (Participants / Ресурсов). Процесс разделен на две синхронные фазы:
- Фаза 1: Подготовка (Prepare Phase / Voting):
- Координатор отправляет всем участникам запрос: «Готовы ли вы зафиксировать изменения?».
- Каждый участник запускает локальную транзакцию, захватывает блокировки строк в БД, пишет данные в свой WAL и отвечает:
VOTE_COMMIT(Да) илиVOTE_ABORT(Нет).
- Фаза 2: Фиксация (Commit Phase):
- Если все до единого участники ответили «Да», Координатор отправляет всем команду
GLOBAL_COMMIT. Участники фиксируют транзакцию и снимают блокировки. - Если хотя бы один участник ответил «Нет» (или не ответил вовсе по таймауту сети), Координатор рассылает команду
GLOBAL_ROLLBACK. Все узлы откатывают локальные изменения.
- Если все до единого участники ответили «Да», Координатор отправляет всем команду
sequenceDiagram
autonumber
participant Coord as Координатор (2PC)
participant Billing as Billing DB
participant Inv as Inventory DB
Note over Coord: ФАЗА 1: Prepare (Голосование)
Coord->>Billing: Prepare (Lock balance)
Coord->>Inv: Prepare (Lock stock)
Billing-->>Coord: Prepared: OK
Inv-->>Coord: Prepared: OK
Note over Coord: Все ответили ДА!<br/>ФАЗА 2: Commit (Фиксация)
Coord->>Billing: Commit
Coord->>Inv: Commit
Billing-->>Coord: Committed (Lock released)
Inv-->>Coord: Committed (Lock released)
2. Паттерн Saga (Сага)
В высоконагруженных микросервисных архитектурах жесткие блокировки (2PC) уступили место паттерну Saga (Сага), предложенному Гектором Гарсиа-Молиной и Кеннетом Салемом еще в 1987 году.
Сага — это цепочка независимых локальных транзакций. Каждая транзакция обновляет локальную базу данных одного сервиса и публикует событие.
Если какой-то шаг завершается ошибкой, Сага запускает цепочку компенсирующих транзакций (Compensating Transactions), которые последовательно откатывают изменения предыдущих шагов в обратном порядке.
Критически важно понимать: компенсирующая транзакция — это не SQL ROLLBACK. Она не может волшебно стереть прошлое. Это семантическое действие, компенсирующее эффект: например, возврат списанных денег на счет или отправка извинительного промокода.
sequenceDiagram
autonumber
participant Client as Клиент
participant S1 as Order Service
participant S2 as Billing Service
participant S3 as Inventory Service (Сбой!)
Client->>S1: Создать заказ
S1->>S1: T1: Создать заказ (NEW)
S1->>S2: Вызов списания
S2->>S2: T2: Списать 5000 руб
S2->>S3: Вызов резерва склада
Note over S3: ОШИБКА: Товар закончился!
S3-->>S2: Fail (Out of stock)
Note over S2,S1: Запуск компенсаций (Rollback)
S2->>S2: C2: Вернуть 5000 руб клиенту
S2-->>S1: Отмена заказа
S1->>S1: C1: Перевести заказ в REJECTED
Существует два фундаментальных способа реализации Саг:
А. Оркестрация (Orchestration)
При оркестрации процессом руководит центральный координатор (Оркестратор). Он хранит стейт-машину саги, пошагово отдает приказы сервисам и в случае аварии запускает сценарии компенсации.
В Go-экосистеме индустриальным стандартом для оркестрации саг стал движок Temporal.io (а также Cadence). Он позволяет писать отказоустойчивые распределенные саги как обычный синхронный Go-код, автоматически персистя каждый шаг и возобновляя выполнение при падении серверов.
Плюсы: Идеальная прозрачность, нет циклических зависимостей, легко отслеживать статус саги.
Б. Хореография (Choreography)
При хореографии центрального диспетчера нет. Сервисы общаются децентрализованно через брокер сообщений (Kafka или RabbitMQ).
Каждый сервис подписывается на события коллег:
Order Serviceсоздает заказ и бросает событиеOrderCreated.Billing Serviceслушает топик, видитOrderCreated, списывает деньги и публикует событиеMoneyCharged.Inventory ServiceловитMoneyCharged, видит нехватку товара и публикует событиеStockReservationFailed.Billing Serviceловит ошибку и возвращает деньги на карту.
Плюсы: Слабая связанность (Loose Coupling), сервисы независимы.
Минусы: Сложно визуализировать граф бизнес-процесса; риск возникновения циклических зависимостей.
3. Distributed Transactions и Go-рантайм
Реализация распределенных транзакций в Go требует маниакальной дисциплины при работе с внешними ресурсами. Вы не можете просто вызвать в цикле функцию базы данных, а следом отправить сообщение в Kafka!
Механическая симпатия: Outbox Pattern
Классическая ошибка разработчика — попытка выполнить двойную запись (Dual Write):
// АНТИПАТТЕРН: Гарантированная потеря консистентности при сбое!
func HandlePayment(ctx context.Context, db *sql.DB, kafkaProducer *kafka.Producer, orderID string) error {
// ШАГ 1: Обновляем БД
_, err := db.ExecContext(ctx, "UPDATE billing SET status = 'CHARGED' WHERE order_id = $1", orderID)
if err != nil {
return err
}
// ШАГ 2: Шлем событие в брокер
// ЧТО ЕСЛИ СЕТЬ МОРГНЕТ ИЛИ ПРОЦЕСС УМРЕТ ПО OOM ЗДЕСЬ?!
// База обновлена, а событие MoneyCharged в Kafka никогда не уйдет!
return kafkaProducer.Send("MoneyCharged", orderID)
}
Решением является паттерн Transactional Outbox:
- В рамках одной локальной ACID-транзакции вы сохраняете бизнес-данные и записываете полезную нагрузку будущего сообщения в служебную таблицу
outboxв той же самой базе данных. - Отдельный фоновый процесс (Relay / Change Data Capture, например Debezium) вычитывает таблицу
outboxи гарантированно отправляет сообщения в брокер с семантикой at-least-once.
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
GoApp["Go Приложение"]:::entry -->|"Единая транзакция<br/>BEGIN ... COMMIT"| DB[("PostgreSQL")]
subgraph DB [PostgreSQL]
T1["Таблица Orders<br/>(Бизнес-данные)"]
T2["Таблица outbox<br/>(События для шины)"]
end
Relay["Фоновый воркер<br/>(CDC / Relay в Go)"] -->|"Вычитывает outbox"| T2
Relay -->|"Надежная отправка"| Kafka["Kafka Broker<br/>(Topic: MoneyCharged)"]
4. TCC (Try-Confirm-Cancel)
Паттерн TCC — это элегантная реализация двухфазной фиксации, но перенесенная с уровня блокировок СУБД на уровень бизнес-логики приложения.
Каждый участвующий сервис реализует три контракта API:
- Try: Предварительное резервирование ресурсов. Например, сервис счетов переводит 100 рублей в поле
balance_pending = 100, но не списывает их окончательно. - Confirm: Окончательное подтверждение операции. Снятие резерва и перевод денег получателю.
- Cancel: Отмена резервирования в случае сбоя соседних сервисов.
Золотое правило TCC:
Все методыTry,ConfirmиCancelобязаны быть строго идемпотентными. Если из-за сетевого ретрая методConfirmвыполнится три раза подряд, баланс не должен списаться трижды.
Подводные камни распределенных транзакций
Итог
- Забудьте о 2PC в микросервисах: Блокировки строк через сеть парализуют масштабирование и создают точки каскадных отказов.
- Saga — промышленный выбор: Разделяйте глобальную транзакцию на цепочку локальных шагов с обязательным проектированием компенсирующих действий.
- Transactional Outbox: Единственный безопасный способ связать реляционную базу данных и брокер сообщений без риска потери событий.
- Идемпотентность — фундамент надежности: Сетевые ретраи неизбежны, поэтому любой шаг саги и метод
Confirm/Cancelв TCC обязан спокойно переносить дубликаты вызовов.
Мы завершили фундаментальный подраздел «Данные и согласованность». Мы изучили, как данные реплицируются, как разрешаются неизбежные конфликты и как оркеструются сложные бизнес-транзакции.
Теперь пора перейти к архитектурному каркасу, на котором строятся все эти механизмы в реальном продакшене.
Что представляет собой микросервисная архитектура с точки зрения Go-инженера?
Переходим к следующему подразделу: 1. Что такое микросервис.