8. Distributed transactions
В архитектуре современных распределенных систем на Go рано или поздно наступает момент, когда тривиальная бизнес-операция перерастает границы одной базы данных.
Представьте стандартный сценарий оформления заказа в современном интернет-гипермаркете:
- Заблокировать и списать денежные средства в сервисе биллинга (PostgreSQL).
- Зарезервировать и списать остатки физических товаров в сервисе склада (MySQL).
- Начислить бонусные баллы и кэшбэк в сервисе лояльности (MongoDB).
В классическом монолите мы бы просто открыли блок BEGIN TRANSACTION, выполнили три SQL-запроса и зафиксировали результат через COMMIT, переложив всю ответственность на ACID-гарантии локального движка.
Однако в распределенной микросервисной среде такой магии больше не существует: у каждой базы данных функционирует собственный изолированный журнал 8. WAL. Write Ahead Log, независимый менеджер блокировок и раздельная оперативная память.
Распределенная транзакция — это архитектурный механизм, координирующий согласованное выполнение группы операций на множестве физически независимых узлов сети так, чтобы вся цепочка обладала свойствами атомарности (Atomicity) и согласованности (Consistency).
Проблема: Частичные отказы
В распределенной системе действует суровый эмпирический закон: всё, что способно сломаться, обязательно сломается в самый неподходящий момент:
- Сетевой стек может моргнуть (Packet Drop) ровно между шагом списания денег и шагом резервирования товара.
- Сервис склада может внезапно упасть под ударом OOM-Killer ядра Linux прямо в процессе выполнения транзакции.
- База данных программы лояльности может отклонить транзакцию из-за конфликта уникального ключа.
Если микросервисы на Go просто выполняют последовательные вызовы по HTTP или gRPC, система гарантированно окажется в катастрофическом несогласованном состоянии (Inconsistent State): деньги с банковской карты клиента списаны, но товар на складе не забронирован, а заказ в системе не существует.
Стратегия 1: Атомарная фиксация (2PC)
Two-Phase Commit (Двухфазный коммит / 2PC) — классический протокол распределенного консенсуса, созданный в попытке перенести бескомпромиссные гарантии локального ACID в распределенную сеть. В этой схеме появляется выделенная системная роль — Координатор транзакции (Transaction Coordinator).
Фазы протокола 2PC:
- Фаза 1: Prepare (Подготовка к фиксации):
Координатор отправляет всем базам данных команду: «Можете ли вы зафиксировать изменения?».
Каждый узел локально выполняет валидацию, сбрасывает дельту изменений в WAL, накладывает строгие эксклюзивные блокировки (X-Locks) на строки и голосует:YES(готов) илиNO(отказ). - Фаза 2: Commit / Rollback (Фиксация или Откат):
- Если все до единого участника проголосовали
YES, координатор рассылает командуCOMMIT. Узлы применяют транзакцию и снимают блокировки. - Если хотя бы один узел проголосовал
NO(или не ответил по таймауту), координатор рассылает командуROLLBACK. Все узлы откатывают изменения по своим журналам.
- Если все до единого участника проголосовали
sequenceDiagram
autonumber
participant C as Координатор (Go Service)
participant DB1 as Billing DB (PostgreSQL)
participant DB2 as Inventory DB (MySQL)
Note over C, DB2: Фаза 1: Prepare (Голосование)
C->>DB1: 1. Prepare (T1) - XA PREPARE
C->>DB2: 2. Prepare (T2) - XA PREPARE
DB1->>DB1: Запись в WAL, наложение локов
DB2->>DB2: Запись в WAL, наложение локов
DB1-->>C: 3. OK (Ready to Commit)
DB2-->>C: 4. OK (Ready to Commit)
Note over C, DB2: Фаза 2: Commit (Фиксация)
C->>DB1: 5. Commit (XA COMMIT)
C->>DB2: 6. Commit (XA COMMIT)
DB1-->>C: 7. Ack (Завершено)
DB2-->>C: 8. Ack (Завершено)
Стратегия 2: Sagas (Саги)
В микросервисной индустрии и мире облачных Go-систем золотым стандартом реализации распределенных транзакций стал архитектурный паттерн Saga (Сага), впервые описанный Гектором Гарсия-Молиной еще в 1987 году.
Сага — это цепочка независимых локальных транзакций в отдельных сервисах. Каждый шаг саги фиксирует локальную транзакцию немедленно, не удерживая глобальных сетевых блокировок.
Если на определенном этапе происходит сбой (например, на складе закончился товар), сага запускает в обратном порядке цепочку компенсирующих транзакций (Compensating Transactions), ликвидирующих последствия предыдущих успешных шагов.
flowchart LR
subgraph HappyPath ["Успешное выполнение"]
T1["1. Списание денег<br>(Local Commit)"] --> T2["2. Резерв склада<br>(Local Commit)"]
T2 --> T3["3. Начисление бонусов<br>(Успех)"]
end
subgraph CompensationPath ["Откат через компенсации при сбое шага 2"]
Fail["Сбой шага 2!<br>(Товара нет)"] -.-> C1["<b>Компенсация C1:</b><br>Возврат списанных денег<br>(Новая транзакция!)"]
end
style HappyPath fill:#0f172a,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
style CompensationPath fill:#0f172a,stroke:#f43f5e,stroke-width:2px,color:#f8fafc
style T1 fill:#1e293b,stroke:#34d399,stroke-width:1px,color:#f8fafc
style T2 fill:#1e293b,stroke:#34d399,stroke-width:1px,color:#f8fafc
style T3 fill:#1e293b,stroke:#34d399,stroke-width:1px,color:#f8fafc
style C1 fill:#1e293b,stroke:#f87171,stroke-width:2px,color:#f8fafc
Архитектурные стили реализации Саг:
- Choreography (Хореография): Сервисы не имеют централизованного координатора. Они реагируют на доменные события, передаваемые через шины сообщений (Kafka, RabbitMQ, NATS). Сервис биллинга опубликовал
PaymentCompleted$\to$ сервис склада поймал событие и попытался списать товар. Если товар закончился, склад публикуетStockReservationFailed$\to$ биллинг ловит событие и запускает компенсацию. - Orchestration (Оркестрация): Выделенный сервис-оркестратор последовательно отправляет gRPC/REST команды участникам саги, сохраняет статус выполнения каждого шага в базу и при необходимости централизованно рассылает компенсирующие команды.
Mechanical Sympathy: Идемпотентность
В любых распределенных транзакциях сетевые сбои делают повторные запросы (Retries) математически неизбежными. Если Go-клиент отправил команду списания денег, но сетевое соединение оборвалось до прихода ответа, клиент не знает: выполнилась ли операция на сервере, или запрос даже не покинул сетевой буфер?
При повторной отправке запроса система обязана гарантировать Идемпотентность: многократное применение одного и того же запроса дает абсолютно идентичный результат, не приводя к двойному списанию средств.
Для этого каждый распределенный запрос снабжается уникальным ключом идемпотентности — Request-ID (или Transaction-ID / Idempotency-Key):
package billing
import (
"context"
"database/sql"
"fmt"
)
// ChargeAccount выполняет атомарное и идемпотентное списание средств
func ChargeAccount(ctx context.Context, tx *sql.Tx, idempotencyKey string, accountID int, amount int64) error {
// 1. Проверяем, не обрабатывался ли данный ключ ранее
var exists bool
checkQuery := `SELECT EXISTS(SELECT 1 FROM idempotency_keys WHERE key = $1)`
if err := tx.QueryRowContext(ctx, checkQuery, idempotencyKey).Scan(&exists); err != nil {
return fmt.Errorf("check idempotency: %w", err)
}
if exists {
// Запрос уже был успешно обработан ранее: повторное списание запрещено!
return nil
}
// 2. Выполняем полезную операцию списания
updateQuery := `UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1`
res, err := tx.ExecContext(ctx, updateQuery, amount, accountID)
if err != nil {
return fmt.Errorf("debit balance: %w", err)
}
rows, err := res.RowsAffected()
if err != nil || rows == 0 {
return fmt.Errorf("insufficient funds or account not found")
}
// 3. Сохраняем ключ идемпотентности в той же локальной транзакции
insertKey := `INSERT INTO idempotency_keys (key, account_id, created_at) VALUES ($1, $2, NOW())`
if _, err := tx.ExecContext(ctx, insertKey, idempotencyKey, accountID); err != nil {
return fmt.Errorf("store idempotency key: %w", err)
}
return nil
}
Сравнение подходов
| Характеристика | 2PC (Two-Phase Commit) | Saga Pattern |
|---|---|---|
| Изоляция (Isolation) | Высокая (ACID-подобная, строгие локи) | Низкая (промежуточные состояния видны) |
| Доступность (Availability) | Низкая (блокировка строк парализует БД) | Высокая (локальные коммиты без удержания локов) |
| Сложность разработки | Реализована внутри СУБД (XA-стандарт) | Требует написания кода компенсирующих действий |
| Модель консистентности | Строгая (Strong Consistency) | В конечном счете (Eventual Consistency) |
| Применимость | Локальные шарды одной базы, enterprise ERP | Распределенные микросервисы, облачный highload |
Реализация на Go: Инструментарий
Писать сложную логику отслеживания состояний, таймаутов и ретраев саг на голых горутинах и каналах крайне рискованно. В профессиональной экосистеме Go принято использовать специализированные платформы:
- Temporal.io / Cadence: Доминирующий промышленный стандарт оркестрации рабочих процессов и саг. Разработчик пишет код распределенной бизнес-логики как обычную линейную функцию на Go, а движок Temporal гарантирует ее детерминированное выполнение, автоматические ретраи и вызовы компенсаций даже при внезапной перезагрузке серверов.
- DTM (Distributed Transaction Manager): Высокопроизводительный распределенный координатор транзакций с открытым исходным кодом, созданный специально для Go. Поддерживает паттерны Saga, TCC (Try-Confirm-Cancel) и двухфазный коммит XA.
Резюме
- Распределенная транзакция координирует модификации между изолированными хранилищами, решая проблему неизбежных частичных сетевых отказов.
- Протокол 2PC (Двухфазный коммит) обеспечивает строгую атомарность, но платит за это жесткими блокировками и риском зависания кластера при отказе координатора.
- Паттерн Saga делит процесс на цепочку локальных транзакций с компенсирующими действиями при авариях, являясь де-факто стандартом для микросервисов.
- Идемпотентность — абсолютное обязательное требование для каждого сетевого эндпоинта, участвующего в распределенных цепочках.
Мы рассмотрели общий ландшафт распределенных транзакций. Однако протокол двухфазного коммита заслуживает максимально детального вскрытия: именно на нем исторически строились распределенные транзакции в классических РСУБД (XA-транзакции в MySQL и Postgres).
В следующей лекции мы разберем механику 2PC на уровне системных вызовов и отказов координатора: 9. Two Phase Commit.