🏗️ Розділ 17 · Питання #1

Що таке патерн Saga і коли його використовувати

У монолітному застосунку всі таблиці лежать в одній СУБД. При оформленні замовлення Spring виконує єдину транзакцію:

Патерн Saga (Сага) — це архітектурний патерн проєктування, призначений для керування розподіленими транзакціями в мікросервісних системах з ізольованими базами даних (Database per Service). Оскільки класичні розподілені транзакції на базі двофазного коміту (2PC / XA) блокують ресурси та погано масштабуються в розподілених середовищах, Saga замінює їх послідовністю незалежних локальних транзакцій, забезпечуючи узгодженість у кінцевому підсумку (Eventual Consistency) та відкат через компенсувальні транзакції.


🟢 Junior Level

Чому в мікросервісах не працює класичний ACID

У монолітному застосунку всі таблиці лежать в одній СУБД. При оформленні замовлення Spring виконує єдину транзакцію:

// Моноліт: єдина ACID-транзакція в одній БД
@Transactional
public void createOrder() {
    orderRepository.save(order);       // Крок 1
    paymentService.charge(payment);    // Крок 2
    inventoryService.reserve(items);   // Крок 3
    // Помилка на кроці 3? СУБД робить моментальний ROLLBACK усіх таблиць!
}

У мікросервісній архітектурі в кожного сервісу своя ізольована база даних (OrderDB, PaymentDB, InventoryDB). Єдиного менеджера транзакцій немає, і звичайний SQL ROLLBACK не може скасувати зміни в чужій базі даних по мережі.

                    ПРОБЛЕМА РОЗПОДІЛЕНОЇ ТРАНЗАКЦІЇ
                    
  [ Order Service ]     ──>  [ Payment Service ]   ──>  [ Inventory Service ]
          │                          │                           │
          ▼                          ▼                           ▼
    [ Order DB ]              [ Payment DB ]              [ Inventory DB ]
  (Замовлення створено)       (Гроші списано)             (Товару немає! ЗБІЙ!)
        │                          │                           │
        └──────────────────────────┴───────────────────────────┘
         ЯК СКАСУВАТИ СПИСАННЯ ГРОШЕЙ У ЧУЖІЙ БАЗІ ДАНИХ?!

Як Saga вирішує проблему

Saga розбиває одну велику розподілену транзакцію на ланцюжок локальних ACID-транзакцій:

  1. Кожен мікросервіс виконує свою транзакцію у власній базі даних і публікує подію.
  2. Наступний сервіс слухає подію та запускає свою локальну транзакцію.
  3. Якщо на якомусь кроці виникає бізнес-помилка (наприклад, на складі не вистачило товару), Saga запускає ланцюжок компенсувальних транзакцій (Compensating Transactions) у зворотному порядку, які семантично нейтралізують раніше зроблені зміни (повертають гроші, скасовують замовлення).
                            УСПІШНИЙ СЦЕНАРІЙ SAGA:
   [Створити замовлення] ──> [Списати гроші] ──> [Зарезервувати товар] ──> [Успіх]

                           СЦЕНАРІЙ ІЗ КОМПЕНСАЦІЄЮ:
   [Створити замовлення] ──> [Списати гроші] ──> [Немає товару! ЗБІЙ!]
          │                          │                     │
          ▼                          ▼                     ▼
   [Скасувати замовлення] <── [Повернути гроші] <──────────┘
      (Компенсація 2)            (Компенсація 1)

🟡 Middle Level

Два підходи до реалізації Saga: Хореографія vs Оркестрація

Критерій Choreography (Хореографія) Orchestration (Оркестрація)
Координація Децентралізована (Event-Driven через Kafka/RabbitMQ) Централізована через виділений сервіс-оркестратор
Зв’язність (Coupling) Слабка (сервіси спілкуються тільки через топіки подій) Сервіси знають команди оркестратора
Складність розуміння Висока при > 4 сервісах (логіка розмазана по коду) Низька (весь сценарій видно в коді одного координатора)
Циклічні залежності Високий ризик зациклення подій Виключені
Точка відмови (SPOF) Відсутня Оркестратор повинен бути відмовостійким
Коли застосовувати Прості ланцюжки з 2–4 сервісів Складні бізнес-процеси (від 4–5 сервісів)

1. Хореографія (Choreography):

Сервіси реагують на події один одного без єдиного центру:

  • OrderService зберігає замовлення зі статусом PENDING і публікує подію OrderCreated.
  • PaymentService слухає OrderCreated, списує баланс і публікує PaymentProcessed.
  • InventoryService слухає PaymentProcessed, виявляє нестачу і публікує InventoryFailed.
  • PaymentService слухає InventoryFailed і робить повернення коштів (PaymentRefunded).
  • OrderService слухає PaymentRefunded і змінює статус замовлення на REJECTED.

