Как делать распределённые транзакции правильно? Saga и Durable Execution на примере Durable Workflow (PHP, Laravel)

Распределённые транзакции между независимыми сервисами невозможно реализовать одной транзакцией базы данных — для этого применяют паттерн Saga с заранее продуманными компенсациями на случай ошибок. Подход Durable Execution позволяет длительному процессу пережить сбои и восстановить выполнение, а в статье на примере продукта Durable Workflow для PHP и Laravel разбирается, как организовать надёжные компенсации и управлять состоянием.

Распределённые транзакции между независимыми сервисами невозможно реализовать одной транзакцией базы данных — для этого применяют паттерн Saga с заранее продуманными компенсациями на случай ошибок. Подход Durable Execution позволяет длительному процессу пережить сбои и восстановить выполнение, а в статье на примере продукта Durable Workflow для PHP и Laravel разбирается, как организовать надёжные компенсации и управлять состоянием.

Для таких процессов применяют Saga: последовательность локальных операций с заранее продуманными действиями на случай ошибки. А чтобы длительный процесс мог продолжиться после сбоя, используют подход Durable Execution. Разберём, как эти идеи дополняют друг друга и как организовать компенсации в Laravel на примере продукта Durable Workflow.

Что такое Saga и какую проблему она решает

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

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

Важно: компенсация — не то же самое, что откат базы данных. Возврат денег может занять несколько дней, а уже отправленное письмо нельзя «отправить обратно». Иногда вместо полного восстановления исходного состояния нужна другая операция: отмена заказа, уведомление клиента или ручная обработка.

Во время выполнения другие участники могут видеть промежуточное состояние. Saga не обеспечивает общей атомарности и изоляции ACID-транзакции; она помогает привести систему к согласованному бизнес-состоянию при условии, что ошибки и компенсации обработаны. Подробнее об ограничениях — в описании паттерна AWS.

Пример: заказ, оплата и доставка

Рассмотрим условный интернет-магазин. Это иллюстрация паттерна, а не описание конкретного внедрения.

ШагОсновное действиеВозможная компенсация
1Создать заказ в статусе «Ожидает обработки»Отменить заказ
2Зарезервировать товарОсвободить резерв
3Списать оплатуИнициировать возврат
4Создать отправлениеОтменить отправление, если перевозчик это допускает

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

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

Схема 1. Условный процесс заказа и его компенсации.

Оркестрация и хореография

Есть два распространённых способа координировать участников:

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

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

Схема 2. Хореография: участники реагируют на события друг друга.

При оркестрации участники получают команды от координатора и сообщают ему результат. Координатор решает, продолжить процесс или запустить компенсации.

Схема 3. Оркестрация: координатор управляет взаимодействием сервисов.

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

Когда использовать Saga, а когда достаточно очереди

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

Но отдельный движок нужен не всегда:

  • Одна база и одна граница транзакции — начните с обычной транзакции БД.
  • Простая последовательность фоновых задач — рассмотрите Laravel Jobs и цепочки заданий.
  • Несколько систем с компенсируемыми действиями — рассмотрите Saga; координатор можно реализовать самостоятельно или использовать workflow-движок.

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

Что такое Durable Execution и как он помогает Saga

Durable Execution — подход к выполнению программ, при котором процесс может продолжиться после сбоя, не теряя зафиксированный прогресс. Как объясняет Temporal, выполнение не привязано к одному процессу или машине: после сбоя платформа восстанавливает состояние и продолжает работу. Это полезно и для длительных процессов, которые часами или днями ждут события, подтверждения или таймера.

Durable Execution — общий принцип, а не название конкретного продукта. Его реализации различаются; не каждый workflow-движок обеспечивает такое выполнение.

Durable Workflow, который используется в примерах статьи, — отдельный продукт, реализующий этот подход.

Saga решает другую задачу: определяет локальные шаги и компенсации для достижения согласованного бизнес-результата.

Durable Execution помогает сохранять и восстанавливать выполнение Saga, но не придумывает компенсации и не гарантирует их успех.

