Как реализовать распределённые транзакции в микросервисах
В монолитной архитектуре с одной реляционной СУБД транзакции обеспечиваются механизмом ACID самой базы данных: если одна операция падает, база откатывает всё состояние (rollback).
🟢 Junior Level
В монолитной архитектуре с одной реляционной СУБД транзакции обеспечиваются механизмом ACID самой базы данных: если одна операция падает, база откатывает всё состояние (rollback).
В микросервисной архитектуре действует принцип Database per Service: каждый сервис владеет собственной изолированной базой данных (PostgreSQL, MongoDB, Redis). Единого менеджера транзакций нет, а сетевые вызовы между сервисами могут завершаться ошибками, задержками или сетевыми разделениями (Network Partition).
Монолит (ACID):
[Сервис] ──► [Одна БД] (BEGIN ──► INSERT/UPDATE ──► COMMIT / ROLLBACK)
Микросервисы (BASE / Eventual Consistency):
[Order Service] [Payment Service] [Stock Service]
│ │ │
▼ ▼ ▼
[(БД Orders)] [(БД Payments)] [(БД Warehouse)]
Основные стратегии реализации:
- Saga (Сага) — последовательность независимых локальных транзакций. Каждый сервис коммитит изменения в своей БД. Если на каком-то шаге происходит сбой, вызываются компенсирующие транзакции, отменяющие предыдущие действия в обратном порядке.
- TCC (Try-Confirm-Cancel) — двухфазный бизнес-протокол: сначала ресурсы временно резервируются (
Try), затем при успехе подтверждаются (Confirm), либо при сбое бронь снимается (Cancel). - Двухфазный коммит (2PC / XA) — жесткий синхронный протокол распределенных транзакций. Координатор опрашивает участников («Готовы?»), и если все ответили «Да», рассылает команду коммита. В современных микросервисах практически не применяется из-за блокировок и низкой надежности.
Золотое правило архитектуры: Самая лучшая распределённая транзакция — та, которой удалось избежать. Если два объекта всегда должны изменяться строго атомарно, чаще всего они принадлежат одному Bounded Context и должны находиться в одном микросервисе.
🟡 Middle Level
1. Паттерн Saga: ACD без I (Isolation)
Сага гарантирует Atomicity, Consistency, Durability, но НЕ гарантирует Isolation: пока сага выполняется, промежуточные результаты локальных транзакций видны другим транзакциям.
Реализуется двумя путями:
- Хореография: Сервисы слушают события друг друга через брокер сообщений (Kafka/RabbitMQ).
- Оркестрация: Специальный координатор (Saga Orchestrator) хранит стейт-машину и шлет команды сервисам.
2. Проблема Dual Write и Transactional Outbox
Классическая ошибка: внутри одного метода сохранить сущность в БД и отправить событие в Kafka:
// АНТИПАТТЕРН: Dual-Write Problem!
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // 1. Запись в БД
kafkaTemplate.send("orders", new OrderCreatedEvent(order.getId())); // 2. Сеть/Брокер
// Если брокер упал или сеть моргнула — заказ в БД остался, событие не ушло!
// Если после отправки упал коммит БД — событие ушло, а заказа в базе нет!
}
Решение: Transactional Outbox Pattern Бизнес-сущность и событие сохраняются в одной локальной транзакции в реляционную БД. Затем фоновый процесс или CDC (Change Data Capture) надежно передает событие в брокер:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;
@Transactional
public Order createOrder(CreateOrderCommand cmd) {
Order order = new Order(cmd.customerId(), cmd.items(), OrderStatus.PENDING);
orderRepository.save(order);
OutboxEvent event = new OutboxEvent(
UUID.randomUUID(),
"Order",
order.getId().toString(),
"OrderCreated",
objectMapper.writeValueAsString(new OrderCreatedPayload(order.getId(), order.getTotalAmount()))
);
outboxRepository.save(event); // Сохраняется АТОМАРНО с заказом!
return order;
}
}
3. TCC (Try-Confirm-Cancel) vs Saga
| Параметр | Saga | TCC (Try-Confirm-Cancel) | 2PC (Two-Phase Commit) |
|---|---|---|---|
| Уровень реализации | Прикладной (события/команды) | Прикладной (бизнес-резервирование) | Инфраструктурный / БД (XA) |
| Фиксация ресурсов | Сразу коммитится в БД, при ошибке откатывается компенсацией | Ресурс «замораживается» в поле reserved_amount на шаге Try |
Блокировка строк на уровне СУБД до конца транзакции |
| Видимость изменений | Промежуточные данные видны сразу всем | Заблокированный ресурс недоступен, но баланс еще не списан | До коммита изменения никому не видны |
| Сложность | Средняя | Высокая (каждая операция делится на 3 метода) | Низкая в коде, экстремально сложная в эксплуатации |
🔴 Senior Level
1. Аномалии отсутствия изоляции в Saga и методы борьбы
Поскольку в Saga локальные транзакции коммитятся немедленно, возникают классические аномалии параллелизма:
- Lost Updates (Потерянные обновления): Сага 1 списала остаток товара, Сага 2 перезаписала его поверх, не зная о первой саге.
- Dirty Reads (Грязные чтения): Сага 1 создала бронь отеля. Клиент увидел забронированный номер и купил авиабилет. Затем Сага 1 упала на шаге оплаты и отменила бронь отеля — клиент остался с билетом, но без отеля.
- Non-repeatable Reads (Неповторяемые чтения): Чтение данных между шагами саги возвращает разные значения из-за промежуточных изменений других саг.
Меры противодействия (Saga Countermeasures):
- Semantic Lock (Семантическая блокировка): Запись помечается статусом
PENDING_*(например,OrderStatus.APPROVAL_PENDING). Любые другие операции с заказом блокируются на уровне бизнес-логики до снятия флага. - Commutative Updates (Коммутативные операции): Проектирование операций так, чтобы порядок их применения не имел значения (например, операции
account.credit()иaccount.debit()коммутативны относительно итоговой суммы при наличии кредитного лимита). - Pessimistic View (Пессимистический взгляд): Перестановка шагов саги так, чтобы операции с наибольшим бизнес-риском выполнялись первыми (Pivotal Transaction).
Структура шагов правильной Саги:
1. Compensable Transactions ──► Могут быть компенсированы при сбое
2. Pivotal Transaction ──► Точка невозврата (если успешна — сага обязана дойти до конца)
3. Retriable Transactions ──► Идут после точки невозврата, обязаны завершиться успехом (идемпотентные ретраи)
2. Реализация надежного Outbox: Polling Publisher vs CDC (Debezium)
- Polling Publisher (
SELECT ... FOR UPDATE SKIP LOCKED): Фоновый шедулер каждые $N$ миллисекунд читает записи со статусомNEWиз таблицыoutbox. Недостатки: Дополнительная нагрузка на СУБД (I/O, блокировки индексов, vacuum/bloat в PostgreSQL), задержка (latency) равна интервалу поллинга. - CDC через Debezium:
Debezium подключается к репликационному слоту базы (PostgreSQL WAL или MySQL binlog) как реплика. Он асинхронно вычитывает бинарные логи транзакций и стримит события напрямую в Kafka с задержкой в единицы миллисекунд, не нагружая процессор СУБД запросами
SELECT.
[Service] ──(Single ACID Tx)──► [PostgreSQL: orders + outbox]
│ (WAL Engine)
▼
[Debezium CDC]
│ (Streaming)
▼
[Apache Kafka]
3. Идемпотентность потребителя (Consumer Idempotency)
Поскольку брокеры сообщений гарантируют доставку At-Least-Once, повторные сообщения неизбежны. Потребитель обязан реализовывать Idempotent Consumer с использованием таблицы обработанных сообщений:
CREATE TABLE processed_messages (
message_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL,
handler_name VARCHAR(100) NOT NULL
);
Внутри транзакции консьюмера:
@Transactional
public void handle(OrderPaidEvent event) {
// Вставка с уникальным ключом: при дубле выбросит DuplicateKeyException
if (processedMessageRepo.existsById(event.eventId())) {
log.warn("Дубликат события: {}, пропускаем", event.eventId());
return;
}
processedMessageRepo.save(new ProcessedMessage(event.eventId(), Instant.now()));
deliveryService.scheduleDelivery(event.orderId());
}
4 Tricky Questions
1. Почему двухфазный коммит (2PC) считается антипаттерном в современных облачных микросервисах?
Ответ:
- Синхронные блокировки: Во время фазы Prepare ресурсы (строки в БД) блокируются эксклюзивными блокировками до момента, пока все участники не пришлют ответ и координатор не выдаст команду Commit. Это драматически снижает пропускную способность (throughput) всей системы.
- Блокирующий координатор (In-Doubt State): Если координатор падает после того, как разослал Prepare, но до рассылки Commit, участники зависают в состоянии неопределенности. Они не имеют права ни закоммитить, ни откатить транзакцию самостоятельно. Строки в БД остаются заблокированными до ручного вмешательства администратора.
- Гетерогенность: Современные микросервисы используют NoSQL (MongoDB, DynamoDB) и брокеры (Kafka), которые не поддерживают интерфейс XA.
2. Чем фундаментально различаются паттерн Saga и паттерн TCC (Try-Confirm-Cancel)?
Ответ:
- В Saga на каждом шаге выполняется полноценная локальная транзакция с немедленным изменением и фиксацией бизнес-данных в БД. Если что-то пошло не так на следующем шаге, запускается отдельная корректирующая транзакция (компенсация). Промежуточные данные видны всей системе.
- В TCC операция явно разбивается на две фазы:
Try: ресурс не списывается окончательно, а резервируется (например, создается временный холдирующий балансreserved_amount += 100, а доступный баланс уменьшаетсяavailable_amount -= 100).Confirm/Cancel: если все участники успешно выполнилиTry, вызываетсяConfirm(деньги списываются сreserved). Если кто-то упал — вызываетсяCancel(бронь снимается:available += 100,reserved -= 100). TCC обеспечивает лучшую изоляцию и предотвращает грязные чтения на уровне бизнес-логики, но требует утроенного объема кода.
3. Что произойдет, если в Саге падает компенсирующая транзакция?
Ответ: Компенсирующая транзакция не имеет права упасть с бизнес-ошибкой, так как откат отката невозможен.
- При технических сбоях (падение сети, недоступность БД) компенсация должна повторяться с экспоненциальной задержкой (Exponential Backoff) до победного конца (Retry until success).
- Если компенсация завершилась фатальной ошибкой (баг в коде, нарушение инварианта данных):
- Данные переводятся в статус
MANUAL_INTERVENTION_REQUIRED. - Событие отправляется в Dead Letter Queue (DLQ).
- Срабатывает алерт для дежурных инженеров (SRE / Support) для ручного исправления состояния системы.
- Данные переводятся в статус
4. Почему наивный Polling Publisher (@Scheduled) для Transactional Outbox не масштабируется на высоких нагрузках?
Ответ:
- Lock Contention: Несколько инстансов сервиса пытаются одновременно вычитать новые записи из таблицы Outbox, что приводит к конкуренции блокировок (
SELECT ... FOR UPDATE). ИспользованиеSKIP LOCKEDпомогает, но создает колоссальную нагрузку на дисковую подсистему и буферный пул СУБД. - Табличный Bloat (раздувание): Постоянные
INSERT,UPDATE(пометкаprocessed = true) и последующиеDELETEв реляционных базах (особенно PostgreSQL с механизмом MVCC) приводят к деградации индексов и необходимости частогоVACUUM. - Задержка (Latency): Минимальная задержка передачи события ограничена интервалом работы шедулера (например, 500 мс – 1 сек). Решение проблемы на высокой нагрузке — перенос чтения на уровень бинарных логов репликации через CDC (Debezium).
🎯 Шпаргалка для интервью
Главное:
- В микросервисах нет единой транзакции (ACID). Используется модель BASE и Eventual Consistency.
- Основной инструмент оркестрации бизнес-транзакций — Saga (Choreography или Orchestration).
- Сага гарантирует атомарность через компенсирующие транзакции, но не гарантирует изоляцию (требуются Semantic Locks).
- Проблема Dual-Write решается паттерном Transactional Outbox + CDC (Debezium).
- Все обработчики сообщений обязаны быть идемпотентными (Idempotent Consumer +
processed_messagestable).
Чек-лист проектирования распределённой транзакции:
- Можно ли объединить эти данные в один сервис и избежать распределенности? (Если да — объедините!).
- Реализован ли Transactional Outbox для публикации событий?
- Являются ли все локальные шаги и компенсации идемпотентными?
- Определена ли точка невозврата (Pivotal transaction)?
- Настроена ли Dead Letter Queue (DLQ) и алертинг на случай зависших компенсаций?
Красные флаги (Чего говорить НЕЛЬЗЯ):
- ❌ «Мы используем XA / 2PC для связи микросервисов по HTTP/gRPC» (2PC блокирует потоки и ресурсы, ломает масштабируемость и порождает In-Doubt сбои).
- ❌ «Я просто отправляю событие в Kafka прямо из
@Transactionalметода в Spring» (Это Dual-Write: гарантирует потерю данных или рассогласованность при падении сети/БД). - ❌ «В Saga можно просто откатить транзакцию через Rollback» (В Saga каждая локальная транзакция уже закоммичена; откат возможен только новой компенсирующей транзакцией).