2. Оркестрація (Orchestration):

Виділений компонент (Saga Orchestrator / State Machine) покроково відправляє сервісам явні команди:

// Оркестратор керує стейт-машиною процесу
public class OrderSagaOrchestrator {

    public void execute(CreateOrderCommand cmd) {
        orderService.createPendingOrder(cmd.orderId());
        
        PaymentResult payment = paymentService.authorize(cmd.orderId(), cmd.amount());
        if (!payment.isSuccess()) {
            orderService.cancelOrder(cmd.orderId());
            return;
        }

        InventoryResult inventory = inventoryService.reserve(cmd.orderId(), cmd.items());
        if (!inventory.isSuccess()) {
            // Запуск компенсувальних транзакцій:
            paymentService.refund(cmd.orderId(), cmd.amount());
            orderService.cancelOrder(cmd.orderId());
            return;
        }

        orderService.approveOrder(cmd.orderId());
    }
}

🔴 Senior Level

Аномалії паралелізму через відсутність букви I (Isolation) у Saga

Класичні розподілені транзакції забезпечують ACID. Saga гарантує тільки ACD (Atomicity, Consistency, Durability), але НЕ гарантує ізоляцію (Isolation = None)!

Доки сага виконується (а це може займати від сотень мілісекунд до годин), проміжні результати локальних транзакцій одразу ж видно іншим транзакціям. Це породжує класичні аномалії паралелізму на рівні бізнес-логіки:

  1. Брудне читання (Dirty Read):
    • Сага 1 списала гроші з балансу клієнта.
    • Паралельна Сага 2 бачить зменшений баланс і відмовляє в оформленні кредиту.
    • Сага 1 падає на кроці резервації складу і повертає гроші. Сага 2 прийняла бізнес-рішення на основі проміжних «брудних» даних!
  2. Втрачені оновлення (Lost Update):
    • Сага A зменшила залишок товару на складі з 10 до 8.
    • Сага B прочитала 8 і зменшила до 5.
    • Сага A впала і компенсувала свою зміну, додавши 2 (стало 7 замість реальних 8).

Контрзаходи ізоляції (Saga Isolation Countermeasures):

  • Семантичне блокування (Semantic Lock): Позначати записи проміжним статусом (наприклад, order.status = PENDING_APPROVAL, account.frozen_amount = 500). Інші транзакції блокуються бізнес-логікою до зняття прапорця.
  • Комутативні оновлення (Commutative Updates): Проєктувати операції так, щоб порядок їх виконання не впливав на результат (account.debit(50) і account.credit(50) комутативні).
  • Песимістичний погляд (Pessimistic View): Перевпорядковувати кроки саги так, щоб операції з найбільшим фінансовим ризиком виконувалися в самому кінці.

Персистентний журнал саги (Saga Log) та Transactional Outbox

Оркестратор не може зберігати стан саги просто в оперативній пам’яті JVM. Якщо под Kubernetes впаде посеред транзакції, стан процесу буде втрачено, гроші списано, а замовлення зависне.

Промислове рішення:

  1. Saga Execution Coordinator (SEC) зберігає кожен крок у реляційну таблицю SagaLog (SagaId, CurrentStep, Payload, Status).
  2. Команди мікросервісам відправляються через патерн Transactional Outbox, гарантуючи, що запис у БД і відправка повідомлення в брокер відбудуться атомарно.
  3. При рестарті пода оркестратор вичитує незавершені саги з SagaLog і відновлює виконання або компенсує їх за таймаутом.
  4. У реальному Enterprise-сегменті замість самописних оркестраторів використовують спеціалізовані рушії робочих процесів: Temporal.io, Camunda (BPMN), Cadence або AWS Step Functions.

4 Tricky Questions

1. Яку з літер ACID порушує патерн Saga, і чому компенсувальна транзакція — це НЕ те саме, що SQL ROLLBACK?

Відповідь:

  1. Патерн Saga порушує літеру I (Isolation). Локальні транзакції кожного кроку комітяться у своїх базах даних негайно, роблячи проміжний стан видимим для всіх паралельних користувачів системи.
  2. ROLLBACK у СУБД повертає фізичний стан сторінок даних у минуле за допомогою Undo-логів, ніби транзакції ніколи не існувало.
  3. Компенсувальна транзакція — це нова окрема транзакція вперед (Compensating Forward Action). Вона не стирає історію, а здійснює семантично протилежну бізнес-дію (наприклад, робить повернення коштів REFUND, створюючи нове проведення в бухгалтерському журналі).

