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

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

Ограничения ACID-транзакций в микросервисной архитектуре

ACID - классические свойства локальной транзакции: Atomicity, Consistency, Isolation, Durability (атомарность, согласованность, изолированность, долговечность). В контексте одной базы данных ACID гарантирует, что транзакция является неделимой - либо выполнится целиком, либо откатится без следа, переведя базу из одного согласованного состояния в другое. Однако в микросервисной архитектуре, где данные разделены по множеству сервисов и баз, обеспечить ACID-модель “на всю систему” крайне сложно:

  • Данные физически разъединены: у каждого сервиса своя база, и транзакция, затрагивающая две из них, локальной быть не может. Штатные механизмы СУБД через сетевую границу не работают. Само взаимодействие идёт по сети - REST, gRPC, сообщения, со всеми положенными задержками и обрывами, так что попытка провести связку операций как одну транзакцию упирается в то, что часть операций зафиксирована, а часть нет. Изоляции между сервисами тоже никакой: без общего менеджера транзакций любой сервис может прочитать чужое промежуточное состояние.

Это всё известно. А вот следующее обычно формулируют неверно.

  • Цена координации, а не только CAP-теорема: CAP описывает узкий сценарий - что делать при сетевом разделении: сохранить согласованность и отказать в обслуживании или ответить, рискуя разойтись в данных. Глобальная ACID-транзакция здесь выбирает согласованность. Но основную цену она берёт в штатном режиме, и об этом CAP молчит. Полнее формулирует расширение PACELC: if Partition then A or C, else L or C - при разделении выбираем между доступностью и согласованностью, а в остальное время - между задержкой и согласованностью. Для 2PC вторая половина важнее первой. Два сетевых раунда на каждую транзакцию это задержка, которую платят все операции, даже когда сеть идеальна. Плюс арифметика доступности: если каждый из пяти участников доступен 99% времени, транзакция, требующая согласия всех пятерых, доступна 0,99⁵ ≈ 95% - деградация без единого разрыва связи. Узкое место в высоконагруженной системе возникает не при аварии, а постоянно.

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

Паттерн Saga: распределённые транзакции через компенсирующие действия

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

Мотивация и принцип работы SAGA

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

  1. Шаги идут в заранее заданном порядке, и каждый - обычная локальная транзакция в своём сервисе. Order создал заказ и опубликовал OrderCreated, Payment по этому событию списал деньги, Inventory зарезервировал товар. Пока всё проходит, сага просто движется вперёд.

  2. Ломается это на первом отказе. Если Inventory отвечает, что товара нет, откатывать нечего: обе предыдущие транзакции давно закоммичены. Вместо отката запускаются компенсации - Payment возвращает деньги, Order переводит заказ в «отменён». Компенсация при этом такая же транзакция, как всё остальное, просто делает обратное.

Сага оформления заказа: прямой путь и компенсация
Сага оформления заказа: прямой путь и компенсация

Такой результат часто называют «атомарностью саги». Точнее говорить, что сага даёт ACD без I: атомарность имитируется компенсациями, согласованность и долговечность обеспечивают локальные транзакции, а изоляции нет вовсе. Отката «без следа» не происходит - компенсация это новая транзакция, выполняющая обратное действие, и промежуточное состояние успевает стать видимым остальным. Отменённый заказ остаётся в истории со статусом «отменён», а не исчезает.

Три типа шагов: compensatable, pivot, retriable

Шаги саги не равнозначны, и до того как писать компенсации, их нужно разложить на три категории.

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

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

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

Отсюда правило, по которому проектируется любая сага: порядок шагов должен быть compensatable - pivot - retriable, и никак иначе. Если некомпенсируемый шаг оказался в середине, сага сломана на уровне замысла, при сбое на следующем шаге откатить его будет нечем.

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

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

И обратное требование к retriable-шагам: они не имеют права отказать по бизнес-причине. Если шаг после pivot может ответить «нельзя» он не retriable, и сага спроектирована неверно. Повторять можно сетевую ошибку и недоступность сервиса, но не отказ по правилам предметной области.

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

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

Логика последовательности должна где-то жить, и вариантов ровно два.

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

  • При хореографии сценария не существует нигде, есть только реакции. Order Service публикует OrderCreated, Payment Service подхватывает и отвечает PaymentApproved или PaymentFailed, Order Service слушает и решает, подтверждать заказ или отменять. Центрального компонента нет, зато чтобы понять, что вообще происходит при оформлении заказа, придётся открыть четыре репозитория.

Одна и та же сага: слева логика собрана в одном месте, справа размазана по сервисам
Одна и та же сага: слева логика собрана в одном месте, справа размазана по сервисам

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

Чем за это платят

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

Теперь счёт, и он довольно длинный.

Компенсацию нужно написать для каждого компенсируемого шага, и это работа не механическая: придётся решить, что делает система, если шаг X прошёл, а шаг Y упал. «Отменить платёж» - задача с бизнес-содержанием, особенно когда деньги уже списаны.

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

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

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

Изоляции нет: чем это оборачивается на практике

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

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

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

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

Общее у всех трёх одно: ни одна не проявляется в тестах на счастливый путь и ни одна не даёт ошибки в логах.

Контрмеры

Набор приёмов здесь давно устоялся, и выбирать из них приходится осознанно, универсального решения нет.

Семантическая блокировка: Запись помечается флагом «в процессе» - статус PENDING, PROCESSING, RESERVED. Все, кто её читает, обязаны этот флаг учитывать: подождать, отказать пользователю или показать данные с оговоркой. Это самая частая контрмера, но у неё есть цена, о которой обычно забывают: нужно решить, что делает читатель при виде флага, и обязательно нужен тайм-аут, иначе упавшая сага оставит запись заблокированной навсегда.

Коммутативные обновления: Операции, для которых порядок не важен: balance = balance - 100 вместо balance = 900. Тогда потерянное обновление невозможно в принципе, а компенсация становится тривиальной - прибавить обратно. Самый дешёвый приём из всех: он ничего не требует, кроме того чтобы писать дельты вместо абсолютных значений.

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

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

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

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

С чего начинать

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

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

Пример: сага для оформления заказа

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

Сага задаётся списком шагов. У каждого шага есть действие, компенсация и тип - тот самый compensatable / pivot / retriable, о котором шла речь выше. Тип решает, что произойдёт при сбое.

// Шаг = действие + компенсация + тип. 
// Тип решает, что движок сделает при сбое
List<SagaStep> ORDER_SAGA = List.of(

    step("create-order", COMPENSATABLE,
         ctx -> ctx.put("orderId", orders.create(ctx.sagaId(), ctx.payload())),
         ctx -> orders.cancel(ctx.sagaId(), ctx.getLong("orderId"))),

    step("authorize-payment", COMPENSATABLE,
         ctx -> ctx.put("authId", payments.authorize(ctx.sagaId(), ctx.amount())),
         ctx -> payments.releaseHold(ctx.sagaId(), ctx.get("authId"))),

    // деньги ушли - откат больше невозможен
    step("capture-payment", PIVOT,        
         ctx -> payments.capture(ctx.sagaId(), ctx.get("authId")),
         ctx -> { throw new UnsupportedOperationException("pivot не компенсируется"); }),

    // письмо не ушло - не повод возвращать деньги
    step("send-confirmation", RETRIABLE,
         ctx -> notifications.confirm(ctx.sagaId(), ctx.getLong("orderId")),
         ctx -> { throw new UnsupportedOperationException("retriable только повторяют"); })
);

Обратите внимание на платёж. Авторизация компенсируема - банк просто снимает удержание, деньги никуда не уходили. Списание компенсировать нечем, поэтому оно и объявлено точкой невозврата, а всё, что после него, обязано быть retriable. Из-за этого сбой при отправке письма приведёт к новой попытке отправки, а не к отмене оплаченного заказа.

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

// Один такт: взять сагу, выполнить очередной шаг, зафиксировать прогресс
void advance(SagaRecord saga) {
    SagaStep step = steps(saga).get(saga.step());
    try {
        // сетевой вызов, Idempotency-Key = sagaId
        step.execute(ctx);           
        // локальный коммит: номер шага + контекст
        repo.save(saga.advanced(ctx));

        // Если процесс умрёт между этими двумя строками, шаг выполнится повторно
        // Это не баг, а контракт: поэтому execute обязан быть идемпотентным

    } catch (TransientFailure e) {
        // таймаут, 5xx, обрыв — повторить позже
        repo.save(saga.retryLater(backoff(saga.attempts()), e));
    } catch (BusinessRejection e) {
        // «нельзя» — откатываться, но не дальше pivot
        repo.save(saga.startCompensation(e));
    }
}

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

За кадром осталось немало: обратный проход по компенсациям, эскалация в ручной разбор, когда компенсация не проходит после N попыток, дедупликация на принимающей стороне, дедлайн саги целиком. Но именно эта обвязка и составляет основную массу кода, а вовсе не бизнес-логика. Ровно её берут на себя готовые решения: workflow-движки Camunda, Temporal и Cadence, транзакции долгого выполнения (LRA в MicroProfile), Seata в режиме SAGA. Внутри у них то же самое: последовательность шагов, компенсации и надёжно сохранённый прогресс. Вопрос только в том, пишете вы это сами или получаете готовым.

Двухфазный коммит (Two-Phase Commit, 2PC)

Сага мирится с тем, что промежуточное состояние видно. 2PC не мирится: он координирует несколько узлов через центрального координатора так, чтобы наружу вышел только конечный результат - либо все зафиксировали, либо все откатили.

Принцип работы 2PC

Участников может быть двое или больше - сервисы, базы, что угодно, умеющее prepare и commit. Над ними ставится координатор, и дальше всё идёт в два раунда.

  1. Фаза подготовки (prepare phase, фаза голосования): Координатор рассылает всем участникам запрос на подготовку к коммиту. Каждый участник локально выполняет свою часть работы транзакции (например, делает необходимые изменения в своей БД) но не фиксирует их, а помечает как “готовые к коммиту” (в базах данных это обычно означает записать изменения в журнал и заблокировать ресурсы). После этого участник отвечает координатору либо “готов” (Yes), если его этап прошёл успешно и он готов зафиксировать, либо “не могу” (No), если произошла ошибка и он не сможет закоммитить. Все участники по сути голосуют “за” или “против” общей фиксации.

  2. Фаза фиксации (commit phase): Координатор собирает ответы. Если все участники ответили "готов/Yes", то координатор посылает команду Commit всем участникам. Каждый участник получает эту команду и выполняет фиксацию своих изменений (окончательно применяет их в своей системе). Если хотя бы один участник ответил отказом ("No"), либо не ответил из-за сбоя, координатор посылает команду Rollback всем тем, кто был готов. Либо все commit, либо все rollback - третьего исхода протокол не допускает.

Две фазы 2PC: блокировки удерживаются всё время между ними
Две фазы 2PC: блокировки удерживаются всё время между ними

После завершения второй фазы координатор может сообщить инициатору (например, приложению, начавшему транзакцию), что транзакция успешно выполнена или откатилась.

Полная реализация координатора заняла бы несколько сотен строк, но весь протокол держится на трёх местах. Их и покажем.

Первое что делает координатор, собрав голоса:

// Фаза 1: голосование
boolean allReady = participants.stream().allMatch(p -> p.prepare(txId));
Decision decision = allReady ? COMMIT : ABORT;

// Ключевая строка всего протокола: решение попадает в журнал ДО рассылки.
// Упади координатор сразу после неё — при старте он прочитает журнал
// и доведёт транзакцию до конца. Без fsync здесь смысла нет.
log.writeDecision(txId, decision);

// Фаза 2: участники уже проголосовали «да» и обязаны подчиниться.
// Ошибка на этом этапе — не повод откатывать, а повод повторять.
for (Participant p : participants) {
    retryUntilSuccess(() -> p.apply(txId, decision));
}

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

Что происходит на стороне участника:

boolean prepare(TxId id) {
    // единственный момент, когда можно сказать «нет»
    if (!canCommit()) return false;
    // изменения на диске, но никому не видны
    writeUndoRedoLog(id);  
    // вот эти блокировки и повиснут
    lockRows(id); 
    // состояние переживёт перезапуск самого участника
    markInDoubt(id);
    // после этого отказаться уже нельзя
    return true;
}

Строка markInDoubt это и есть та самая подвешенная транзакция, о которой шла речь выше. Участник записал изменения, удерживает блокировки и физически не может принять решение сам: он не знает, чем закончилось голосование у остальных. Любая XA-совместимая СУБД показывает такие транзакции в системном представлении, и разбирать их приходится либо вернувшемуся координатору, либо администратору вручную. Ручное разрешение называется эвристическим решением и опасно ровно тем, что администратор может выбрать не то, что выбрал координатор: тогда одна часть системы закоммитит, а другая откатит - то самое нарушение атомарности, ради предотвращения которого протокол и затевался.

Участники проголосовали, решения нет - вот та самая блокировка 2PC
Участники проголосовали, решения нет - вот та самая блокировка 2PC

Восстановление: три строки, которые объясняют, зачем нужен был журнал:

// При старте координатора
for (Tx tx : log.unfinished()) {
    // решения в журнале нет - откат
    Decision d = tx.decision().orElse(ABORT);
    for (Participant p : tx.participants()) retryUntilSuccess(() -> p.apply(tx.id, d));
}

Логика простая: если решение успело записаться доигрываем его, если нет - значит, координатор упал до точки невозврата, и безопасно откатить. Внутри retryUntilSuccess живут тайм-ауты, экспоненциальный backoff и предел попыток, после которого зовут человека, их я оставил за скобками, но именно там прячется цена протокола.

Отсюда же видно, где у 2PC настоящее слабое место. Не в двух фазах и не в блокировках, а в том, что журнал решения существует в единственном экземпляре. Пока координатор лежит, участники ждут, и сделать они ничего не могут. Ровно эту проблему и решают распределённые СУБД, реплицируя журнал координатора через Raft или Paxos: копий решения становится несколько, потеря узла перестаёт быть фатальной, и протокол из ненадёжного превращается в рабочий.

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

Применение 2PC и поддержка инструментов

Протокол 2PC широко применялся в традиционных корпоративных приложениях, особенно до эры микросервисов. Типичные места, где встречается 2PC:

  • Распределённые реляционные БД: Например, транзакция затрагивает две разные базы данных (две СУБД или два соединения). В Java для этого есть JTA (Java Transaction API) и XA-драйверы - координатор (Narayana, Atomikos и др.) обеспечивает двухфазный коммит между базами.

  • Комбинация база данных + очередь сообщений: классический кейс записать данные в БД и отправить сообщение так, чтобы не вышло «записали в базу, но сообщение потеряли» или наоборот. Если брокер умеет быть XA-ресурсом, его можно включить в одну глобальную транзакцию с базой. Умеют это далеко не все: XA поддерживают ActiveMQ (Classic и Artemis), IBM MQ, Oracle AQ и другие JMS-провайдеры, реализующие XAConnectionFactory. А вот два самых популярных сегодня брокера: Kafka и RabbitMQ - XA-ресурсами не являются. У Kafka есть собственные транзакции продюсера, но они атомарны только внутри Kafka и о вашей базе ничего не знают, у RabbitMQ есть транзакции канала AMQP, которые тоже не XA. Для типичного современного стека этот путь просто закрыт и именно отсюда растёт паттерн Outbox, к которому мы вернёмся ниже.

  • Классические монолитные системы с несколькими ресурсами: Транзакция, обновляющая несколько субсистем (например, две БД, или БД и файловую систему), также могла координироваться через 2PC.

Для реализации 2PC необходима поддержка со стороны всех участников. Каждый ресурс должен обладать интерфейсом приготовления/фиксации. В мире реляционных БД это стандарт XA, в NoSQL-хранилищах или пользовательских сервисах такой поддержки часто нет. Поэтому в чистых микросервисах, где сервисы гетерогенны, самостоятельно реализовывать 2PC сложно - нужно либо писать слой-адаптер для каждого (чтобы сервисы умели принимать команды prepare/commit), либо ограничиться теми ресурсами, что уже поддерживают XA. Существуют готовые менеджеры транзакций (координаторы) - упомянутые AtomikosNarayanaBitronix и пр., которые можно встроить в приложение и сконфигурировать ресурсы для участия в глобальной транзакции. Но это тянет за собой серьезные ограничения, о которых ниже.

Почему его почти не берут между сервисами

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

  • Доступность падает мультипликативно - арифметику мы уже считали выше. Практическое следствие: недоступен один участник, и операция не состоится целиком. Сага в такой ситуации может подождать, пока сервис вернётся, или уйти в компенсации. У 2PC такого выбора нет: он либо собирает все голоса, либо отменяет транзакцию.

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

2PC остаётся полезным там, где участников мало и они однородны. Типичный пример это перевод денег между счетами, которые лежат в одной шардированной базе, но на разных шардах. Сага здесь плоха не сложностью кода, а семантикой: между списанием и зачислением есть окно, когда денег нет ни на одном счёте, и отчёт, снятый в этот момент, покажет неверный итог по системе. А условия, которые губят 2PC между микросервисами, тут не выполняются: участников ровно два, они однородны, стоят в одном кластере, блокировка держится микросекунды. Поэтому шардирующие слои вроде Citus делают межшардовые записи именно двухфазным коммитом через штатные PREPARE TRANSACTION и COMMIT PREPARED в PostgreSQL.

И это только прикладной уровень. Внутри слоя хранения 2PC живее всех живых: Google Spanner, CockroachDB, TiDB, YugabyteDB выполняют распределённые транзакции двухфазным коммитом, просто поверх консенсуса. Это лечит главную болезнь протокола: классический 2PC блокируется, когда падает координатор с единственной копией решения, если же журнал координатора реплицирован через Raft или Paxos, решение переживает потерю узла и участники всегда могут узнать исход. Добавьте однородных участников, один кластер, RTT в доли миллисекунды и короткие блокировки - и протокол, невыносимый между микросервисами, оказывается вполне рабочим.

Отсюда практический вывод: не пишите 2PC сами - получите его вместе с базой. Если инвариант действительно требует строгой атомарности, дешевле положить связанные данные в одну распределённую СУБД, которая делает 2PC за вас, чем городить XA-координатор поверх собственных сервисов.

Сравнение Saga и 2PC

Задача у них одна, а расходятся они в четырёх местах:

Критерий

Saga (Сага)

2PC (двухфазный коммит)

Что видно снаружи, пока операция идёт

Промежуточное состояние видно всем: заказ создан, деньги не списаны. Это надо описывать в контракте сервиса.

Не видно ничего: наружу выходит только конечный результат.

Что требуется от участников

Ничего, кроме локальных транзакций. Любой сервис, любая база.

Поддержка XA. Kafka, RabbitMQ и большинство REST-сервисов участниками быть не могут.

Что происходит при сбое

Компенсации, которые пишете вы. Отката «без следа» нет, промежуточные эффекты остаются в истории.

Централизованный откат. Если сбой после фазы подготовки - транзакция висит, пока не вернётся координатор или администратор.

Где уместен

Длинные бизнес-процессы: заказы, биллинг, бронирование. Там, где шаги компенсируемы, а секунда задержки никого не убивает.

Участники однородны, их немного, стоят в одном кластере: межшардовые записи, распределённые СУБД, несколько баз под одним XA-координатором.

Альтернативные подходы и паттерны

Saga и 2PC - два полюса, но между ними есть промежуточные варианты, и на практике чаще встречаются именно они:

Протокол TCC (Try-Confirm-Cancel)

Клиент бронирует перелёт и отель одним пакетом. Билет выписался, свободных номеров не оказалось - и теперь надо возвращать билет, который уже продан. TCC (Try-Confirm-Cancel) закрывает такие случаи тем, что до последнего момента ничего не продаёт: сначала всё резервируется (Try), и только когда резервы есть у всех участников, приходит подтверждение (Confirm) или отмена (Cancel).

Как работает TCC: Представим бронирование авиабилета и отеля единым пакетом. В модели TCC шаги будут такими:

  • Try: Сервис билетов получает запрос на бронь и предварительно резервирует место (не подтверждая окончательно продажу). Параллельно сервис отелей резервирует номер. Эти “Try” шаги выполняются для всех участвующих сервисов - они занимают необходимые ресурсы, помечая их как занятые, но транзакция пока не считается завершенной.

  • Confirm: Если все сервисы успешно выполнили Try и готовы завершить, то отправляется команда Confirm каждому - билеты выписываются окончательно, бронь отеля подтверждается. Каждый сервис совершает окончательное изменение.

  • Cancel: Если какой-то из Try-шагов не удался (или один из сервисов ответил, что не может выполнить), то всем сервисам, уже сделавшим Try, шлётся команда Cancel - они отменяют ранее сделанные резервирования (освобождают места, деньги не списывают и т.д.).

Те же две фазы, что у 2PC, но держат их резервы с TTL, а не блокировки строк
Те же две фазы, что у 2PC, но держат их резервы с TTL, а не блокировки строк

Наружу это выглядит атомарно: либо все Confirm прошли, либо всё зарезервированное отменилось на Cancel. Это похоже на 2PC (Try - аналог prepare, Confirm/Cancel - commit/rollback), но различие не в наличии координатора: в реальных реализациях он есть - в Seata за TCC-режим отвечает тот же Transaction Coordinator, что и за остальные режимы. Отличается другое: кто реализует фазы и что удерживается между ними.

Кто реализует фазы: в XA/2PC prepare и commit реализует менеджер ресурса - сама СУБД, приложение о них не знает. В TCC три метода пишет разработчик: Try, Confirm, Cancel - обычные методы сервиса, вызываемые по обычному HTTP или gRPC. Отсюда главное преимущество: участником может стать любой сервис, умеющий три HTTP-метода.

Какие блокировки удерживаются: в 2PC между prepare и commit висит физическая блокировка строк, и держит её транзакция, открытая через сеть. В TCC шаг Try коммитится локально и сразу - «блокировка» становится семантической: строка резерва со статусом и временем жизни. Физических блокировок между сервисами нет, а зависший резерв снимается по тайм-ауту, а не ждёт администратора.

По отношению к Saga TCC - это сага, у которой промежуточные эффекты не видны наружу: до Confirm заказ не оформлен и деньги не списаны, есть только резерв.

TCC - компромисс: строгость 2PC силами приложения и без XA. Платят за это так.

Контракт сервиса вырастает втрое. Вместо одного метода - Try, Confirm, Cancel, и двухфазность приходится тащить внутрь локальной логики. Сервис бронирования отеля теперь умеет держать номер зарезервированным, помечать его занятым и оплаченным, снимать бронь: три состояния там, где раньше было два.

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

Идемпотентность - та же, что в саге, только теперь для трёх методов вместо двух. Повторный Confirm не должен ломать уже подтверждённый резерв, повторный Cancel - уже отменённый.

Зато отказ виден рано: если ресурс недоступен на Try, до реального изменения дело не доходит вовсе, компенсировать нечего.

Главное же ограничение в том, что резервируется не всё. Бронирование, удержание средств на карте, выделение мощностей - да. Отправку письма или запись в лог зарезервировать нельзя, их придётся делать сагой с компенсацией или игнорировать при отмене.

На практике TCC реализуют либо вручную, либо готовыми решениями: TCC-режим есть в Seata (наряду с AT, XA и SAGA), в ByteTCC, а Atomikos поддерживает его в коммерческой ExtremeTransactions - под тем же названием Try-Confirm/Cancel. Устоявшегося синонима у паттерна нет, в литературе он так и называется TCC.

Паттерн Outbox (Transactional Outbox)

Проблема двойной записи (dual-write) - одна из самых распространённых в микросервисах: как гарантировать, что действие в локальной базе данных и отправка события/сообщения в другой сервис произойдут атомарно? Например, Order Service сохранил заказ в своей БД и должен отправить событие OrderCreated в Kafka для других сервисов. Если запись в БД прошла, а отправка сообщения не удалась - данные уже изменились, а другие сервисы об этом не узнают. И наоборот, если сообщение ушло, а база не сохранилась - другие узнают о заказе, которого нет. Классический 2PC между БД и брокером бывает недоступен. Паттерн Outbox решает эту проблему, гарантируя, что база и сообщение остаются синхронизированы.

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

  1. Локальная транзакция: В сервис поступает запрос (например, создать заказ). Сервис открывает транзакцию к своей БД. В ней он выполняет обычные изменения (создает запись заказа) и параллельно вставляет запись в таблицу Outbox - например, JSON с информацией о событии OrderCreated, которое надо отправить, и статус “новое”. Затем транзакция коммитится. Если по каким-то причинам база не сохранилась - соответственно, ни заказ, ни событие в Outbox не записались. Если коммит успешен - и заказ, и запись о сообщении надежно в базе.

  2. Отправка из Outbox: Отдельный компонент - назовём его Outbox Processor - периодически считывает новые записи из таблицы Outbox. Для каждой записи он осуществляет реальную отправку сообщения в брокер или вызывает внешний сервис. После успешной отправки помечает запись Outbox как отправленную. Эта операция тоже транзакционная локально в базе.

Заказ и событие попадают под один коммит - распределённой транзакции здесь нет
Заказ и событие попадают под один коммит - распределённой транзакции здесь нет

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

Приём сводится к одной сделке: вы получаете гарантию доставки и платите за неё дубликатами. Событие уйдёт обязательно - оно лежит в базе и дождётся, пока брокер поднимется. Но уйдёт как минимум один раз, а не ровно один: если процессор упал после отправки и до отметки «отправлено», при перезапуске он отправит снова. Значит, на принимающей стороне обязательна дедупликация по идентификатору сообщения - Inbox-таблица или уникальный индекс. Строгого exactly-once здесь не получить, и закладываться на него не стоит.

Остальное - эксплуатация. Таблица растёт, её надо чистить и следить за индексами: внутри сервиса заводится собственная маленькая очередь со всеми обязанностями очереди. Отправка идёт с задержкой от миллисекунд до секунд, в зависимости от частоты опроса или настроек CDC.

Писать процессор самому не обязательно: Debezium читает журнал транзакций и публикует события в Kafka. Цена - отдельная инфраструктура: Kafka Connect с коннектором, доступ к WAL или binlog с правами репликации, а для outbox-таблицы обычно ещё SMT-роутер, раскладывающий записи по топикам. «Без кода» здесь не означает «без эксплуатации».

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

Другие техники: eventual consistency, транзакционные сообщения

Ещё несколько вещей, которые всплывают в любом разговоре про распределённые транзакции:

  • Модель BASE и eventual consistency: Противопоставляется ACID. Basically Available, Soft state, Eventually consistent - принцип, широко используемый в распределённых системах: система всегда доступна для работы, но допускает, что данные могут временно расходиться, а согласованность достигается “в итоге”. Saga - частный случай eventual consistency. Другие примеры: Event Sourcing (когда состояние вычисляется из потока событий), CQRS (раздельные модели команд и запросов, синхронизация через события). В микросервисах немедленная консистентность нужна не всегда, часто достаточно, что через секунду-другую все сервисы сойдутся на одних данных

  • Transactional messaging (транзакционные сообщения): термин для разных способов обеспечить атомарность между отправкой сообщения и изменением состояния. Outbox - один из них. Другой подход - использовать сам брокер как хранилище состояния: послали команду в топик и считаем операцию завершённой только после подтверждения обработки на той стороне. Собственные транзакции есть и у брокеров, но, как уже говорилось, они атомарны внутри брокера и вашу базу не охватывают, поэтому проблему двойной записи сами по себе не решают. Есть шаблон Transactional Inbox/Outbox, когда на принимающей стороне входящие сообщения тоже пишутся в локальную таблицу и обрабатываются атомарно с локальной транзакцией сервиса - по сути outbox на обоих концах.

  • Коммерческие распределённые транзакции: Исторически существуют продукты, которые позволяют реализовать двухфазный коммит между разнородными системами. Например, Atomikos - популярный менеджер транзакций для Java, позволяющий включать в одну JTA-транзакцию несколько ресурсов (баз, очередей). IBM MQ и IBM TX Series - примеры промышленного решения для распределённых транзакций. Эти решения работают, но как отмечалось, в микросервисах применяются редко из-за сложности и требований к участникам.

  • Трёхфазный коммит (3PC): добавляет между голосованием и фиксацией третью фазу, чтобы участники могли завершить транзакцию без координатора. Неблокирующим он оказывается только в модели с честными отказами узлов и без сетевых разделений, при разделении 3PC ломает уже не доступность, а корректность - две части кластера способны принять противоположные решения. Поэтому он и не прижился: координатора сделали отказоустойчивым репликацией решения (Paxos Commit, Gray и Lamport, 2006).

  • Решение на уровне инфраструктуры: связанные данные кладут в одну распределённую СУБД, которая сама выполняет распределённый коммит (см. выше про 2PC поверх консенсуса). Консистентность предоставляет база, и это, пожалуй, самый дешёвый способ получить строгую атомарность там, где она действительно нужна.

Практические рекомендации: как выбрать подход

Дальше то, что я проверяю по порядку, когда в проекте появляется первая операция на два сервиса.

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

Что клиент получает в ответ: Этот вопрос решает больше, чем все остальные. Если API может ответить «принято, статус спросите позже» - подходит сага, и дальше речь только о компенсациях. Если клиенту нужен окончательный ответ в том же запросе, сага отпадает: промежуточное состояние придётся показать, а показывать его нечем. Остаётся TCC с резервами или переезд связанных данных в одну базу.

Кто участники: Если среди них Kafka, RabbitMQ или чужой сервис по HTTP - вопрос про 2PC снят, XA там нет и не появится: остаётся Outbox для доставки и сага для процесса. Если участники свои, однородные и стоят в одном кластере - стоит посмотреть, не решается ли задача переездом в распределённую СУБД, которая сделает двухфазный коммит сама.

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

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

Что нужно при любом ответе

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

Порядок вопросов при выборе подхода
Порядок вопросов при выборе подхода

Распределённая транзакция видна снаружи. Клиент получает «платёж обрабатывается» вместо «оплачено» и должен уметь спросить статус позже. Это решение принимается при проектировании API, а не при выборе библиотеки. 2PC прячет сложность в инфраструктуру и работает, пока участники однородны и стоят рядом. Saga выносит её в код и в бизнес-логику: писать приходится больше, зато видно, что происходит при сбое и кто за это отвечает. Хуже всего третий вариант - считать, что сложности нет, и обнаружить её в проде, когда деньги списались, а заказ не создался.