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

Объясните механизм: как сохранить инвариант «остаток не уходит в минус», если два сервиса могут одновременн...

Объясните механизм: как сохранить инвариант «остаток не уходит в минус», если два сервиса могут одновременно подтвердить списание?

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

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

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

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

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

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

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

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

Предположим, два сервиса почти одновременно видят остаток 10 единиц. Каждый проверяет условие для списания 7 единиц, получает положительный результат и записывает новый остаток 3. В итоге подтверждены две операции на 14 единиц, хотя доступно только 10.

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. Достаточно ли упорядочить события, чтобы защитить остаток?

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

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

  1. Можно ли вынести проверку остатка в отдельный сервис, оставив запись в другом сервисе?

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

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

  1. Что делать, если владелец подтвердил резерв, но инициирующий сервис не получил ответ?

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

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