ARTICLE / GO BACKEND

Все статьи

Состояния и компенсации в SAGA-оркестраторе между микросервисами

Как хранить состояние SAGA, различать отказ и неизвестный исход и управлять компенсациями, когда команды повторяются, а ответы приходят после тайм-аута.

ArchitectureDistributed SystemsIntegrationsObservability

Оркестратор не дождался подтверждения резервирования и начал отмену. Затем пришёл успешный ответ: ресурс занят, хотя процесс уже помечен отменённым. Это возможный результат вполне исправных локальных транзакций, если модель процесса считает тайм-аут доказательством неудачи.

Последовательность вызовов — простая часть SAGA. Сложнее после перезапуска определить следующий допустимый шаг, отличить отказ от неизвестного исхода и завершить компенсацию, которая сама может зависнуть. От этого зависит, останется ли у пользователя понятный результат или заявка потребует ручного поиска по нескольким сервисам.

Контекст и ограничения

Возьмём условный процесс: зарезервировать ресурс, согласовать внешний шаг, подтвердить результат. Участники владеют независимыми данными; команды и ответы могут повторяться, задерживаться и приходить после смены решения. Пользовательский сценарий допускает промежуточное состояние. Без этого условия SAGA не заменит требуемую атомарность.

У SAGA нет автоматической изоляции между процессами. Пока один процесс ждёт, другой может изменить тот же ресурс. Запрет двойного резервирования обеспечивает владелец ресурса своей транзакцией и ограничениями, а не центральный журнал. Оркестратор решает, какой шаг запросить, но не получает права обходить правила участника.

Ниже — модель с долговечным состоянием в PostgreSQL и доставкой команд через outbox. Готовый движок исполнения может взять на себя хранение истории и ожидания, но смысл отмены и допустимость компенсации всё равно определяет приложение. Механизм outbox уже разобран отдельно; здесь важны решения, которые он переносит.

Рабочая модель: намерение процесса и факты о шагах

Не превращать неизвестный исход в отказ

У шага нужны как минимум три различимых исхода: эффект подтверждён, получен окончательный отказ без эффекта, результат пока неизвестен. Последний возникает не только при обрыве сети. Например, gRPC допускает DEADLINE_EXCEEDED после успешного изменения, если ответ пришёл слишком поздно.

Отдельно хранится направление процесса: продолжаем выполнение или добиваемся отмены. «Отмена запрошена» описывает намерение, а не состояние ресурса. Поздний успех при отмене не возвращает процесс на прямой путь: он добавляет подтверждённый эффект, который ещё нужно урегулировать.

Схема показывает фрагмент обработки одного шага. Достоверное отсутствие эффекта означает не просто пустой ответ поиска, а результат протокола, исключающий последующее исполнение той же операции.

flowchart TD
    W[Ждём исход шага] -->|Успех| A[Эффект подтверждён]
    W -->|Окончательный отказ| R[Эффекта нет]
    W -->|Тайм-аут| U[Исход неизвестен]
    U -->|Сверка или поздний успех| A
    U -->|Отказ или подтверждённое закрытие операции| R
    A -->|Процесс отменяется| C[Компенсируем эффект]
    C -->|Эффект компенсации подтверждён| F[Эффект урегулирован]
    U -->|Не удаётся выяснить исход| M[Нужен ручной разбор]
    C -->|Не удаётся завершить| M

Повтор с тем же ключом операции, запрос её статуса и сверка — разные способы уменьшить неопределённость. Какой из них доступен, задаёт контракт участника. Если контракт не позволяет установить исход, оркестратор должен сохранять эту неопределённость, а не выбирать удобную ветку по числу попыток.

Хранить достаточно для восстановления решения

Для каждого экземпляра процесса я ожидаю увидеть:

Это не обязательная схема одной таблицы. Важно, чтобы после перезапуска не требовалось восстанавливать смысл из стека вызовов. Журнал переходов объясняет, какое событие изменило решение; текущее состояние позволяет быстро найти незавершённую работу.

Версия модели отличается от ревизии конкретного экземпляра. Новый выпуск должен корректно продолжать старые состояния либо мигрировать их явно. Проверка только новых процессов не покрывает этот риск.

