Distributed Transactions¶
Распределенные транзакции — это не одна транзакция побольше, а проблема координации изменений между несколькими независимыми ресурсами: разными БД, разными сервисами, БД + брокером сообщений.
Как правильно думать о проблеме?
Пока все происходит внутри: одной БД; одного connection; одного transaction manager -- мы живем в
мире обычной локальной транзакции. Там все относительно просто: BEGIN + несколько SQL-операций +
COMMIT или ROLLBACK.
Но в распределенных транзакциях локальный rollback уже не спасает, потому что:
у каждого сервиса своя БД, чужой COMMIT уже мог произойти, внешняя система может быть недоступна,
сообщение может отправиться дважды, письмо уже могло уйти пользователю, часть шагов может быть
необратимой.
ACID
межсервисная транзакция почти никогда не равна одной ACID-транзакции в духе одной БД.
в распределенных транзакциях нет ACID
на практике в микросервисах очень часто делают не что-то одно, а связку:
- локальная транзакция в сервисе
- Outbox для надежной публикации событий
- Kafka или другой брокер как транспорт
- Saga для межсервисного процесса
- Inbox / dedup / идемпотентность на стороне consumer
Это не дает магической глобальной ACID-транзакции. Зато дает более реалистичную и масштабируемую модель.
Главный конфликт¶
Хочется получить сразу все:
- строгую глобальную атомарность;
- простую реализацию;
- высокую доступность;
- слабую связанность сервисов;
- хорошую масштабируемость;
- минимальную latency.
На практике так не бывает. Приходится выбирать tradeoff. Отсюда и появляются разные подходы:
2PC / XA— максимум атомарности, но высокая связанность и тяжелая координация;Saga— координация через локальные транзакции и компенсации;Outbox— надежная публикация событий без dual write;Inbox / dedup / idempotency— защита от дублей и повторной доставки;eventual consistency— сознательная модель, а не случайная ошибка.
Что почти всегда нужно рядом¶
Как только речь идет о distributed transactions, рядом почти всегда появляются:
- idempotency
- retry
- deduplication
- Inbox
- correlation id
- trace id
- saga id
- timeout handling
- observability
- audit trail
Почему: - сообщения могут прийти повторно - шаг может завершиться, а ack потеряться - consumer может упасть после обработки, но до commit - compensation тоже может дать ошибку - без трассировки очень трудно понять, где завис процесс
- дубликаты;
- переотправка;
- потерянный ack;
- зависший процесс;
- частично выполненная компенсация;
- рассинхрон между БД и брокером;
- промежуточные бизнес-состояния.
Проблемы и Чем их решают¶
- Нет одного общего атомарного commit между сервисами -> либо 2PC / XA, либо отказ от глобальной атомарности и переход к Saga.
dual write problem(БД сохранили, событие не отправили или наоборот) -> Transactional Outbox.- Нет изоляции между параллельными workflow / saga -> Конкуренция и отсутствие изоляции,
optimistic locking,version check,semantic lock,reservation status,per-key serialization. - Нужно сначала зарезервировать ресурс, а потом либо подтвердить, либо отменить -> TCC / Try-Confirm-Cancel.
- Дубликаты сообщений и повторная доставка -> Idempotency, Inbox, processed_messages, unique constraints.
- Частично выполненный бизнес-процесс -> Saga, compensating actions, явные статусы процесса.
- Компенсация может быть неидеальной или необратимой -> compensating actions, проектирование компенсаций как отдельных бизнес-операций.
- Временная рассинхронизация между сервисами -> Eventual consistency, промежуточные статусы (
PENDING,FAILED,COMPENSATING). - Write уже принят, а UI / projection еще показывает старое состояние -> Stale Reads / Read-Your-Own-Writes / Lagging Read Model.
- Зависшие процессы и шаги без ответа -> Timeout handling, Retry policy, monitoring, manual intervention flow.
- Повторные ошибки при retry -> делать retry только вместе с Idempotency, Retry policy, backoff и лимитом попыток.
- Автоматические retry / timeout / compensation не довели процесс до конца -> Reconciliation / Repair / Manual Recovery.
- Потеря понимания, где именно сломался flow -> Observability,
correlation_id,trace_id,saga_id, audit trail. - Сильная связанность и блокировки при глобальной координации -> не тащить 2PC / XA туда, где лучше работают Saga + Transactional Outbox.
- Невозможно честно откатить внешний side effect -> использовать refund/cancel/release как compensating actions и заранее признавать необратимые действия.
- Проблемы порядка событий -> Partition key и порядок событий.
2PC / XA¶
Есть coordinator и несколько участников. Сначала все говорят "готов commitить", а потом coordinator
говорит всем либо COMMIT, либо ROLLBACK.
Плюсы: строгая атомарность между участниками.
Минусы: блокирующий протокол; тяжелая координация; выше latency; хуже availability; плохо ложится на микросервисы и внешние системы.
Подробнее: 2PC / XA
Saga¶
Нет одного общего глобального commit. Есть цепочка локальных транзакций в разных сервисах. При ошибке выполняются компенсирующие действия.
Плюсы: лучше подходит для микросервисов; хорошо сочетается с асинхронностью; нет тяжелого XA coordinator на весь процесс.
Минусы: логика сложнее; нет мгновенной глобальной консистентности; нужны компенсации, ретраи, таймауты, наблюдаемость.
Подробнее: SAGA
TCC / Try-Confirm-Cancel¶
TCC (Try-Confirm-Cancel) — это паттерн координации, в котором операция делится на три фазы:
Try— временно зарезервировать ресурс;Confirm— окончательно подтвердить операцию;Cancel— отменить резерв.
Это полезно там, где нельзя сразу делать финальный commit, потому что сначала надо понять, смогут ли успешно завершиться остальные шаги процесса. Типичные примеры: hold денег или лимита; резерв товара; hold слота, квоты или capacity.
TCC похож на Saga, потому что тоже работает без глобального XA commit, но здесь акцент именно на модели "сначала временно hold, потом либо confirm, либо cancel". Это сильный паттерн для денег, лимитов и inventory hold, где обычная компенсация может быть слишком грубой или поздней.
Плюсы: явно моделирует резерв и финализацию; хорошо подходит для ресурсов, которые естественно
умеют hold/confirm/cancel; уменьшает риск преждевременного финального commit.
Минусы: требует от каждого участника поддержки трехфазной бизнес-семантики; усложняет API и
состояния; Try, Confirm и Cancel тоже должны быть идемпотентными и наблюдаемыми.
Transactional Outbox¶
Не пытаться одновременно "сохранить в БД" и "отправить в брокер", а в той же локальной транзакции,
что и бизнес-данные, записать событие в outbox таблицу бд, потом отдельный publisher или CDC
публикует событие наружу.
Плюсы: решает проблему dual write.
Минусы: публикация обычно at least once; дубликаты все равно возможны; downstream должен быть
идемпотентным.
Подробнее: Transactional Outbox
Eventual consistency¶
Eventual consistency — это модель, в которой разные части системы могут быть временно несогласованы, но через некоторое время приходят к правильному итоговому состоянию. Она появляется потому, что в распределенной системе каждый сервис живет со своей БД, шаги выполняются цепочкой локальных commit, между сервисами общение идет через события, очереди, HTTP, часть шагов ретраится, а часть компенсируется. Поэтому нельзя ожидать, что все куски системы обновятся в один и тот же момент, одним глобальным commit и без промежуточных состояний.
Это не обязательно ошибка. Это может быть нормальное состояние процесса: заказ уже в PENDING,
следующее событие еще в пути, consumer еще не обработал сообщение, retry еще не закончился,
orchestration еще не перевела процесс в следующий шаг. То есть система может быть временно
несогласованной, но логически оставаться корректной.
Если система работает в этой модели, нужно явно проектировать: - промежуточные статусы; - allowed transitions между статусами; - timeout policy; - retry policy; - compensation rules; - финальные успешные и неуспешные состояния.
Примеры статусов:
- PENDING
- RESERVED
- CONFIRMED
- FAILED
- COMPENSATING
- CANCELLED
Проблема начинается не в том, что состояния расходятся на короткое время, а в том, что нет таймаутов, ретраев, мониторинга, понятной финальной модели состояния, непонятно, кто должен завершить зависший процесс, а UI и другие сервисы не умеют жить с промежуточными статусами.
Конкуренция И Отсутствие Изоляции¶
Одна из самых неприятных проблем distributed transactions в том, что у них обычно нет той же изоляции, что у одной локальной БД-транзакции. Это значит, что две saga могут одновременно менять одини те же записи.
Типичный сценарий:
Saga A -> reserve inventory for order-1
Saga B -> reserve inventory for order-2
обе смотрят на один и тот же остаток
обе считают, что ресурса хватает
Именно здесь появляются race condition, двойной резерв, lost update и конфликтующие переходы состояний.
Чаще всего это решают через:
- optimistic locking
- version check
- semantic lock
- reservation status
- per-key serialization
Идея в том, что система должна явно не позволять двум параллельным процессам молча сделать несовместимые изменения одного и того же ресурса.
Если этого не сделать, то даже при хороших retry и compensation можно получить логически неверный итог: два подтвержденных резерва, двойное списание или сломанные переходы статусов.
Stale Reads / Read-Your-Own-Writes / Lagging Read Model¶
Отдельная проблема distributed systems в том, что write уже мог успешно завершиться, а read model, projection или UI еще показывают старое состояние. То есть команда уже принята, но пользователь сразу после этого не видит свой же результат.
Типичный сценарий:
1. order accepted
2. событие ушло в брокер
3. projection еще не обновилась
4. UI все еще показывает старый статус
Это не обязательно баг транспорта. Это нормальный эффект lagging read model и eventual consistency.
Обычно это решают через:
- polling статуса;
- отдельный
status endpoint; version/token;wait-for-projection;monotonic reads;- явное сообщение в UI, что операция еще "обрабатывается".
Главная мысль:
нельзя обещать read-your-own-writes там, где архитектура реально работает через асинхронные projection и eventual consistency.
Idempotency¶
Idempotency означает, что повторный вызов не должен ломать бизнес-инвариант и не должен повторно создавать тот же эффект. Она нужна потому, что в distributed systems сообщения могут приходить повторно, producer может сделать retry, consumer может упасть после обработки, но до commit, а relay может отправить одно и то же событие дважды. Поэтому нельзя рассчитывать, что каждое событие будет обработано ровно один раз на уровне транспорта.
Если пришел второй OrderCreated, не должен появиться второй заказ. Если повторно пришел
PaymentCaptured, нельзя списать деньги еще раз. Если второй раз пришел ReserveInventory, нельзя
удвоить резерв. Именно это и решает идемпотентность.
Чаще всего ее достигают через:
- уникальный
event_id - unique constraints
- upsert
- проверку "обрабатывали ли уже это событие"
Inboxprocessed_messages- business key based deduplication
Главная мысль: если в системе есть ретраи и сообщения, идемпотентность почти всегда обязательна.
Inbox¶
Inbox — это паттерн для входящих сообщений, который нужен, когда событие может прийти повторно,
а бизнес-операцию второй раз выполнять нельзя. Идея простая: получить событие, проверить, не было
ли оно уже обработано, если событие новое — выполнить локальную транзакцию, а в той же транзакции
отметить его как обработанное. Если такое сообщение уже встречалось, считаем его duplicate и не
делаем side effect повторно.
Inbox особенно полезен там, где:
- consumer делает retry;
- сообщения приходят из Kafka или другого at-least-once транспорта;
- нужно защитить БД или внешний side effect от повторной обработки.
Обычно Inbox используют вместе с Outbox: один сервис надежно публикует исходящие события,
другой надежно и идемпотентно принимает входящие.
processed_messages¶
processed_messages — это самая частая техническая реализация идемпотентности на стороне consumer.
Обычно это таблица, где хранятся message_id или event_id, consumer_name, processed_at, а
иногда еще correlation_id и статус обработки. Смысл в том, что consumer перед обработкой
проверяет, видел ли он уже это сообщение, а после успешной локальной транзакции в той же
транзакции записывает, что сообщение обработано.
Схема выглядит так:
1. получили сообщение
2. проверили processed_messages
3. если записи нет -> обрабатываем
4. в той же транзакции пишем, что сообщение обработано
5. если запись уже есть -> считаем это duplicate
Это один из самых практичных способов защиты от re-delivery, потому что он не требует магии от брокера и работает на стороне самого приложения.
Compensating Actions¶
Компенсирующее действие — это не rollback БД, а новое бизнес-действие, которое логически
нейтрализует уже сделанный шаг. Если был charge, компенсацией будет refund; если был
reserve inventory, компенсацией будет release inventory; если был create order,
компенсацией будет cancel order. Это важно, потому что в распределенном процессе прошлый шаг
уже мог commit'нуться, а внешний мир уже мог его увидеть.
Нужно помнить, что compensation:
- тоже может упасть;
- тоже должна быть идемпотентной;
- не всегда идеально симметрична исходной операции;
- иногда вообще не может полностью "стереть" прошлое действие.
Поэтому compensation — это часть бизнес-дизайна процесса, а не просто технический rollback.
Нельзя проектировать систему так, будто компенсация "всегда проходит с первого раза": для нее тоже
нужны retry policy, observability и иногда manual intervention flow.
Retry Policy¶
Retry нужен, потому что многие ошибки временные: сеть моргнула, брокер недоступен, БД вернула transient error, внешний сервис дал timeout. В таких случаях без retry система будет слишком хрупкой. Но retry опасен, если операция неидемпотентна, нет лимита попыток, нет backoff и нет различия между transient и permanent error.
Плохой retry:
- бесконечный;
- без backoff;
- на неидемпотентной операции;
- без понимания, когда надо остановиться и эскалировать ошибку.
Хороший retry policy обычно включает:
- ограничение по количеству попыток;
- backoff;
- jitter;
- классификацию ошибок;
- observability;
- понятный финальный failure outcome.
Главная мысль: retry полезен только вместе с idempotency и четкими правилами, а не как безусловная реакция на любую ошибку.
Timeout Handling¶
В распределенных процессах нельзя ждать "сколько угодно". Если нет явного timeout policy, любая distributed workflow рано или поздно начнет зависать. Поэтому заранее нужно определить, сколько ждать ответ следующего шага, когда считать шаг потерянным, когда делать retry, когда переходить к compensation, когда эскалировать в manual review и кто вообще отвечает за stuck processes.
Без этого появляются:
- зависшие саги;
- орфанные резервы;
- объекты в вечном
PENDINGилиIN_PROGRESS; - ручные ночные разборы в проде.
Timeout handling — это не второстепенная настройка, а обязательная часть архитектуры distributed workflow.
Observability¶
Без наблюдаемости distributed transactions очень быстро становятся непрозрачными и неуправляемыми. Если процесс идет через несколько сервисов, событий, retry и compensation, нужно уметь ответить на вопросы: какая сага стартовала, какой шаг завершился, какой шаг ретраится, где именно произошла ошибка, что уже компенсировалось, а что зависло и требует вмешательства.
Минимум обычно нужен такой:
correlation_idtrace_idsaga_idevent_id- audit trail по шагам
- понятные промежуточные и финальные статусы
Observability здесь не nice-to-have, а часть корректности системы: без нее невозможно понять, где сломался flow и что нужно чинить.
Reconciliation / Repair / Manual Recovery¶
Retry, timeout и compensation покрывают только часть реальности. В проде почти всегда бывают случаи, когда автоматические механизмы уже исчерпаны, а процесс все еще не доведен до корректного финального состояния.
Примеры:
- событие застряло в DLQ;
- компенсация падала много раз подряд;
- внешний сервис долго недоступен;
- объект завис в
PENDINGилиCOMPENSATING; - projection так и не сошлась с source of truth.
Для этого нужен отдельный слой восстановления:
reconciliation jobrepair commandDLQ / parking lot- операторский разбор
- ручной replay или ручной cancel / confirm
Идея reconciliation в том, что система периодически или по запросу сравнивает ожидаемое и фактическое состояние, находит рассинхроны и пытается довести их до правильного итога.
Главная мысль:
manual recovery и reconciliation — это не признак плохой архитектуры, а нормальный обязательный слой для реальных distributed transactions.
Partition Key И Порядок Событий¶
Если распределенный процесс идет через брокер вроде Kafka, нужно понимать, что порядок событий не
возникает сам собой. Обычно для этого выбирают стабильный partition key, например orderId или
paymentId, и держат события одной сущности в одной partition. Это дает локальный порядок по одной
сущности и уменьшает хаос в consumer logic.
Но важно помнить:
- это не дает глобальный порядок по всей системе;
- это не убирает все race condition автоматически;
- это не заменяет явный дизайн состояний и переходов.
Поэтому порядок событий надо проектировать явно, а не надеяться, что брокер "как-нибудь сам" сохранит нужную бизнес-логику.