2. Що станеться, якщо компенсувальна транзакція сама впаде з помилкою (наприклад, зовнішній платіжний шлюз повернув HTTP 500)? Чи має право Saga зупинитися?

Відповідь:

  1. Ні, сага не має права зупинятися! Якщо кинути компенсацію на півдорозі, система залишиться в розірваному, неконсистентному стані (гроші списано, а замовлення скасовано).
  2. Компенсувальні транзакції зобов’язані проєктуватися абсолютно ідемпотентними та повторюваними (Idempotent & Highly Available).
  3. При помилці компенсації оркестратор зобов’язаний виконувати нескінченні повтори з експоненційною затримкою (Exponential Backoff Retry).
  4. Якщо повтори вичерпано або настала фатальна помилка (рахунок заблоковано), інцидент відправляється в Dead Letter Queue (DLQ) і формує алерт для ручного втручання людини (Human-in-the-loop / Support Dashboard).

3. Як боротися з гонкою повідомлень «компенсація прийшла раніше прямої дії» (Out-of-order execution) в асинхронній Saga?

Відповідь:

  1. В асинхронних мережах через мережеві затримки подія скасування CancelPayment може обігнати затримане повідомлення ProcessPayment.
  2. Якщо обробник скасування виконається першим, а потім прийде запізніле пряме списання грошей, кошти спишуться назавжди без компенсації.
  3. Рішення — стейт-машина сутності:
    • Коли сервіс платежів отримує CancelPayment для ще невідомого paymentId, він створює запис у статусі CANCELLED_BEFORE_ARRIVAL.
    • Коли пізніше приходить запізніла команда ProcessPayment, сервіс бачить статус CANCELLED_BEFORE_ARRIVAL і не виконує списання, відкидаючи команду як застарілу.

4. У чому фундаментальні відмінності 2PC (Two-Phase Commit) від Saga, і чому 2PC практично не використовується в сучасних мікросервісах?

Відповідь:

  1. Блокування: 2PC на фазі Prepare фізично утримує блокування рядків у всіх залучених СУБД до отримання команди Commit. У хмарі із сотнями сервісів це призводить до блокувань таблиць, експоненційного зростання дедлоків та деградації продуктивності (затримка дорівнює часу відповіді найповільнішого сервісу).
  2. Відмовостійкість: Якщо координатор 2PC падає під час фази фіксації, бази даних залишаються заблокованими на невизначений термін.
  3. Гетерогенність: 2PC вимагає підтримки стандарту XA всіма учасниками. Сучасні брокери (Kafka, RabbitMQ) та NoSQL СУБД (MongoDB, Cassandra) не підтримують XA-транзакції.
  4. Saga використовує неблокуючі локальні транзакції та відмінно масштабується в асинхронних хмарних архітектурах.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«Патерн Saga керує розподіленими транзакціями в мікросервісах без використання блокуючого двофазного коміту (2PC). Saga розбиває глобальну операцію на ланцюжок локальних транзакцій у кожному сервісі. При виникненні бізнес-помилки запускаються компенсувальні транзакції у зворотному порядку для повернення системи до узгодженого стану (Eventual Consistency). Існує два підходи: хореографія (сервіси спілкуються через події в Kafka — для простих ланцюжків) та оркестрація (виділений координатор або рушій Temporal/Camunda керує кроками — для складних процесів). Головна особливість саги — відсутність ізоляції (ACD замість ACID), що вимагає семантичних блокувань та обов’язкової ідемпотентності компенсацій».

Ключові метрики для Production-моніторингу

  • Saga Completion Rate vs Compensation Rate: відсоток саг, що завершилися відкатом (зростання вище 5% вказує на проблеми зі складом або платежами).
  • Saga Duration (End-to-End Latency): загальний час виконання всіх кроків саги.
  • Dead Letter Queue (DLQ) / Failed Compensations Count: критичний лічильник завислих компенсацій, що вимагають ручного втручання.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Saga гарантує повну ACID-ізоляцію транзакцій» — У сагах немає ізоляції (I), проміжні зміни видно всім до завершення процесу.
  • ❌ «Компенсувальна транзакція відкочує базу даних назад як ROLLBACK» — Компенсація не відкочує БД, а виконує нову пряму коригувальну транзакцію (REFUND).
  • ❌ «Хореографія ідеально підходить для складних процесів із 15 сервісів» — Призведе до неконтрольованого клубка подій (Spaghetti Architecture), для 5+ сервісів потрібна оркестрація.
  • ❌ «Якщо впала компенсація, ми просто логуємо помилку і йдемо далі» — Компенсації зобов’язані повторюватися до переможного кінця або ескалуватися черговому інженеру.

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