О чём статья
Почему выбрали компенсирующие транзакции
Как работает механизм с централизованным координатором
Реализация на PHP (код, диаграммы)
Извлечённые уроки
Введение
В больших компаниях операции часто затрагивают множество сервисов: Active Directory, GitLab, Kaiten, базы данных. Любой из них может сломаться — зависнуть, не ответить, превысить лимит.
Классические решения здесь не работают:
Транзакции БД не умеют откатывать POST-запросы к GitLab API
Two-Phase Commit требует XA-протокол, которого нет в REST API
Retry Pattern умеет повторять, но не умеет отменять уже выполненное
В этой статье расскажу, как мы реализовали паттерн компенсирующих транзакций для управления доступами в АльфаСтрахование.
Про термины
Saga Pattern изначально (1987 год) описывал long-lived транзакции в распределенных базах данных. Сейчас этот термин используется шире — для любых длительных процессов с компенсациями вместо блокировок.
Суть паттерна: SAGA разбивает длинную бизнес-операцию на последовательность независимых шагов. Каждый шаг — это локальная транзакция, которая что-то меняет и сразу фиксирует изменения. К каждому шагу прилагается компенсирующая операция — способ откатить эффект, если что-то пойдёт не так дальше по цепочке.
Билет забронирован → деньги списаны → отель зарезервирован → и тут оказывается что машин нет. SAGA запускает компенсации в обратном порядке: отменяет отель, возвращает деньги, освобождает билет.
Два стиля оркестровки:
Choreography (хореография): каждый участник знает, что делать после своего шага. Билет забронирован? Сервис билетов кидает событие, сервис оплаты его ловит и списывает деньги. Децентрализованно, но сложно отследить весь флоу.
Orchestration (оркестрация): есть дирижёр, который командует: "Сначала билет, потом деньги, потом отель". Он же запускает компенсации при сбое. Централизованный контроль, проще понять что происходит.
В нашем случае:
Монолитное приложение на Laravel
Интеграции с внешними системами (AD, GitLab, PostgreSQL)
Процессы с человеческими согласованиями (от минут до часов)
Orchestration — центральный координатор управляет процессом отката
Дальше буду использовать термины "компенсирующие транзакции" и "Saga Pattern" как синонимы.
Бизнес-кейс: техническое окно с откатом согласований
Чтобы остановить важный сервис на время обновления, нужно согласовать это со всеми зависимыми командами.
Сценарий: Обновляем PostgreSQL кластер в воскресенье с 2 ночи до 6 утра.
Последовательность:
Согласование с владельцем системы
Администратор создаёт заявку
Владелец одобряет
Создание технического окна
Создаётся запись
ServiceDowntimeв БДОкно появляется в календаре всех команд
Команды видят окно и начинают подготовку (отключают мониторинг, уведомляют клиентов)
Сбор согласований от затронутых команд
6 бизнес-партнеров одобрили ✓
Последний БП отказал ✗
Проблема: Техническое окно уже создано, команды начали подготовку, но один из партнёров отказал. Процесс выполнен частично.
Последствия без автоматического отката
Техническое окно висит в календаре, но провести его нельзя. Администратору придется вручную:
Удалить запись о недоступности
Предупредить все 6 команд об отмене
Перенести окно на другое время
Время на ручной откат: ~30 минут.
Как работают компенсации
При отказе система автоматически:
Удаляет запись
ServiceDowntimeАннулирует все 6 полученных согласований (статус → CANCELLED)
Отправляет уведомления всем участникам
Уведомляет администратора о необходимости пересогласования
Время: меньше 3 секунд.
Принцип атомарности: Либо окно полностью одобрено всеми, либо его нет. Окно без согласований — это нарушение регламента и риск конфликтов.
Требования к решению
Надёжность — возможность отката выполненных операций при ошибке
Прозрачность — логирование всех действий для аудита
Частичная компенсация — продолжение отката даже при ошибке одной компенсации
Идемпотентность — безопасное повторное выполнение
Масштабируемость — поддержка 90+ различных типов workflow
Обратная совместимость — новый механизм не должен ломать существующие процессы
Почему компенсирующие транзакции?
Перед реализацией мы рассмотрели альтернативы. Почему они не подошли:
Database Transactions: проблема длительности
Обернуть все операции в BEGIN TRANSACTION ... COMMIT/ROLLBACK.
Почему не подходит:
Между созданием окна и получением согласований проходит от минут до часов
Держать транзакцию открытой так долго означает блокировки, риск deadlock, проблемы с connection pooling
Транзакции не работают с внешними API —
ROLLBACKне откатит POST-запрос к GitLab
Two-Phase Commit (2PC): нет поддержки в API
Координатор запрашивает у всех участников готовность (prepare), затем commit/rollback.
Почему не подходит:
Внешние API (GitLab, Jira, Confluence) не поддерживают XA-транзакции
В REST API нет prepare-фазы
2PC снижает доступность: если хотя бы один участник недоступен, вся транзакция блокируется
Event Sourcing: избыточная сложность
Хранить все события для восстановления полной истории состояний.
Почему не подходит:
Нам не нужна полная история — достаточно знать текущее состояние
Event Sourcing решает другую задачу (аудит, временные запросы)
Наш случай: важно знать заблокирован ли пользователь, а история промежуточных состояний не критична
Сравнение подходов
Критерий | DB Trans | 2PC | Saga | Event Sourcing |
|---|---|---|---|---|
Одна БД, быстрые операции | ✅ | ❌ | ⚠️ | ❌ |
Распределённые БД с XA | ❌ | ✅ | ✅ | ⚠️ |
Внешние API (REST) | ❌ | ❌ | ✅ | ⚠️ |
Долгие процессы (минуты-часы) | ❌ | ❌ | ✅ | ⚠️ |
Eventual consistency приемлема | ❌ | ❌ | ✅ | ✅ |
Нужны ACID-гарантии | ✅ | ✅ | ❌ | ❌ |
✅ — подходит, ⚠️ — может быть, ❌ — не подходит
Почему Saga подошла нам
Наши требования:
✅ Работа с разными системами (AD, GitLab, Kaiten, БД, Kafka)
✅ Работа с внешними API
✅ Долгие процессы с человеческими согласованиями
✅ Eventual consistency приемлема (задержка 1-5 сек не критична)
✅ Операции можно компенсировать (DELETE созданного, UNBLOCK заблокированного)
Да, мы отказались от ACID-гарантий в пользу конечной согласованности. Это приемлемо для HR/инфраструктурных процессов, но НЕ подходит для финансовых транзакций.
Базовая архитектура системы
Прежде чем перейти к компенсациям, кратко о базовой архитектуре.
Task → Workflow → CompensationRecord
Task — атомарная единица работы:
Что делать: тип действия (
CREATE_USER,GRANT_ACCESS)С какими данными: параметры выполнения
Статус:
WAITING,IN_PROGRESS,COMPLETED,FAILED
Workflow — стратегия выполнения:
Синхронное или асинхронное
С согласованием или без
С компенсацией или без
CompensationRecord — персистентное хранилище для отката:
ID задачи
ID действия
Данные для восстановления (JSON)
Статус компенсации
Порядковый номер (для LIFO)
Как компенсации расширяют базовую архитектуру
Компенсирующие транзакции добавляют три механизма:
Автоматическая регистрация — для каждой успешной задачи создается запись с данными для отката
LIFO откат — при ошибке откат выполняется в обратном порядке
Orchestration — центральный координатор (
CompensateRetireRecursiveAction) управляет процессом
Compensating Transactions: как это работает
Компенсация vs Rollback
Rollback (откат транзакции):
Автоматический откат изменений в БД
Полная отмена (как будто их не было)
Работает только внутри одной базы
Compensation (компенсация):
Явное бизнес-действие, отменяющее эффект операции
Семантическая отмена (новое противоположное действие)
Работает с любыми системами (API, БД, файлы)
Компенсация — это не магический технический rollback, а бизнес-логика отмены.
Когда применять
Подходит:
Мультисистемные операции (API, БД, external services)
Долгие процессы с человеческим фактором
Eventual consistency приемлема
Операции можно компенсировать
Не подходит:
Финансовые транзакции (нужны ACID-гарантии)
Одна БД, короткие операции (используйте транзакции)
Невозможность компенсации (отправка email без возможности "отозвать")
Реализация: архитектура компонентов
Почему Orchestration?
У нас Orchestration (централизованное управление), а не Choreography (event-driven без координатора).
Это значит:
Есть главный координатор (
CompensateRetireRecursiveAction)Координатор сам решает порядок отката (LIFO)
Вся логика в одном месте — легко дебажить
Choreography (каждый сервис сам реагирует на события) хорош для микросервисов, но для нашего монолита избыточен.
Общая схема
Реализация состоит из 5 компонентов:
CompensationRegistry — регистрация компенсаций при выполнении действий
CompensationRecord — хранение метаданных (БД таблица с JSON)
CompensatableActionInterface — контракт для сбора данных отката
Compensation Actions — отдельные классы для восстановления
CompensateRetireRecursiveAction — координация LIFO отката