Идентификатор логической операции не меняется при транспортном повторе. Новая попытка доставки получает свой номер для диагностики, но не новый ключ идемпотентности. Компенсация — отдельная операция со своим ключом и ссылкой на исходный эффект. Повторное использование ключа с другими параметрами должно отклоняться, а срок его хранения — покрывать возможные поздние команды. Эти ограничения разобраны в контракте идемпотентных API AWS.

Фиксировать решение вместе со следующим действием

Обработка ответа должна атомарно сохранить принятый факт, новую ревизию процесса и намерение отправить следующую команду. Иначе процесс перейдёт в ожидание шага, который никто не запустил, либо команда уйдёт без сохранённого решения. Сетевой вызов не нужно удерживать внутри транзакции PostgreSQL: она заканчивается записью намерения, доставку выполняет отдельный процесс.

Конкурирующие ответы и таймеры проверяют ревизию и допустимость перехода. Проигравший обновление заново читает состояние и пересчитывает решение; он не повторяет внешнее действие вслепую. Идентификатор сообщения защищает от транспортного дубля, а идентификатор операции связывает ответ с конкретным шагом. Один не заменяет другой.

Ожидания тоже должны переживать перезапуск. Один time.After внутри Go-процесса этого не обеспечивает. Срок хранится рядом с состоянием; планировщик выбирает наступившие действия, а обработчик повторно проверяет, что они ещё актуальны. Сохранённый таймер позволяет продолжить работу после восстановления, но не обещает срабатывания точно в срок при недоступной инфраструктуре. Тот же предел есть у долговечных таймеров Temporal.

Компенсировать свой эффект, а не восстанавливать снимок

Команда «освободить резерв этой операции» устойчивее команды «вернуть прежнее количество доступных единиц». Во втором случае компенсация может затереть изменения других процессов. Участник должен найти именно принадлежащий операции эффект и применить допустимый переход из текущего состояния.

Компенсация — новое бизнес-действие. Она не обязана возвращать исходное состояние или идти строго в обратном порядке. Порядок определяется зависимостями и ценой промежуточной несогласованности. Уже исполненное внешнее действие иногда требует отдельного возврата или ручного решения, а не технического «отката».

Данные для выяснения исхода и возможной компенсации сохраняют до отправки исходной команды. Регистрировать их только после успешного ответа поздно: эффект мог состояться, а ответ потеряться. Такой разрыв отдельно показан в разборе компенсаций Temporal. Если идентификатор ресурса появляется лишь в ответе, участник должен позволять найти результат по заранее известному идентификатору операции либо дать другой проверяемый способ восстановления.

Типичные поломки и как их ловят

Отмена пришла раньше исходной команды

Участник получил компенсацию, не нашёл резерв и вернул успех. После этого до него дошла задержанная команда резервирования. Повторяемость обеих команд не защищает от такого порядка: каждая могла выполниться ровно один раз.

Один вариант протокола — долговечная запись отмены по исходному идентификатору операции. Выполнение и отмена согласуют переход через общую запись операции или другой механизм взаимного исключения. Запрет проверяется вместе с созданием эффекта в локальной транзакции; если отмена зафиксирована первой, запоздавшая команда отклоняется. Просто проверить отсутствие отмены и создать резерв в одной транзакции недостаточно при Read Committed: конкурентная отмена может не увидеть ещё не зафиксированный резерв.

Если эффект уже создан, отмена переводит его в допустимое состояние. Другой вариант — сначала дождаться достоверного исхода, затем компенсировать. Без такой договорённости ответ «сейчас не найдено» не позволяет закрыть процесс. Проверочные тесты доставляют отмену первой и запускают обе команды конкурентно.

Поздний успех выбросили как устаревшее сообщение

Проверка только текущего шага удобна, пока ответы приходят по порядку. При отмене она может отбросить единственное подтверждение созданного ресурса. Ответ сопоставляют с журналом выданных операций, сохраняют факт и проверяют, урегулирован ли эффект. Уже подтверждённую компенсацию не запускают заново как новое намерение.

Нужны тесты перестановок: тайм-аут, решение об отмене, поздний успех, повтор успеха, ответ компенсации. После каждой последовательности проверяют не только статус процесса, но и отсутствие неучтённого ресурса у участника. Неизвестный идентификатор операции требует разбора, а не продвижения текущего шага.

