АрхитектураМикросервисы и интеграцииАрхитектор распределённых систем

Разберите последствия паттерна: сервис держит транзакцию базы данных открытой во время синхронного вызова д...

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

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

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

Исторический контекст

В монолитах несколько действий часто выполняются внутри одной транзакции одной базы данных. Механизмы ACID позволяют откатить изменения, если любая часть операции завершилась ошибкой.

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

Постановка проблемы

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

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

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

Подробное решение

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

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

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

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

Ситуация из практики

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

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

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

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

Что кандидаты часто упускают

  1. Всегда ли нужно полностью исключать сетевые вызовы из транзакции?

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

  1. Почему после тайм-аута нельзя считать удалённую операцию неуспешной?

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

  1. Как локальная транзакция помогает с событием, если публикация происходит после коммита?

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