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

Що таке компенсувальні транзакції

Будь-яка розподілена сага складається з трьох типів локальних транзакцій:


🟢 Junior Level

Компенсувальна транзакція (Compensating Transaction) — це бізнес-операція, яка семантично нейтралізує (скасовує) ефект раніше успішно зафіксованої локальної транзакції в межах розподіленого процесу (патерн Saga).

Прямий хід (Forward Flow):
[Замовлення створено] ──► [Гроші списано] ──► [Склад: ТОВАРУ НЕМАЄ! ❌]

Зворотний хід (Backward Compensation):
[Замовлення скасовано (C1)] ◄── [Повернення грошей (C2)] ◄──┘ (Компенсація у зворотному порядку)

Ключові відмінності від класичного ROLLBACK:

  1. Транзакція вже закомічена: На відміну від команди ROLLBACK в SQL, яка скасовує незафіксовані зміни в оперативній пам’яті та WAL-лозі БД до коміту, компенсувальна транзакція виконується над вже закоміченими даними, які могли встигнути прочитати інші користувачі.
  2. Нова явна операція: Компенсація — це окремий INSERT або UPDATE у базу даних. Наприклад, якщо прямою операцією було «списання 500 гривень», то компенсацією буде не видалення рядка з аудиту, а операція «зарахування 500 гривень із типом REFUND».
  3. Зворотний порядок (LIFO): Якщо виконалися кроки $T_1 \to T_2 \to T_3$, а на кроці $T_4$ стався збій, компенсації викликаються строго у зворотному порядку: $C_3 \to C_2 \to C_1$.

🟡 Middle Level

1. Анатомія транзакцій у патерні Saga

Будь-яка розподілена сага складається з трьох типів локальних транзакцій:

  1. Компенсовні транзакції (Compensable Transactions): Кроки, які можуть бути гарантовано скасовані за допомогою компенсувальної транзакції (наприклад, резервування коштів, створення чернетки замовлення).
  2. Поворотна транзакція (Pivotal Transaction): «Точка неповернення». Якщо ця транзакція завершилася успішно, сага зобов’язана дійти до переможного кінця. Якщо впала — сага відкочується компенсаціями назад. Поворотна транзакція не може бути компенсована.
  3. Повторювані транзакції (Retriable Transactions): Кроки, що виконуються після поворотної транзакції. Вони зобов’язані завершитися успіхом. При будь-яких збоях система повторює їх доти, доки вони не виконаються (Forward Recovery).
   [T1: Бронь товару] ──► [T2: Списання бонусів] ──► [T3: Оплата (PIVOTAL)] ──► [T4: Оформлення чека]
         │                       │                                                      │
     Компенсовна             Компенсовна                         Точка неповернення     Повторювана
   (є відкат C1)           (є відкат C2)                       (після неї нема відкату) (повтор до перемоги)

2. Обов’язкові вимоги до компенсувальної транзакції

А. Ідемпотентність (Idempotence)

Компенсувальна подія або команда може бути доставлена кілька разів через мережеві збої брокера (семантика at-least-once). Повторний виклик не повинен призводити до повторного повернення грошей:

@Service
@RequiredArgsConstructor
public class AccountCompensationService {

    private final AccountRepository accountRepo;
    private final RefundLogRepository refundLogRepo;

    @Transactional
    public void refundPayment(UUID sagaId, UUID accountId, BigDecimal amount) {
        // Перевірка ідемпотентності: чи було вже виконано повернення для цієї саги?
        if (refundLogRepo.existsBySagaId(sagaId)) {
            log.info("Повернення для саги {} вже виконано раніше. Пропуск.", sagaId);
            return;
        }

        Account account = accountRepo.findByIdWithLock(accountId)
            .orElseThrow(() -> new EntityNotFoundException("Рахунок не знайдено"));

        account.credit(amount);
        accountRepo.save(account);

        // Фіксуємо факт компенсації
        refundLogRepo.save(new RefundLog(sagaId, accountId, amount, Instant.now()));
    }
}

Б. Неможливість бізнес-помилки

Компенсувальна транзакція не має права завершитися бізнес-відмовою (наприклад, «недостатньо прав» або «невалідний статус»). Усі бізнес-перевірки мають бути завершені до виконання прямої дії. Компенсація може впасти лише з інфраструктурною помилкою (недоступність мережі чи бази).


🔴 Senior Level

1. Проблема інверсії подій (Out-of-Order / Phantom Compensation)

В асинхронних чергах (Kafka, RabbitMQ) можлива ситуація мережевої гонки: компенсувальна команда $C_i$ випереджає пряму дію $T_i$. Сценарій: Оркестратор відправив команду HoldStock на склад, але пакет завис у мережевому буфері. Не дочекавшись відповіді, оркестратор за тайм-аутом вирішує скасувати замовлення і відправляє ReleaseStock. Сервіс складу спочатку отримує команду ReleaseStock, а вже через секунду — завислу HoldStock.

Якщо написати код наївно:

  1. ReleaseStock нічого не знаходить і завершується успішно.
  2. Слідом приходить HoldStock і навічно блокує товар на складі під уже скасоване замовлення!

Рішення: Семантичний запис про скасування (Tombstone Record)

@Service
@Transactional
public class StockReservationService {

    private final ReservationRepository reservationRepo;

    public void releaseReservation(UUID orderId, UUID productId, int count) {
        Optional<Reservation> opt = reservationRepo.findByOrderId(orderId);

        if (opt.isPresent()) {
            Reservation res = opt.get();
            if (res.getStatus() == ReservationStatus.ACTIVE) {
                res.setStatus(ReservationStatus.CANCELLED);
                productRepo.incrementStock(productId, count);
            }
        } else {
            // Компенсація прийшла раніше прямої дії!
            // Створюємо «надгробну плиту» (Tombstone), що блокує майбутню пряму дію
            Reservation tombstone = Reservation.builder()
                .orderId(orderId)
                .productId(productId)
                .status(ReservationStatus.CANCELLED_BY_EARLY_COMPENSATION)
                .build();
            reservationRepo.save(tombstone);
        }
    }

    public void holdStock(UUID orderId, UUID productId, int count) {
        Optional<Reservation> opt = reservationRepo.findByOrderId(orderId);
        if (opt.isPresent() && opt.get().getStatus() == ReservationStatus.CANCELLED_BY_EARLY_COMPENSATION) {
            log.warn("Пряма дія прийшла ПІСЛЯ компенсації для замовлення {}. Операцію відхилено.", orderId);
            return; // Нічого не резервуємо
        }
        // Звичайна логіка резерву...
    }
}

2. Семантична компенсація vs Технічний відкат

Не всі дії в реальному світі можна «стерти»:

  • Незворотні зовнішні дії: Відправлення SMS клієнту, друк накладної, фізичне нарізання кабелю на складі, виклик стороннього банківського еквайра.
  • Семантичне відшкодування:
    • Замість «скасування відправлення SMS» $\to$ відправлення нового SMS: «Вибачте, сталася помилка, ваше замовлення скасовано».
    • Замість безкоштовного повернення комісії еквайра $\to$ виплата компенсації за рахунок компанії або нарахування промокоду / бонусних балів.
    • Замість видалення запису з бази $\to$ запис коригувальної проводки в бухгалтерському журналі (Ledger Accounting).

3. Forward Recovery vs Backward Recovery

Параметр Backward Recovery (Відкат через компенсації) Forward Recovery (Рух уперед до успіху)
Суть При збої кроку $K$ викликаються компенсатори $C_{K-1} \dots C_1$ При збої кроку $K$ система повторює його (Retry) з експоненційним backoff до перемоги
Застосування Бізнес-помилки на ранніх етапах (немає грошей, немає товару на складі) Технічні збої або кроки після поворотної точки (Pivotal Transaction)
Складність Висока: потрібно розробити та протестувати код скасування для кожного кроку Середня: потрібна лише сувора ідемпотентність і надійний механізм ретраїв
Вплив на клієнта Замовлення скасовується, клієнт отримує сповіщення про помилку Замовлення затримується в часі, але в підсумку успішно виконується

4 Tricky Questions

1. Що таке точка неповернення (Pivotal Transaction) і чому після неї не можна запускати компенсації?

Відповідь: Поворотна транзакція (Pivotal Transaction) — це операція в сазі, після успішної фіксації якої відкат назад бізнес-процесу або технічно неможливий, або економічно неприпустимий (наприклад, списання коштів через міжбанківський шлюз SWIFT/СЕП або виклик зовнішнього незворотного API). Якщо поворотна транзакція закомічена, сага переходить у режим Forward Recovery. Усі подальші кроки (наприклад, генерація чека, відправлення сповіщення, оновлення статусу в CRM) зобов’язані бути реалізовані як Retriable Transactions: система нескінченно повторюватиме їх із наростаючою затримкою до успішного завершення, або відправлятиме в Dead Letter Queue для ручного закриття оператором.

2. Що робити, якщо компенсувальна транзакція падає з помилкою?

Відповідь: Компенсувальна транзакція не може завершитися відмовою відкату. Якщо вона впала:

  1. Інфраструктурний збій (мережа, СУБД): Оркестратор зобов’язаний повторювати виклик з Exponential Backoff і додаванням випадкового джитера (Jitter).
  2. Вичерпання ліміту ретраїв: Запис переводиться в спеціальний статус (наприклад, COMPENSATION_STUCK або MANUAL_RECOVERY_REQUIRED), генерується Critical Alert у моніторинг (PagerDuty/Opsgenie), а подія поміщається в DLQ. Проблему вирішує черговий інженер служби підтримки через адміністративну панель.

3. У якому порядку мають виконуватися компенсації при паралельних кроках саги (Fork-Join)?

Відповідь: Якщо сага розпаралелила завдання: крок $A$ запустив одночасно $B_1$ та $B_2$, і $B_2$ впав у той час, як $B_1$ завершився успішно:

  1. Оркестратор зобов’язаний дочекатися завершення або скасувати операцію $B_1$.
  2. Викликається компенсація $C_{B_1}$.
  3. Тільки після успішного завершення компенсації всіх паралельних гілок викликається компенсація попереднього кроку $C_A$. Компенсації незалежних паралельних гілок можуть виконуватися паралельно одна одній.

4. Чому в компенсувальній транзакції неприпустимо просто викликати DELETE для сутності, створеної на прямому кроці?

Відповідь:

  1. Втрата аудиту: Фінансові вимоги та вимоги комплаєнсу (SOX, PCI-DSS) забороняють безслідне видалення записів. Усі зміни повинні бути відображені як послідовність подій в аудит-лозі.
  2. Порушення цілісності зовнішніх ключів (FK): Інші сервіси могли встигнути прочитати ID створеної сутності та зв’язати з нею свої внутрішні процеси. Видалення призведе до «битих» посилань (Dangling Pointers).
  3. Гонки даних: Якщо пряме створення (INSERT) затримається в мережі, а DELETE виконається першим, то INSERT, що прийшов пізніше, залишить сутність у базі назавжди. Замість DELETE використовується зміна статусу на CANCELLED (Soft Delete / Tombstone).

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

Головне:

  • Компенсувальна транзакція — це не відкат (ROLLBACK), а нова пряма дія, яка нівелює бізнес-ефект попереднього кроку в розподіленій сазі.
  • Компенсації виконуються строго у зворотному порядку (LIFO).
  • Три золоті правила компенсації:
    1. Зобов’язана бути абсолютно ідемпотентною (Idempotent).
    2. Не може бути відхилена бізнес-валідацією.
    3. Має бути стійкою до фантомних компенсацій (надходження компенсації раніше прямої дії).
  • Pivotal Transaction ділить сагу на дві фази: до неї — Backward Recovery (відкат компенсаціями), після неї — Forward Recovery (повтори до переможного кінця).

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

  • ❌ «Компенсувальна транзакція робить rollback у базі даних» (Попередня транзакція вже давно закомічена; компенсація робить новий commit).
  • ❌ «Якщо компенсація впала, ми відкочуємо саму компенсацію» (Відкат компенсації неможливий; компенсація повторюється до успіху або ескалується людині).
  • ❌ «Для скасування замовлення ми просто видаляємо запис через repository.deleteById()» (Порушує аудит, породжує фантомні записи при out-of-order запитах).

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