Что такое компенсирующие транзакции
Любая распределённая сага состоит из трёх типов локальных транзакций:
🟢 Junior Level
Компенсирующая транзакция (Compensating Transaction) — это бизнес-операция, которая семантически нейтрализует (отменяет) эффект ранее успешно зафиксированной локальной транзакции в рамках распределённого процесса (паттерн Saga).
Прямой ход (Forward Flow):
[Заказ создан] ──► [Деньги списаны] ──► [Склад: ТОВАРА НЕТ! ❌]
Обратный ход (Backward Compensation):
[Заказ отменён (C1)] ◄── [Возврат денег (C2)] ◄──┘ (Компенсация в обратном порядке)
Ключевые отличия от классического ROLLBACK:
- Транзакция уже закоммичена: В отличие от команды
ROLLBACKв SQL, которая отменяет незафиксированные изменения в оперативной памяти и WAL-логе БД до коммита, компенсирующая транзакция выполняется над уже закоммиченными данными, которые могли успеть прочитать другие пользователи. - Новая явная операция: Компенсация — это отдельный
INSERTилиUPDATEв базу данных. Например, если прямой операцией было «списание 500 рублей», то компенсацией будет не удаление строки из аудита, а операция «зачисление 500 рублей с типомREFUND». - Обратный порядок (LIFO): Если выполнились шаги $T_1 \to T_2 \to T_3$, а на шаге $T_4$ произошел сбой, компенсации вызываются строго в обратном порядке: $C_3 \to C_2 \to C_1$.
🟡 Middle Level
1. Анатомия транзакций в паттерне Saga
Любая распределённая сага состоит из трёх типов локальных транзакций:
- Компенсируемые транзакции (Compensable Transactions): Шаги, которые могут быть гарантированно отменены с помощью компенсирующей транзакции (например, резервирование средств, создание черновика заказа).
- Поворотная транзакция (Pivotal Transaction): «Точка невозврата». Если эта транзакция завершилась успешно, сага обязана дойти до победного конца. Если упала — сага откатывается компенсациями назад. Поворотная транзакция не может быть компенсирована.
- Повторяемые транзакции (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.
Если написать код наивно:
ReleaseStockничего не находит и завершается успешно.- Следом приходит
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. Что делать, если компенсирующая транзакция падает с ошибкой?
Ответ: Компенсирующая транзакция не может завершиться отказом отката. Если она упала:
- Инфраструктурный сбой (сеть, СУБД): Оркестратор обязан повторять вызов с Exponential Backoff и добавлением случайного джиттера (Jitter).
- Исчерпание лимита ретраев: Запись переводится в специальный статус (например,
COMPENSATION_STUCKилиMANUAL_RECOVERY_REQUIRED), генерируется Critical Alert в мониторинг (PagerDuty/Opsgenie), а событие помещается в DLQ. Проблему решает дежурный инженер службы поддержки через административную панель.
3. В каком порядке должны выполняться компенсации при параллельных шагах саги (Fork-Join)?
Ответ: Если сага распараллелила задачи: шаг $A$ запустил одновременно $B_1$ и $B_2$, и $B_2$ упал в то время, как $B_1$ завершился успешно:
- Оркестратор обязан дождаться завершения или отменить операцию $B_1$.
- Вызывается компенсация $C_{B_1}$.
- Только после успешного завершения компенсации всех параллельных веток вызывается компенсация предшествующего шага $C_A$. Компенсации независимых параллельных веток могут выполняться параллельно друг другу.
4. Почему в компенсирующей транзакции недопустимо просто вызывать DELETE для сущности, созданной на прямом шаге?
Ответ:
- Потеря аудита: Финансовые и комплаенс-требования (SOX, PCI-DSS) запрещают бесследное удаление записей. Все изменения должны быть отражены как последовательность событий в аудит-логе.
- Нарушение целостности внешних ключей (FK): Другие сервисы могли успеть прочитать
IDсозданной сущности и связать с ней свои внутренние процессы. Удаление приведет к битым ссылкам (Dangling Pointers). - Гонки данных: Если прямое создание (
INSERT) задержится в сети, аDELETEвыполнится первым, то пришедший позжеINSERTоставит сущность в базе навсегда. ВместоDELETEиспользуется смена статуса наCANCELLED(Soft Delete / Tombstone).
🎯 Шпаргалка для интервью
Главное:
- Компенсирующая транзакция — это не откат (ROLLBACK), а новое прямое действие, нивелирующее бизнес-эффект предыдущего шага в распределенной саге.
- Компенсации выполняются строго в обратном порядке (LIFO).
- Три золотых правила компенсации:
- Обязана быть абсолютно идемпотентной (
Idempotent). - Не может быть отклонена бизнес-валидацией.
- Должна быть устойчива к фантомным компенсациям (приходу компенсации раньше прямого действия).
- Обязана быть абсолютно идемпотентной (
- Pivotal Transaction делит сагу на две фазы: до неё — Backward Recovery (откат компенсациями), после неё — Forward Recovery (повторы до победного конца).
Красные флаги (Чего говорить НЕЛЬЗЯ):
- ❌ «Компенсирующая транзакция делает rollback в базе данных» (Предыдущая транзакция уже давно закоммичена; компенсация делает новый commit).
- ❌ «Если компенсация упала, мы откатываем саму компенсацию» (Откат компенсации невозможен; компенсация повторяется до успеха или эскалируется человеку).
- ❌ «Для отмены заказа мы просто удаляем запись через
repository.deleteById()» (Нарушает аудит, порождает фантомные записи при out-of-order запросах).