Sequence Diagram: автоматическая регистрация

Sequence Diagram: процесс компенсации

State Diagram: жизненный цикл CompensationRecord

Детали реализации
CompensationRecord: хранилище для отката
Каждое выполненное действие автоматически создает запись:
CREATE TABLE compensation_records ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, action_id INTEGER NOT NULL, action_key VARCHAR(255), compensation_data JSON NOT NULL, -- Полное состояние для восстановления executed_at TIMESTAMP, compensated_at TIMESTAMP, compensation_status VARCHAR(20) DEFAULT 'pending', compensation_error TEXT, sequence_number INTEGER DEFAULT 0, -- LIFO-порядок INDEX (task_id, sequence_number), INDEX (compensation_status) );
Статусы:
pending— ожидает компенсацииexecuting— откат выполняетсяcompleted— успешно откаченоfailed— откат провалился, нужно ручное вмешательство
Пример compensation_data для блокировки пользователя в AD:
{ "email": "ivanov.i@company.com", "previous_user_account_control": 512, "ad_groups": ["CN=Developers,OU=Groups", "CN=VPN_Users,OU=Groups"] }
sequence_number обеспечивает строгий LIFO: последнее действие откатывается первым.
CompensationRegistry: автоматическая регистрация
interface CompensationRegistryInterface { // Регистрация с данными для отката public function register( Task $task, string $actionClass, string $actionKey, array $compensationData ): CompensationRecord; // Получение записей для task (только прямые) public function getByTask(Task $task): Collection; // Рекурсивное получение (task + все subtasks в LIFO порядке) public function getByTaskRecursive(Task $task): Collection; public function markAsCompleted(CompensationRecord $record): void; public function markAsFailed(CompensationRecord $record, string $error): void; }
Реализация:
class DatabaseCompensationRegistry implements CompensationRegistryInterface { public function register( Task $task, string $actionClass, string $actionKey, array $compensationData ): CompensationRecord { // Автоинкремент sequence_number через Redis $sequenceNumber = Redis::incr("compensation:sequence:{$task->getId()}"); return CompensationRecord::create([ 'task_id' => $task->getId(), 'action_id' => $task->getActionId(), 'action_key' => $actionKey, 'compensation_data' => $compensationData, 'executed_at' => now(), 'compensation_status' => CompensationRecord::STATUS_PENDING, 'sequence_number' => $sequenceNumber, ]); } public function getByTaskRecursive(Task $task): Collection { // Рекурсивный сбор task_id (task + все subtasks) $taskIds = $this->collectTaskIdsRecursively($task); return CompensationRecord::whereIn('task_id', $taskIds) ->where('compensation_status', CompensationRecord::STATUS_PENDING) ->orderByDesc('task_id') // LIFO: сначала последние tasks ->orderByDesc('sequence_number') // LIFO: сначала последние actions ->get(); } }
Как устроена компенсация
Главный принцип: действие и компенсация — это два разных класса с разными ActionID.
Терминология:
Compensatable Action — действие (BLOCK_USER), реализует
CompensatableActionInterfaceCompensation Action — компенсация (UNBLOCK_USER), НЕ реализует
CompensatableActionInterface
CompensatableActionInterface: сбор данных
Компенсируемые действия реализуют интерфейс:
interface CompensatableActionInterface { public function getCompensationData(Task $task): array; }
Пример: блокировка пользователя в AD
#[WorkflowAction(ActionID::BLOCK_USER_IN_AD)] // ActionID = 105 class BlockUserInADAction extends BaseContextAwareAction implements CompensatableActionInterface { protected function executeAction(Task $task): void { $email = $this->getTechInfo('email'); $login = $this->extractLogin($email); // Получаем состояние перед изменением $currentControl = $this->adldapService->getUserAccountControl($login); // Проверка на идемпотентность if ($currentControl === AccountControl::DISABLED_ACCOUNT) { $this->setStepResult('block_user', ['already_blocked' => true]); return; } // Сохраняем состояние для компенсации $this->setStepResult('block_user', [ 'previous_user_account_control' => $currentControl, ]); // Выполняем блокировку $this->adldapService->blockUser($login); } // Вызывается автоматически после успешного executeAction() public function getCompensationData(Task $task): array { $email = $this->getTechInfo('email'); $stepResult = $this->getStepResult('block_user'); $previousControl = $stepResult['previous_user_account_control']; // Type-safe константы для согласованности return BlockUserCompensationData::create($email, $previousControl); } }
Важные моменты:
Идемпотентность — проверка состояния перед выполнением
Сохранение полного состояния — всех данных, которые меняет action
Автоматическая регистрация —
getCompensationData()вызывается вBaseContextAwareAction
Compensation Actions: восстановление состояния
Compensation actions не реализуют CompensatableActionInterface — они только читают данные и восстанавливают состояние.
Маппинг через атрибуты
Связь объявляется через атрибут:
#[WorkflowAction(ActionID::UNBLOCK_USER_IN_AD)] #[CompensatesAction(ActionID::BLOCK_USER_IN_AD)] // ← Декларация связи class UnblockUserInADAction extends BaseContextAwareAction { // Реализация... }
CompensationActionMapRegistry автоматически сканирует классы:
// Сгенерированный маппинг: [ ActionID::BLOCK_USER_IN_AD => ActionID::UNBLOCK_USER_IN_AD, ActionID::REMOVE_FROM_AD_GROUPS => ActionID::RESTORE_AD_GROUPS, ActionID::ADD_TO_DISMISS_GROUPS => ActionID::REMOVE_FROM_DISMISS_GROUPS, ]
Пример: разблокировка пользователя
#[WorkflowAction(ActionID::UNBLOCK_USER_IN_AD)] #[CompensatesAction(ActionID::BLOCK_USER_IN_AD)] class UnblockUserInADAction extends BaseContextAwareAction { protected function executeAction(Task $task): void { // 1. Получаем ID записи $compensationRecordId = $this->getTechInfo('compensation_record_id'); // 2. Загружаем CompensationRecord $record = CompensationRecord::findOrFail($compensationRecordId); // 3. Извлекаем данные $email = BlockUserCompensationData::extractEmail($record->compensation_data); $previousControl = BlockUserCompensationData::extractPreviousControl($record->compensation_data); $login = $this->extractLogin($email); // 4. Проверяем текущее состояние $currentControl = $this->adldapService->getUserAccountControl($login); if ($currentControl === $previousControl) { return; // Уже в нужном состоянии } // 5. Восстанавливаем предыдущее состояние $this->adldapService->setUserAccountControl($login, $previousControl); } }
CompensateRetireRecursiveAction: координация отката
Компенсация выполняется через обычный action:
#[WorkflowAction(ActionID::COMPENSATE_RETIRE_RECURSIVE)] class CompensateRetireRecursiveAction extends BaseContextAwareAction { public function __construct( ContextManagerInterface $contextManager, Logger $logger, private readonly TasksRepositoryInterface $tasksRepository, private readonly ActionRegistry $actionRegistry, private readonly CompensationRegistryInterface $compensationRegistry, private readonly TaskService $taskService ) { parent::__construct($contextManager, $logger); } protected function executeAction(Task $task): void { $originalTaskId = $this->getTechInfo('original_task_id'); $reason = $this->getTechInfo('reason', 'Compensation requested'); $originalTask = $this->tasksRepository->find($originalTaskId); // Получить все записи в обратном порядке (LIFO) $compensationRecords = $this->compensationRegistry->getByTaskRecursive($originalTask); $compensatedCount = 0; $failedCompensations = []; foreach ($compensationRecords as $record) { if (!$record->isPending()) continue; try { $this->compensateRecord($record, $task); $compensatedCount++; } catch (Throwable $e) { $failedCompensations[] = [ 'record_id' => $record->id, 'action_id' => $record->action_id, 'error' => $e->getMessage() ]; $this->logger->error('Compensation failed', [ 'record_id' => $record->id, 'error' => $e->getMessage() ]); } } $this->setStepResult('compensate_retire_recursive', [ 'total_records' => $compensationRecords->count(), 'compensated_count' => $compensatedCount, 'failed_count' => count($failedCompensations), 'failed_compensations' => $failedCompensations, ]); } private function compensateRecord(CompensationRecord $record, Task $parentTask): void { $record->markAsExecuting(); $compensationActionId = $this->compensationActionMapRegistry ->getCompensationActionId($record->action_id); if ($compensationActionId === null) { $record->markAsCompleted(); return; } // Создаём отдельную задачу для компенсации $compensationTask = $this->taskService->createWaitingSubtask( new TaskDataDTO( templateId: TemplateID::RETIRE, senderEmail: $task->getSender()->getEmail(), techInfo: ['compensation_record_id' => $record->id], actionId: $compensationActionId ), $task )->first(); if ($compensationTask) { $record->markAsCompleted(); } } }
Стратегия Best-Effort
При ошибке одной компенсации процесс продолжается. Частичная компенсация лучше отсутствия компенсации.
Пример: 10 согласований, 9 откачены, 1 упало (GitLab API недоступен). Администратор получает отчёт и вручную откатывает только GitLab.
Автоматическая регистрация в BaseContextAwareAction
abstract class BaseContextAwareAction { public function execute(Task $task): void { try { // 1. Выполняем бизнес-логику $this->executeAction($task); // 2. Автоматически регистрируем компенсацию if ($this instanceof CompensatableActionInterface && $this->compensationRegistry !== null) { $this->registerCompensation($task); } $task->markAsCompleted(); } catch (Throwable $e) { $task->markAsFailed($e->getMessage()); throw $e; } } private function registerCompensation(Task $task): void { $actionKey = $task->getTechInfo()->get('action_key'); // Вызываем getCompensationData() у наследника $compensationData = $this->getCompensationData($task); // Сохраняем в БД $this->compensationRegistry->register( $task, static::class, $actionKey, $compensationData ); } }
Таким образом:
Достаточно реализовать
CompensatableActionInterfaceс методомgetCompensationData()BaseContextAwareActionавтоматически создастCompensationRecordпосле успешного выполненияДанные из
getCompensationData()сразу попадают в БД
Опыт эксплуатации
Основные причины провалов компенсаций
1. Внешние API недоступны (60%)
Решение: Retry
private function compensateWithRetry(CompensationRecord $record, Task $parentTask): void { $maxAttempts = 3; $baseDelay = 1; for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { try { $this->compensateRecord($record, $parentTask); return; } catch (ApiTimeoutException | ConnectionException $e) { if ($attempt === $maxAttempts) { $record->markAsFailed($e->getMessage()); throw $e; } $delay = $baseDelay * pow(2, $attempt - 1); sleep($delay); } } }
2. Ресурс уже удалён вручную (25%)
Решение: идемпотентные компенсации с проверкой состояния
public function executeAction(Task $task): void { $userId = $this->getCompensationData($task)['gitlab_user_id']; try { $user = $this->gitlabApi->getUser($userId); } catch (NotFoundException $e) { // Уже удалён return; } if ($user['state'] === 'active') { return; // Уже в нужном состоянии } $this->gitlabApi->unblockUser($userId); }
Ограничения и компромиссы
Eventual consistency, не ACID
Между выполнением и компенсацией проходит 1-3 секунды — система в этот момент в несогласованном состоянии.
Это приемлемо для HR/инфраструктурных процессов, но неприемлемо для финансовых транзакций.
Требуется ручное вмешательство при частичных сбоях
Около 1% компенсаций требуют ручного исправления 1-2 провалов. Необходим обученный персонал и документация.
Данные в compensation_data должны быть достаточными
Лучше сохранять полное состояние перед выполнением действия (группы, роли, права).
Идемпотентность компенсаций обязательна
Компенсации могут выполняться повторно (retry, ручной запуск). Каждая компенсация должна проверять текущее состояние перед выполнением.
Когда НЕ использовать Saga
❌ Финансовые транзакции — нужны ACID-гарантии
❌ Одна БД, короткие операции — DB transactions проще
❌ Необратимые действия — отправка email, физическое удаление без backup
❌ Строгая согласованность критична — управление квотами, race conditions
Заключение
Compensating Transactions — прагматичное решение для мультисистемных процессов в больших приложениях.
Что получили:
Автоматические откаты при сбоях
Масштабируемость на 90+ различных процессов
Прозрачность и аудит всех операций
Правильное применение требует понимания границ (eventual consistency vs ACID) и выбора подходящего стиля координации (Orchestration vs Choreography).
Полезные ссылки
Оригинальная статья о Saga — Garcia-Molina & Salem, 1987
Microservices Patterns: Saga — Chris Richardson