В Durable Workflow сохраняется история выполнения процесса. При восстановлении код workflow воспроизводится по этой истории, а результаты уже зафиксированных шагов используются повторно. Внешнюю работу — запросы к API, запись в другую систему, платёж — выносят в activities.

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

О различиях между повторным выполнением и воспроизведением истории рассказывает руководство PHP SDK.

У продукта есть два разных режима:

  • Embedded Laravel — workflow работает внутри Laravel-приложения. Пример ниже использует API Workflow\V2 из документации embedded-режима.
  • Отдельный Durable Workflow Server — сервер хранит состояние и координирует воркеры через свой протокол; PHP, Python и Rust подключаются через свои SDK. В примере ниже используется встроенный режим: компенсации регистрируются через $this->addCompensation(). В серверном режиме PHP SDK использует другой способ — $context->saga(), поэтому для него пример нужно адаптировать.

Пример Saga в Laravel: бронирование поездки

Возьмём сценарий из документации Durable Workflow: последовательно бронируем перелёт, отель и автомобиль. После каждого успешного шага регистрируем действие для его отмены.

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


use function Workflow\V2\activity;

use Workflow\V2\Attributes\Type;

use Workflow\V2\Workflow;

#[Type('booking-saga')]

class BookingSagaWorkflow extends Workflow

{

    public function handle(): array

    {

        try {

            $flightId = activity(BookFlightActivity::class);

            $this->addCompensation(

                fn () => activity(CancelFlightActivity::class, $flightId)

            );

            $hotelId = activity(BookHotelActivity::class);

            $this->addCompensation(

                fn () => activity(CancelHotelActivity::class, $hotelId)

            );

            $carId = activity(BookRentalCarActivity::class);

            $this->addCompensation(

                fn () => activity(CancelRentalCarActivity::class, $carId)

            );

            return compact('flightId', 'hotelId', 'carId');

        } catch (\Throwable $e) {

            $this->compensate();

            throw $e;

        }

    }

}

Пример компенсации: отмена брони отеля

В предыдущем примере compensate() не отменяет бронь сам по себе: он вызывает зарегистрированную CancelHotelActivity. Ниже — упрощённая реализация этой activity для локальной базы приложения.

Предположим, что BookHotelActivity создаёт запись HotelReservation со статусом confirmed и возвращает её идентификатор. У модели есть поля id и status. Тогда компенсация может выглядеть так:


<?php

namespace App\Workflows\Activities;

use App\Models\HotelReservation;

use RuntimeException;

use Workflow\V2\Activity;

final class CancelHotelActivity extends Activity

{

    public function handle(int $hotelId): void

    {

        // Меняем статус только у подтверждённой брони.

        $updated = HotelReservation::query()

            ->whereKey($hotelId)

            ->where('status', 'confirmed')

            ->update(['status' => 'cancelled']);

        if ($updated === 1) {

            return;

        }

        $status = HotelReservation::query()

            ->whereKey($hotelId)

            ->value('status');

        // Повторная отмена уже отменённой брони безопасна.

        if ($status === 'cancelled') {

            return;

        }

        throw new RuntimeException(

            "Не удалось отменить бронь отеля: {$hotelId}"

        );

    }

}

Здесь handle() — точка входа activity в embedded Durable Workflow V2; CancelHotelActivity нужно импортировать в файл workflow. Условное обновление не отменит бронь с неожиданным статусом, а повторный вызов для уже отменённой записи завершится без ошибки. Если запись не найдена или её статус не позволяет отмену, activity сообщает об ошибке: её нельзя считать успешно выполненной.

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

Подробнее о структуре activity — в документации Durable Workflow.

Что произойдёт при ошибке

Если бронирование автомобиля завершится ошибкой после успешной брони перелёта и отеля, compensate() сначала вызовет отмену отеля, затем перелёта. Компенсация автомобиля не зарегистрирована: его шаг не вернул успешный результат. При успешном завершении workflow компенсации не вызываются.

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

Сама регистрация не запускает откат автоматически: его явно вызывает код в catch. Отмена также требует работающих обработчиков и достаточного времени на выполнение и повторные попытки.

Что если ответ от внешнего API потерялся

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

Для таких случаев нужен отдельный протокол восстановления: стабильный бизнес-ключ бронирования, идемпотентные повторные запросы, проверка статуса у провайдера и сверка неизвестных результатов. Если API поддерживает безопасную отмену по заранее известному ключу, можно рассмотреть регистрацию компенсации до вызова; она должна корректно обрабатывать отсутствие брони. Это не автоматическая гарантия приведённого примера. Temporal разбирает оба момента регистрации и их последствия.

Как спроектировать надёжные компенсации

Идемпотентность и повторные попытки

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

Практический подход:

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

Порядок и ошибки компенсации

Последовательная отмена в обратном порядке подходит, когда поздние действия зависят от ранних. В embedded-режиме можно включить параллельную компенсацию через setParallelCompensation(true), но только если отменяемые действия действительно независимы. У этого режима есть требования к возвращаемым из замыканий activity; проверьте пример в документации.

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

Тайм-ауты и отмена процесса

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

В embedded Durable Workflow истечение общего workflow timeout закрывает выполнение до того, как код сможет запустить очистку; обычный catch или finally этого не исправляет. Немедленные cancel() и terminate() также не дают окна для компенсации. Для управляемой отмены используйте предусмотренный версией механизма cooperative cancellation и сверяйтесь с документацией Saga.

Конкурентные процессы и события

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

Если участник меняет данные и одновременно должен опубликовать событие, возникает проблема двойной записи: база обновилась, а сообщение не отправилось. Один из вариантов — transactional outbox: сохранить изменение и запись о событии в одной локальной транзакции, а затем отдельно доставить событие. Получатель всё равно должен учитывать возможные дубликаты. Подробности и ограничения описаны в AWS Transactional Outbox.

Детерминированность и наблюдаемость

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

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

Durable Workflow Server 3.0: что изменилось

3 ноября 2026 года команда представила Server 3.0 — отдельный сервер на Rust. По описанию разработчиков, его серверная часть работает одним процессом с базой данных: самостоятельно создаёт схему и обслуживает таймеры и расписания. Поддерживаются SQLite, MySQL, MariaDB и PostgreSQL.

Это упрощает развёртывание серверной части: рядом с ней больше не нужны отдельные Redis, серверный queue worker и scheduler из прежней архитектуры. Но база, резервное копирование, аутентификация и SDK-воркеры приложения по-прежнему нужны. Embedded Laravel при этом остаётся отдельным вариантом и не заменяется Server 3.0.

Для перехода с PHP Server 2.x разработчики описывают необратимую миграцию с предварительной проверкой резервной копии. В релизной статье также отмечены ограничения, включая отсутствие опубликованного подтверждения отказоустойчивости native Server и серверных квот. Перед обновлением проверьте матрицу поддержки и совместимые версии SDK, а миграцию сначала выполните на копии.

Проверка перед внедрением

  • Для каждого шага определены результат, допустимая компенсация и ситуации, когда нужен оператор.
  • Основные действия и компенсации безопасны при повторах; известен способ выяснить результат после потери ответа.
  • Порядок компенсаций соответствует зависимостям; параллельное выполнение используется только для независимых действий.
  • У повторов, ожиданий и компенсаций есть тайм-ауты и достаточный запас времени до общего дедлайна.
  • Конкурентные операции защищены на стороне владельца данных; публикация событий не теряется при двойной записи.
  • Видны состояние процесса и ошибки; есть уведомления, сверка и процедура ручного восстановления.
  • На тестовом окружении проверены отказ каждого шага, ошибка компенсации, повторный вызов, потеря ответа и перезапуск воркера. Изменения workflow проверены на совместимость с уже запущенными процессами.

Итог

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

Durable Execution позволяет продолжать выполнение после сбоев и управлять длительными ожиданиями. В примере статьи этот подход реализован с помощью Durable Workflow, который сохраняет историю и восстанавливает исполнение. Правила возврата платежа, отмены брони и действия при неудачной компенсации остаются ответственностью приложения и команды.

Источники и материалы

Ответить