🏗️ Раздел 17 · Вопрос #3

Как реализовать распределённые транзакции в микросервисах

В монолитной архитектуре с одной реляционной СУБД транзакции обеспечиваются механизмом 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)]

Основные стратегии реализации:

  1. Saga (Сага) — последовательность независимых локальных транзакций. Каждый сервис коммитит изменения в своей БД. Если на каком-то шаге происходит сбой, вызываются компенсирующие транзакции, отменяющие предыдущие действия в обратном порядке.
  2. TCC (Try-Confirm-Cancel) — двухфазный бизнес-протокол: сначала ресурсы временно резервируются (Try), затем при успехе подтверждаются (Confirm), либо при сбое бронь снимается (Cancel).
  3. Двухфазный коммит (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 локальные транзакции коммитятся немедленно, возникают классические аномалии параллелизма:

  1. Lost Updates (Потерянные обновления): Сага 1 списала остаток товара, Сага 2 перезаписала его поверх, не зная о первой саге.
  2. Dirty Reads (Грязные чтения): Сага 1 создала бронь отеля. Клиент увидел забронированный номер и купил авиабилет. Затем Сага 1 упала на шаге оплаты и отменила бронь отеля — клиент остался с билетом, но без отеля.
  3. 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) считается антипаттерном в современных облачных микросервисах?

Ответ:

  1. Синхронные блокировки: Во время фазы Prepare ресурсы (строки в БД) блокируются эксклюзивными блокировками до момента, пока все участники не пришлют ответ и координатор не выдаст команду Commit. Это драматически снижает пропускную способность (throughput) всей системы.
  2. Блокирующий координатор (In-Doubt State): Если координатор падает после того, как разослал Prepare, но до рассылки Commit, участники зависают в состоянии неопределенности. Они не имеют права ни закоммитить, ни откатить транзакцию самостоятельно. Строки в БД остаются заблокированными до ручного вмешательства администратора.
  3. Гетерогенность: Современные микросервисы используют NoSQL (MongoDB, DynamoDB) и брокеры (Kafka), которые не поддерживают интерфейс XA.

2. Чем фундаментально различаются паттерн Saga и паттерн TCC (Try-Confirm-Cancel)?

Ответ:

  • В Saga на каждом шаге выполняется полноценная локальная транзакция с немедленным изменением и фиксацией бизнес-данных в БД. Если что-то пошло не так на следующем шаге, запускается отдельная корректирующая транзакция (компенсация). Промежуточные данные видны всей системе.
  • В TCC операция явно разбивается на две фазы:
    1. Try: ресурс не списывается окончательно, а резервируется (например, создается временный холдирующий баланс reserved_amount += 100, а доступный баланс уменьшается available_amount -= 100).
    2. Confirm / Cancel: если все участники успешно выполнили Try, вызывается Confirm (деньги списываются с reserved). Если кто-то упал — вызывается Cancel (бронь снимается: available += 100, reserved -= 100). TCC обеспечивает лучшую изоляцию и предотвращает грязные чтения на уровне бизнес-логики, но требует утроенного объема кода.

3. Что произойдет, если в Саге падает компенсирующая транзакция?

Ответ: Компенсирующая транзакция не имеет права упасть с бизнес-ошибкой, так как откат отката невозможен.

  • При технических сбоях (падение сети, недоступность БД) компенсация должна повторяться с экспоненциальной задержкой (Exponential Backoff) до победного конца (Retry until success).
  • Если компенсация завершилась фатальной ошибкой (баг в коде, нарушение инварианта данных):
    1. Данные переводятся в статус MANUAL_INTERVENTION_REQUIRED.
    2. Событие отправляется в Dead Letter Queue (DLQ).
    3. Срабатывает алерт для дежурных инженеров (SRE / Support) для ручного исправления состояния системы.

4. Почему наивный Polling Publisher (@Scheduled) для Transactional Outbox не масштабируется на высоких нагрузках?

Ответ:

  1. Lock Contention: Несколько инстансов сервиса пытаются одновременно вычитать новые записи из таблицы Outbox, что приводит к конкуренции блокировок (SELECT ... FOR UPDATE). Использование SKIP LOCKED помогает, но создает колоссальную нагрузку на дисковую подсистему и буферный пул СУБД.
  2. Табличный Bloat (раздувание): Постоянные INSERT, UPDATE (пометка processed = true) и последующие DELETE в реляционных базах (особенно PostgreSQL с механизмом MVCC) приводят к деградации индексов и необходимости частого VACUUM.
  3. Задержка (Latency): Минимальная задержка передачи события ограничена интервалом работы шедулера (например, 500 мс – 1 сек). Решение проблемы на высокой нагрузке — перенос чтения на уровень бинарных логов репликации через CDC (Debezium).

🎯 Шпаргалка для интервью

Главное:

  • В микросервисах нет единой транзакции (ACID). Используется модель BASE и Eventual Consistency.
  • Основной инструмент оркестрации бизнес-транзакций — Saga (Choreography или Orchestration).
  • Сага гарантирует атомарность через компенсирующие транзакции, но не гарантирует изоляцию (требуются Semantic Locks).
  • Проблема Dual-Write решается паттерном Transactional Outbox + CDC (Debezium).
  • Все обработчики сообщений обязаны быть идемпотентными (Idempotent Consumer + processed_messages table).

Чек-лист проектирования распределённой транзакции:

  1. Можно ли объединить эти данные в один сервис и избежать распределенности? (Если да — объедините!).
  2. Реализован ли Transactional Outbox для публикации событий?
  3. Являются ли все локальные шаги и компенсации идемпотентными?
  4. Определена ли точка невозврата (Pivotal transaction)?
  5. Настроена ли Dead Letter Queue (DLQ) и алертинг на случай зависших компенсаций?

Красные флаги (Чего говорить НЕЛЬЗЯ):

  • ❌ «Мы используем XA / 2PC для связи микросервисов по HTTP/gRPC» (2PC блокирует потоки и ресурсы, ломает масштабируемость и порождает In-Doubt сбои).
  • ❌ «Я просто отправляю событие в Kafka прямо из @Transactional метода в Spring» (Это Dual-Write: гарантирует потерю данных или рассогласованность при падении сети/БД).
  • ❌ «В Saga можно просто откатить транзакцию через Rollback» (В Saga каждая локальная транзакция уже закоммичена; откат возможен только новой компенсирующей транзакцией).

Связанные темы: