Распределённые транзакции и Saga

Как выполнить операцию, которая затрагивает три сервиса, если общей транзакции больше не существует.

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

Вернёмся к оформлению заказа. Нужно: создать заказ, списать деньги, зарезервировать товар на складе, поставить в очередь на доставку. Четыре сервиса, четыре базы. В монолите это был бы один BEGIN ... COMMIT: либо всё, либо ничего. Здесь такого рычага нет. И если деньги списались, а товара на складе не оказалось — у вас недовольный клиент и деньги, которые надо возвращать.

Почему двухфазный коммит не применяют

Формально решение существует: 2PC (two-phase commit, двухфазный коммит). Координатор сначала спрашивает всех участников «готовы зафиксировать?» (фаза prepare) и, если все ответили «да», командует «фиксируем» (фаза commit). Атомарность обеспечена. Почему же в микросервисах его почти никто не использует?

  • Блокировки живут всё время транзакции. Между prepare и commit каждый участник держит строки заблокированными. Если операция идёт 300 мс, вы на 300 мс заблокировали склад поперёк всех остальных заказов. Пропускная способность падает в разы.
  • Координатор — единая точка отказа. Он упал между фазами — участники остались в состоянии in-doubt: зафиксировать нельзя, откатить нельзя, транзакция висит и держит блокировки, пока координатор не воскреснет.
  • Половина стека вообще не умеет 2PC. Kafka, большинство NoSQL-хранилищ, любой внешний платёжный шлюз, да и вообще любой HTTP API в двухфазный коммит не играют. Как вы сделаете prepare у платёжной системы?
  • Доступность приносится в жертву. Операция завершится, только если все участники живы и отвечают. Один тормозящий сервис останавливает всех.
Свойство2PCSaga
Атомарностьнастоящая (всё или ничего)достигается компенсациями
Изоляцияестьнет — промежуточные состояния видны
Блокировкина всё время операциитолько внутри одного локального шага
Доступностьпадает с ростом числа участниковвысокая, шаги независимы
Работает с брокером и внешним APIнетда

Ключевую строчку выделим отдельно: Saga даёт ACD без I — атомарность (через компенсации), согласованность и долговечность есть, а изоляции нет. Пока сага идёт, посторонний наблюдатель видит недоделанную картину: заказ уже создан, но ещё не оплачен. С этим придётся жить, и это придётся проектировать явно.

Saga: цепочка локальных транзакций с компенсациями

Разложим наш заказ по шагам. У каждого шага своя локальная транзакция (обычный ACID внутри одной базы) и своё компенсирующее действие.

ШагСервисЛокальная транзакцияКомпенсация
1Ordersсоздать заказ в статусе PENDINGперевести в CANCELLED
2Inventoryзарезервировать 2 шт.снять резерв
3Paymentsсписать 4990вернуть 4990 (refund)
4Deliveryсоздать отгрузку— (pivot: дальше не отменяем)
5Ordersперевести заказ в CONFIRMED— (повторяемый шаг)

Здесь главная мысль урока: компенсация — это не rollback. Откат в базе стирает следы, будто ничего не было. Компенсация — новая бизнес-операция, которая следы оставляет. Деньги уже ушли в банк: «отката» не существует, существует возврат, и он попадёт в выписку клиента отдельной строкой. Письмо «ваш заказ принят» уже улетело: отменить его нельзя, можно только прислать второе — «к сожалению, товара не оказалось».

Шаги саги принято делить на три типа — деление очень практичное:

  • Компенсируемые — их можно отменить (создать заказ, зарезервировать товар, списать деньги).
  • Pivot, точка невозврата — шаг, после которого сага уже не отменяется, а только доводится до конца. Курьер забрал посылку — назад не отыграть.
  • Повторяемые — идут после pivot и могут только успешно завершиться, пусть и с ретраями: отправить чек, начислить бонусы, поменять статус.

