🏗️ Розділ 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): Фоновий планувальник (scheduler) кожні $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).

Чек-лист проєктування розподіленої транзакції:

  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 кожна локальна транзакція вже закомічена; відкат можливий лише новою компенсувальною транзакцією).

Пов’язані теми: