🏗️ Раздел 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 запросах).

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