Распределённые транзакции
Проблема распределённых транзакций
В монолите с одной БД транзакция гарантирует ACID. В микросервисной архитектуре данные разбросаны по нескольким сервисам и БД. Как обеспечить согласованность?
Перевод денег: Account A → Account B
Монолит: Микросервисы:
┌────────────────────┐ ┌──────────┐ ┌──────────┐
│ BEGIN │ │ Service A │ │ Service B │
│ UPDATE accounts │ │ DB_A │ │ DB_B │
│ SET balance -100│ └─────┬─────┘ └─────┬─────┘
│ WHERE id = A; │ │ │
│ UPDATE accounts │ │ Как быть? │
│ SET balance +100│ │ │
│ WHERE id = B; │ Если A списал, │
│ COMMIT; │ а B не зачислил? │
└────────────────────┘
Подходы к распределённым транзакциям
| Подход | Согласованность | Доступность | Сложность |
|---|---|---|---|
| 2PC | Strong | Низкая (блокирующий) | Средняя |
| 3PC | Strong | Выше (не блокирующий) | Высокая |
| Saga | Eventual | Высокая | Средняя |
| TCC | Eventual (с резервированием) | Высокая | Высокая |
Two-Phase Commit (2PC)
Протокол
2PC -- классический протокол атомарного коммита, координируемый одним узлом (координатором).
Coordinator Participant A Participant B
│ │ │
│ ── Phase 1: PREPARE ──────────────────│
│ │ │
│ PREPARE │ │
│───────────────────>│ │
│───────────────────────────────────────>│
│ │ │
│ VOTE YES │ │
│<───────────────────│ │
│<───────────────────────────────────────│
│ │ │
│ ── Phase 2: COMMIT ───────────────────│
│ │ │
│ COMMIT │ │
│───────────────────>│ │
│───────────────────────────────────────>│
│ │ │
│ ACK │ │
│<───────────────────│ │
│<───────────────────────────────────────│
│ │ │
▼ Transaction committed ✓ ▼
Фаза 1: Prepare (Голосование)
- Координатор отправляет
PREPAREвсем участникам - Каждый участник:
- Выполняет все операции транзакции
- Записывает в WAL (write-ahead log)
- Блокирует ресурсы
- Отвечает
YES(готов) илиNO(не готов)
Фаза 2: Commit/Abort (Решение)
- Если все ответили
YES→ координатор отправляетCOMMIT - Если хотя бы один ответил
NO→ координатор отправляетABORT
Проблемы 2PC
Coordinator Participant A Participant B
│ │ │
│ PREPARE │ │
│───────────────────>│ │
│───────────────────────────────────────>│
│ │ │
│ VOTE YES │ │
│<───────────────────│ │
│<───────────────────────────────────────│
│ │ │
X (Coordinator crashes!) │
│ │
Participant A: Participant B:
Ресурсы заблокированы! Ресурсы заблокированы!
Ждёт решения... Ждёт решения...
(ЗАБЛОКИРОВАН) (ЗАБЛОКИРОВАН)
| Проблема | Описание |
|---|---|
| Blocking | Если координатор упал после PREPARE -- участники заблокированы |
| Single point of failure | Координатор -- единая точка отказа |
| Latency | Минимум 2 roundtrip (4 сообщения на участника) |
| Lock duration | Ресурсы заблокированы на время всего протокола |
Где используется 2PC
- XA Transactions -- стандарт распределённых транзакций (Java EE, .NET)
- PostgreSQL --
PREPARE TRANSACTION/COMMIT PREPARED - MySQL -- XA transactions между InnoDB instances
Three-Phase Commit (3PC)
3PC добавляет промежуточную фазу для предотвращения блокировки.
Coordinator Participant A Participant B
│ │ │
│ Phase 1: PREPARE │
│───────────────>│ │
│───────────────────────────────────>│
│ VOTE YES │ │
│<───────────────│ │
│<───────────────────────────────────│
│ │ │
│ Phase 2: PRE-COMMIT │
│───────────────>│ │
│───────────────────────────────────>│
│ ACK │ │
│<───────────────│ │
│<───────────────────────────────────│
│ │ │
│ Phase 3: COMMIT │
│───────────────>│ │
│───────────────────────────────────>│
Ключевое отличие: PRE-COMMIT фаза гарантирует, что если координатор упал, участники знают: решение было "commit". Они могут завершить транзакцию самостоятельно по таймауту.
3PC vs 2PC
| Аспект | 2PC | 3PC |
|---|---|---|
| Блокировка при отказе координатора | Да | Нет |
| Число раундов | 2 | 3 |
| Сложность | Средняя | Высокая |
| Network partitions | Проблема | Может привести к несогласованности |
| Использование в практике | Широкое | Редкое |
Вывод: 3PC решает проблему блокировки, но не решает проблему network partitions. На практике используется редко -- Saga pattern предпочтительнее.
Saga Pattern
Saga -- паттерн, в котором длинная транзакция разбивается на последовательность локальных транзакций. Каждая локальная транзакция имеет компенсирующее действие для отката.
Концепция
Saga: Бронирование путешествия
T1: Забронировать рейс C1: Отменить бронь рейса
T2: Забронировать отель C2: Отменить бронь отеля
T3: Забронировать машину C3: Отменить бронь машины
T4: Списать оплату C4: Вернуть деньги
Успешный путь:
T1 → T2 → T3 → T4 ✓
Отказ на T3:
T1 → T2 → T3(fail) → C2 → C1 (compensating transactions)
Choreography Saga (Хореография)
Каждый сервис слушает события и решает, что делать дальше. Нет центрального координатора.
┌──────────┐ OrderCreated ┌──────────┐
│ Order │ ──────────────────> │ Payment │
│ Service │ │ Service │
└──────────┘ └────┬─────┘
▲ │
│ PaymentCompleted
│ │
│ ▼
│ ┌──────────┐
│ ShippingCompleted │ Shipping │
│ <─────────────────────── │ Service │
│ └──────────┘
│
▼ OrderCompleted
При ошибке -- обратная цепочка:
ShippingFailed → PaymentService.refund() → OrderService.cancel()
Плюсы хореографии:
- Нет единой точки отказа
- Слабая связанность между сервисами
- Простая для 2-4 шагов
Минусы хореографии:
- Сложно отследить общий статус
- Циклические зависимости
- Сложно добавить новый шаг
- Трудно тестировать end-to-end
Orchestration Saga (Оркестрация)
Центральный оркестратор управляет последовательностью шагов.
┌──────────────┐
│ Saga │
│ Orchestrator │
└──────┬───────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Payment │ │ Inventory│ │ Shipping │
│ Service │ │ Service │ │ Service │
└──────────┘ └──────────┘ └──────────┘
Orchestrator:
1. → PaymentService.charge() ✓
2. → InventoryService.reserve() ✓
3. → ShippingService.ship() ✗ FAIL
4. → InventoryService.release() (compensate)
5. → PaymentService.refund() (compensate)
Плюсы оркестрации:
- Легко отследить статус саги
- Простая логика компенсации
- Проще добавить новые шаги
- Легко тестировать
Минусы оркестрации:
- Оркестратор -- потенциальная точка отказа
- Более сильная связанность
- Риск "god service"
Choreography vs Orchestration
| Критерий | Choreography | Orchestration |
|---|---|---|
| Связанность | Слабая | Умеренная |
| Центральная точка отказа | Нет | Оркестратор |
| Наблюдаемость | Сложная | Простая |
| Добавление шагов | Сложно | Просто |
| Подходит для | 2-4 простых шагов | 4+ шагов, сложная логика |
| Примеры | Простые event-driven | Order processing, booking |
Semantic Lock (Семантическая блокировка)
Проблема Saga: между шагами данные видны другим транзакциям (нет изоляции). Решение -- semantic lock:
Order Status Flow:
PENDING → PROCESSING → COMPLETED
│
└──→ COMPENSATING → CANCELLED
Другие сервисы проверяют статус:
if (order.status == PROCESSING) {
// Wait or reject -- order is in saga
}
TCC (Try-Confirm/Cancel)
TCC -- паттерн, в котором каждый сервис реализует три операции:
┌─────────────────────────────────────────────┐
│ TCC │
│ │
│ Try: Резервировать ресурсы │
│ (но НЕ выполнять действие) │
│ │
│ Confirm: Подтвердить резервирование │
│ (выполнить действие) │
│ │
│ Cancel: Отменить резервирование │
│ (освободить ресурсы) │
│ │
└─────────────────────────────────────────────┘
Пример: покупка билета
Coordinator TicketService PaymentService
│ │ │
│ ── Try Phase ─────│────────────────────│
│ │ │
│ Try │ │
│──────────────────>│ │
│ reserve(seat=5A) │ │
│ Reserved ✓ │ │
│<──────────────────│ │
│ │ │
│──────────────────────────────────────>│
│ │ Try │
│ │ hold($100) │
│ │ Held ✓ │
│<──────────────────────────────────────│
│ │ │
│ ── Confirm Phase ─│────────────────────│
│ │ │
│ Confirm │ │
│──────────────────>│ │
│ assign(seat=5A) │ │
│──────────────────────────────────────>│
│ │ Confirm │
│ │ charge($100) │
│ │ │
▼ Transaction done ✓ ▼
TCC vs Saga
| Аспект | TCC | Saga |
|---|---|---|
| Изоляция | Через резервирование | Нет (semantic lock) |
| Откат | Cancel (чистая отмена) | Compensating tx (может быть сложно) |
| Бизнес-логика | Try/Confirm/Cancel в каждом сервисе | Forward + Compensating в каждом сервисе |
| Подходит для | Резервирование ресурсов | Длинные бизнес-процессы |
| Сложность API | Высокая (3 эндпоинта) | Средняя (2 операции) |
Outbox Pattern
Outbox Pattern решает проблему атомарной публикации событий вместе с записью в БД.
Проблема:
1. UPDATE orders SET status='paid' ← успех
2. PUBLISH event 'OrderPaid' ← сервис упал!
Событие потеряно!
Решение -- Outbox:
┌─────────────────────────────────────────┐
│ BEGIN TRANSACTION │
│ UPDATE orders SET status='paid'; │
│ INSERT INTO outbox_events │
│ (type, payload) │
│ VALUES ('OrderPaid', '...'); │
│ COMMIT; │
└─────────────────────────────────────────┘
Relay Process (CDC / polling):
outbox_events → Kafka/RabbitMQ
Способы чтения Outbox
| Способ | Описание | Плюсы | Минусы |
|---|---|---|---|
| Polling | Периодический SELECT из outbox | Простой | Задержка, нагрузка на БД |
| CDC (Debezium) | Чтение WAL БД | Real-time, нет нагрузки | Сложная инфраструктура |
| Listen/Notify | PostgreSQL LISTEN/NOTIFY | Быстро | Только PostgreSQL |
Idempotency (Идемпотентность)
В распределённых транзакциях сообщения могут дублироваться. Каждый шаг должен быть идемпотентным.
// Idempotency key ensures exactly-once processing
POST /api/payments
Headers:
Idempotency-Key: "order-123-payment-v1"
Body: { "amount": 100, "order_id": "123" }
First call: Process payment → 200 OK
Second call: Return cached result → 200 OK (no double charge)
Что говорить на интервью
- 2PC -- для strong consistency между 2-3 сервисами, но blocking
- Saga -- для eventual consistency, предпочтительнее 2PC в микросервисах
- Orchestration -- для сложных flow (5+ шагов)
- Choreography -- для простых flow (2-3 шага)
- TCC -- когда нужна изоляция через резервирование
- Outbox -- для гарантии публикации событий
- Idempotency -- обязательна для любого подхода