Проектируя сагу, ставьте компенсируемые шаги до pivot, а рискованные проверки — как можно раньше. Если товара может не быть, глупо сначала брать деньги: сначала резерв, потом оплата (именно поэтому в таблице выше Inventory идёт вторым, а не третьим). Половина «сложных саг» упрощается простой перестановкой шагов.

Вот скелет оркестратора, который выполняет шаги и откатывает их в обратном порядке при сбое. Запустите — увидите, как деньги возвращаются на карту:

world = {"balance": 10000, "stock": 1, "order": None}

def create_order():
    world["order"] = "PENDING"
    print("  заказ создан:", world["order"])

def cancel_order():
    world["order"] = "CANCELLED"
    print("  заказ отменён:", world["order"])

def charge():
    world["balance"] -= 4990
    print("  списали 4990, на карте:", world["balance"])

def refund():
    world["balance"] += 4990
    print("  ВЕРНУЛИ 4990, на карте:", world["balance"])

def reserve():
    if world["stock"] < 2:
        raise RuntimeError("на складе только %d шт." % world["stock"])
    world["stock"] -= 2

saga = [
    ("create_order",   create_order, cancel_order),
    ("charge_payment", charge,       refund),
    ("reserve_stock",  reserve,      lambda: None),
]

done = []
for name, do, undo in saga:
    try:
        print("ШАГ", name)
        do()
        done.append((name, undo))
    except Exception as e:
        print("СБОЙ", name, "-", e)
        for dname, dundo in reversed(done):   # компенсируем в обратном порядке
            print("КОМПЕНСАЦИЯ", dname)
            dundo()
        break
else:
    print("Сага прошла целиком")

print("Итог:", world)

Результат:

ШАГ create_order
  заказ создан: PENDING
ШАГ charge_payment
  списали 4990, на карте: 5010
ШАГ reserve_stock
СБОЙ reserve_stock - на складе только 1 шт.
КОМПЕНСАЦИЯ charge_payment
  ВЕРНУЛИ 4990, на карте: 10000
КОМПЕНСАЦИЯ create_order
  заказ отменён: CANCELLED
Итог: {'balance': 10000, 'stock': 1, 'order': 'CANCELLED'}

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

Хореография против оркестрации

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

Хореография: каждый слушает события и решает сам

Центрального управляющего нет. Orders публикует OrderCreated — Payments его слышит и списывает деньги, публикует PaymentCompleted — Inventory слышит и резервирует товар, и так далее. При сбое Inventory публикует ReservationFailed, а Payments, услышав это событие, делает возврат.

Orders     --OrderCreated-->       Payments
Payments   --PaymentCompleted-->   Inventory
Inventory  --ReservationFailed-->  Payments (refund) + Orders (cancel)

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

Оркестрация: есть дирижёр

Появляется явный компонент — оркестратор (отдельный сервис Order Saga либо готовый движок вроде Temporal или Camunda). Он хранит состояние саги и рассылает команды: «Payments, спиши», «Inventory, зарезервируй». Участники отвечают, оркестратор решает, что дальше.

state: AWAITING_PAYMENT
  on PaymentCompleted  -> command ReserveStock,   state: AWAITING_STOCK
  on PaymentFailed     -> command CancelOrder,    state: FAILED
state: AWAITING_STOCK
  on StockReserved     -> command CreateShipment, state: AWAITING_SHIPMENT
  on StockUnavailable  -> command RefundPayment,  state: COMPENSATING

Плюс: процесс виден целиком в одном месте — его легко читать, тестировать, менять и мониторить («сколько саг сейчас висит в состоянии COMPENSATING?»). Минус: риск, что оркестратор превратится во всезнающего бога, в котором осядет вся бизнес-логика, а сервисы выродятся в анемичные CRUD-обёртки.

КритерийХореографияОркестрация
Где живёт процессразмазан по сервисамв одном компоненте
Связанностьминимальнаясредняя (оркестратор знает всех)
Отладка и наблюдаемостьтяжело, нужен трейсинглегко, состояние саги лежит в БД
Ветвления и циклыбыстро становятся адомнормально
Когда брать2–4 шага, простая линейная логика5 и больше шагов, ветвления, таймауты, деньги

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