Аренда работы истекла, но старый исполнитель продолжает вызов

Передача аренды другому экземпляру не останавливает уже отправленный запрос. Проверка ревизии защищает запись оркестратора, но сама по себе не защищает внешний ресурс. Повтор одной операции поглощает участник. Для защиты от устаревшего владельца может потребоваться номер его поколения: получатель проверяет его при изменении ресурса и отклоняет команды старых владельцев.

Это нельзя заменить проверкой аренды только перед вызовом: процесс способен задержаться сразу после неё. Если получатель не поддерживает нужную защиту, остаются ограничение конкуренции и сверка, но обещать отсутствие гонки уже нельзя. Такой сценарий проверяют остановкой исполнителя между получением работы и внешним эффектом.

Компенсация зависла, а процесс назвали завершённым

У компенсации есть собственные повторы, задержки и постоянные отказы. После исчерпания автоматического бюджета процесс переходит в ручной разбор: фиксируются причина, ответственный и следующее допустимое действие. За разбор и эскалацию отвечает владелец процесса; допустимость действия над ресурсом по-прежнему проверяет участник. Это не успешная отмена. Нельзя удалять журнал и записи о ключах идемпотентности, пока они нужны для завершения и защиты от поздних сообщений.

Ручное действие тоже проходит проверку состояния и оставляет запись о принятом решении. Оператору нужен не произвольный SQL к чужой базе, а ограниченная команда: повторить проверку, подтвердить исход на основании сверки с участником или выполнить согласованную компенсацию. Иначе ручной путь обходит те же инварианты, ради которых строился автомат.

Что измерять

Общее число успешных процессов почти не помогает найти застрявшую SAGA. Нужны возраст старейшего неизвестного исхода, время в компенсации и возраст очереди ручных разборов. Для планировщика отдельно измеряют задержку между сохранённым сроком и фактическим запуском действия: здоровые участники не помогут, если никто не разбудил процесс.

Попытки доставки отделяют от переходов состояния. Высокое число вызовов может означать повтор одного шага, а не полезное продвижение. Поэтому отдельно хранят время последнего подтверждённого продвижения: если каждый повтор обновляет updated_at, свежая запись маскирует застрявший процесс. Полезны также конфликты ревизий, поздние ответы, отказы компенсаций и расхождения между журналом эффектов и состоянием ресурсов. Пороги привязывают к допустимому времени незавершённого пользовательского сценария.

Измерения группируют по типу процесса, этапу и ограниченному набору причин. Идентификаторы экземпляров остаются в журнале и трассировках. Количество и возраст незавершённых процессов старых версий показывают, какую модель команда ещё обязана поддерживать после нового выпуска.

Когда такой оркестратор вреден

Если инвариант требует немедленной общей атомарности, SAGA добавит промежуточные состояния, но не отменит требование. Иногда правильнее вернуть данные в одну границу и выполнить локальную транзакцию. Если процесс короткий, а реакции действительно независимы, отдельный координатор может создать лишнюю точку операционной ответственности.

Нельзя рассчитывать на компенсацию там, где предметная область её не допускает. Необратимый шаг стоит отделить явно и по возможности отложить до необходимых проверок; после него может потребоваться завершение вперёд, а не отмена предыдущих действий. Если любой неизвестный исход требует ручной работы, до роста нагрузки нужно оценить пропускную способность этого пути.

Собственный универсальный движок тоже не всегда оправдан. Вместе с несколькими переходами команда получает версионирование процессов, планировщик, восстановление, инструменты разбора и эксплуатацию хранилища. Выбор готового движка уменьшает часть механики, но не снимает ответственности за правила участников.

Вывод для архитектурного ревью

У рабочего оркестратора три проверяемых свойства. Тайм-аут сохраняет неизвестный исход, пока участник не подтвердил результат. Решение и намерение следующего действия фиксируются атомарно. Отмена считается завершённой только когда выданные операции больше не могут создать неучтённый эффект, а подтверждённые эффекты урегулированы по правилам предметной области.

Компенсация не обещает, что ничего не произошло. Она делает последствия произошедшего явными, управляемыми и доступными для проверки.