Как это работает

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

Transactional outbox: атомарной записи «в базу и в брокер» не бывает

Классическая ловушка называется dual write: сервис записал заказ в PostgreSQL и следом отправил событие в брокер. Между этими двумя действиями процесс может умереть — и вот заказ есть, а события нет. Сага не начнётся, заказ навсегда останется в PENDING. Лечится так: событие пишут в ту же локальную транзакцию, в таблицу outbox. Отдельный процесс (или CDC-коннектор вроде Debezium) читает эту таблицу и публикует сообщения в брокер. Транзакция одна — значит, факт и событие о нём атомарны.

Идемпотентность: доставка «хотя бы один раз» означает дубли

Брокеры гарантируют at-least-once: одно и то же сообщение может приехать дважды — например, потребитель успел обработать его, но не успел подтвердить. Если обработчик списания не идемпотентен, клиент заплатит два раза. Стандартное лекарство — таблица inbox с уже обработанными message_id: пришло виденное — молча подтверждаем и выходим. Плюс ключ идемпотентности на стороне платёжного шлюза.

Таймауты и зависшие саги

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

Частые ошибки

  • Компенсация тоже может упасть. Возврат денег не прошёл — что делать? Ретраи с экспоненциальной паузой, а после исчерпания попыток — очередь на ручной разбор. Компенсации обязаны быть идемпотентными и, по возможности, «сколько угодно раз повторяемыми».
  • Забыли про отсутствие изоляции. Пока сага идёт, другой процесс видит заказ в PENDING и, скажем, начисляет по нему бонусы. Лечится семантической блокировкой: промежуточный статус явно присутствует в модели, и вся остальная логика обязана его учитывать.
  • Нет идемпотентности. Самая дорогая ошибка в списке: двойное списание у живого клиента. Проверьте каждого потребителя.
  • Dual write без outbox. Работает на демо, теряет события под нагрузкой и при рестартах.
  • Хореография на восемь шагов. Через полгода никто в команде не сможет ответить, что произойдёт при отказе третьего сервиса.
  • Порядок шагов не продуман. Сначала берут деньги, потом проверяют наличие товара — и создают себе поток возвратов на ровном месте.
  • Сага без сохранённого состояния. Оркестратор перезапустился — и все идущие саги исчезли. Состояние саги — это данные, оно живёт в базе.

Итоги

  • 2PC даёт настоящую атомарность, но ценой блокировок, единой точки отказа и несовместимости с брокерами и внешними API — поэтому в микросервисах его почти не используют.
  • Saga — цепочка локальных ACID-транзакций; при сбое ранее выполненные шаги отменяются компенсирующими операциями в обратном порядке.
  • Компенсация — не откат, а новая бизнес-операция: возврат, отмена, письмо-извинение.
  • Изоляции у саги нет: промежуточные состояния видны, их надо явно моделировать (PENDING, RESERVED).
  • Шаги бывают компенсируемые, pivot и повторяемые; рискованные проверки ставьте раньше денежных операций.
  • Хореография — до 3–4 шагов; оркестрация — когда шагов много, есть ветвления, таймауты и деньги.
  • Обязательный минимум для прода: transactional outbox, идемпотентные обработчики, таймауты и мониторинг зависших саг.
Проверьте себя
1. Чем компенсирующая транзакция в Saga принципиально отличается от ROLLBACK в базе данных?
AНичем: это тот же откат, просто выполняется по сети
BОна быстрее, потому что не держит блокировки
CОна не стирает следы, а выполняет новую бизнес-операцию: деньги не «раз-списываются», а возвращаются отдельной проводкой
DОна выполняется автоматически движком СУБД при потере связи с координатором
2. Сервис записал заказ в свою базу, но упал, не успев отправить событие в брокер. Сага не началась. Какой механизм это предотвращает?
AДвухфазный коммит между базой и брокером
BTransactional outbox: событие пишется в ту же локальную транзакцию, а публикует его отдельный процесс
CУвеличение таймаута публикации в брокер
DПовторная отправка события из блока finally