image

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

Микросервисы, REST, API… даже не уверен, можно ли впихнуть в заголовок поста еще больше модных словечек, но знаю наверняка: все эти словечки вворачиваются для того, чтобы заронить сомнения в душе разработчиков, архитекторов и управляющих директоров. Сомнения таковы: если не «делать» микросервисов, если не предусмотреть API на все случаи жизни, а также не придерживаться REST – то что-то пойдет не так. И вы определенно что-то делаете не так, если не проводите все операции по HTTP.

Так что, держитесь, сейчас будет бомба: я утверждаю, что микросервисы – это не REST по HTTP.

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

История о двух подходах к использованию API


Прежде, чем показать, как можно злоупотреблять REST API, начну с некоторого позитива и приведу пример грамотного использования API в микросервисной архитектуре.

API для составного UI

image

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

Разве здесь что-то не так? Нет, все как и должно быть!

Это совершенно нормальный и ожидаемый вариант использования API. Поскольку в наше время большинство веб-разработчиков к клиентским фреймворкам, например, Angular, Vue и React (также остается надежда, что в ближайшем будущем на клиенте будет гораздо шире представлен Blazor), существует неписанное ожидание, что также будет построен API, через который клиентский код сможет считывать данные из приложения и записывать их в приложение.

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

Хороший API критически важен для успеха всего проекта.

API для межсервисной коммуникации

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

API для составного UI

image

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

В чем же проблема? В том, что это делается с помощью вызова HTTP. Протокол HTTP – синхронный, блокирующий. Обычно он требует, чтобы вызывающая сторона дожидалась ответа. Разумеется, в .NET есть соответствующие оптимизации, например async/await, но они всего лишь позволяют вызывающей стороне работать в режиме многозадачности после того, как был совершен запрос. Это никак не отменяет того факта, что вы по-прежнему вынуждены дожидаться отклика.

Почему это плохо?

Чтобы обработать любую команду, Blue вынужден отправить вызов к Purple – только так он сможет выполнить свою задачу. Поскольку вызов является блокирующим, а как сеть, так и брокер не отличаются надежностью, либо сам Purple может работать медленно или отказать, на Blue может обрушиться каскад задержек и исключений. В свою очередь, из-за этих ошибок сам Blue может перестать отвечать, и так до тех пор, пока не обвалится вся система. Одна большая блокирующая цепочка вызовов, выстроившаяся по HTTP.

Не выглядит ли все это как автономия сервисов? Никак нет! Это сильная связанность. А в данном случае мы имеем дело с конкретным типом связанности, так называемой временной связанностью.

Вернемся на шаг назад. Почему же Blue, чтобы справиться с поставленной перед ним задачей, нужно стабильно получать данные от Purple? В соответствии с Постулатами сервис-ориентированной архитектуры (SOA), каждый сервис должен быть автономен. Это означает, что как поведение, так и данные, необходимые сервису для выполнения его работы должны локализоваться именно в этом сервисе. Если Blue обращается к Purple за данными, нужными ему для выполнения его работы, то, определенно, Blue не автономен, а Purple потенциально также не автономен.

Важнее, что за данные Blue разрешено получать от Purple? Зачастую это данные, которые должны инкапсулироваться Purple. Поэтому, когда Purple свободно отдает эти данные Blue, создается логическое связывание.

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

Даже в распределенной системе требуется обеспечить разделяемость некоторых данных между сервисами. Ни одна (полезная) система не может работать в полной изоляции. Бывает, что одному сервису для выполнения задачи требуется подмножество данных от другого сервиса.
Вместо того, чтобы полагаться на межсервисную коммуникацию по протоколу HTTP, можно воспользоваться системой сообщений.

Отступление: а что можно сказать о кэшировании?


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

image

Сколько времени нужно на кэширование данных? Сколько – на обработку инвалидации кэша? Может ли инвалидация зависеть от нагрузки? Нужен ли мне распределенный кэш? Уйдя далеко по этой дорожке, можно столкнуться со сложностями при реализации. Я ведь уже упомянул инвалидацию кэша?

Ирония судьбы в данном случае такова: те, кто хочет обойтись без согласованности в конечном счете, настаивая, что им нужно получать данные «в режиме реального времени» и использовать для этого блокирующие вызовы по HTTP, в итоге все равно могут столкнуться с необходимостью так или иначе обеспечивать согласованность в конечном счете – реализовав ее в виде кэша. Да, кэширование – это вариант согласованности в конечном счете. Почему? Потому что в любой момент те данные, с которыми работает Blue (извлеченные и кэшированные с Purple) могут устареть, и это означает, что в конечном счете данные будут согласованы.

Окей, возвращаемся к сообщениям!

Исследование обмена сообщениями


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

Как это можно сделать? Поставим проблему с ног на голову.

Теперь не Blue будет обращаться к Purple за данными. Теперь мы перейдем на модель «публикация-подписка», где Purple публикует события, а Blue может на них подписываться.

image

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

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

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

Теперь, когда мы обрисовали контекст, и у нас есть «карта местности», давайте перейдем к конкретике и разберем практический пример, относящийся к предметной области «Доставка».

The Shipping Domain


В предметной области «Доставка» (Shipping) у нас два сервиса: «Выполнение» (Fulfillment) и «Склад» (Warehouse).

• Fulfillment отвечает за выполнение заказа. Для простоты предположим, что пока каждому заказу соответствует один товар.

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

Имея эти базовые определения, давайте разберемся, как в данном случае при помощи сообщений организовать совместное использование данных с передачей их из Warehouse в Shipping, так, чтобы Shipping мог судить: возможно ли выполнить заказ, исходя из складских запасов данного товара в Warehouse.

Не звоните мне, я сам вам позвоню


Теперь не Fulfillment будет обращаться к Warehouse, запрашивая, в каком количестве на складе имеется нужный товар и, соответственно, определять, может ли быть выполнен заказ.

image

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

public class ProductInventoryUpdated
{
    public Guid ProductId { get; set; }
    public int UpdatedAmount { get; set;}
}

UpdatedAmount может выражаться положительным числом (запасы товара были восполнены в результате новой поставки, либо из-за того, что на склад пришел возврат, т.д.) либо отрицательным числом (заказы выполняются, товар убывает). UpdatedAmount – это дельта.
Fulfillment подписывается на ProductInventoryUpdated. Обрабатывая событие, Fulfillment считывает последние данные об имеющемся количестве товара с заданным id, опираясь на сообщение из локальной базы данных. Далее применяется дельта для пересчета доступного количества и для записи в локальную базу данных обновленной информации о доступном количестве товара.

Что мы выиграли, предприняв такой подход?
• Устранили временное связывание между сервисами.
• Внедрили для Склада асинхронный подход «выстрелил и забыл», организовав широковещательную передачу данных об обновлении склада методом «публикация/подписка».
• Локализовали в Выполнении все данные, которые нужны этому сервису, чтобы справиться со своими задачами. Все это – без блокирующих HTTP-запросов. Вот как, например, это может выглядеть:

image

ProductInventoryUpdated содержит только стабильные бизнес-абстракции: contains id товара и дельту изменения складских запасов.

Я уже пару раз упоминал о стабильных бизнес-абстракциях и приводил примеры данных, которые таковыми считаются. А какие данные не являются стабильной бизнес-абстракцией (то есть, какие данные не следует разделять)?

Здесь это могут быть такие данные, как название товара, SKU-номер (складской номер товара), bin # (указание, где именно товар расположен на складе)… все, что нужно Складу для выполнения своей работы. Данные и поведение, используемые при расчете дельты, должны оставаться как следует инкапсулированы в сервис Warehouse.

Рефакторинг: от дельты к доступному количеству


Итак, мы уже достаточно хорошо спроектировали систему, но всегда можно сделать ее лучше. Что, если все расчеты у нас будут выполняться в Fulfillment? В настоящий момент Warehouse должен публиковать дельту товара в соответствии со скорректированной информацией о складских запасах. Ведь Warehouse известно, сколько штук товара есть на складе – почему бы не опубликовать эту информацию?

Давайте попробуем, подходит ли это нам:

image

Все, что теперь нужно сделать Fulfillment – это обработать событие и вставить AvailableAmount для товара с подходящим id в свое локальное хранилище данных. Никаких вычислений, просто вставить значение и готово.

Сообщения – это не магия


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

Так, если вы планируете открыть в сообщении доступ ко всем данным, содержащимся в Warehouse – то вас ждут те же проблемы с сильным связыванием, которые возникали, когда в качестве контракта использовался API. Хотя механизмы доставки здесь и отличаются, логически допускается ровно та же ошибка, и в результате вы получите… все ту же гигантскую запутанную кучу.

Заключение


В этом посте была представлена жизнеспособная альтернатива межсервисной коммуникации по протоколу HTTP, применительно к микросервисной архитектуре. Использование публикации/подписки – отличный подход к устранению временной связанности между вашими микросервисами, а также первый шаг к внедрению работы с сообщениями.

Как и при любом подходе, не нужно просто сносит всю базу кода и все делать по-новому.

image

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

Брокеры могут послужить отличным входным решением для реализации дешевой и сердитой функциональности публикация/подписка. В RabbitMQ можно работать с Топиками, которые вписываются в модель «публикация/подписка». Azure Service Bus – также отличный кандидат на эту роль, тем более, что в него внедрено еще множество вкусностей, например, дедупликация сообщений, объединение сообщений в пакеты, транзакции и прочий функционал, свойственный сервисным шинам.

Если вас интересуют фреймворки, поддерживающие семантику публикации/подписки через API, посмотрите в сторону шины событий CAP. Семантика публикации/подписки + уровень надежности, достаточный для большого предприятия, обеспечивается в NServiceBus или ReBus.

Комментарии (9)


  1. UnclShura
    11.11.2022 15:22
    +2

    Отлично. Теперь вместо жесткой гарантии получения данных мы получили слабое связывание и отстутствие гарантий вообще. Для страничек в интернете - прекрасно. Для финансовой системы немедленная смерть. (Да есть паттерны типа event sourcing, но они настолько сложны в разработке... и избыточны в большенстве случаев)

    Вот пример - синий сервис требует данные фиолетового и они на pub/sub. От фиолетового ничего не пришло. Это он лежит? Это ничего не случислось? Или еще: все то-же самое, но от фиолетового пришло. Ура? А это точно последние данные? Ах там таймстамп-же есть? Ну и что - обновиться через милисекунду оно все равно могло.

    Вот и получаются гибридные pub/sub и request/response. А там не только все плюсы, но и все минусы обоих.

    Нет счастья. Надо думать над каждой связью.


    1. EgorovDenis
      12.11.2022 21:48
      -1

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

      Без машины состояний или иной технологии с отменой данный метод не стоит применять и применим он не везде, но ниша его может быть достаточно обширной. Например, можно реализовать оплату покупки через Saga + MassTransit


  1. StanislavL
    11.11.2022 15:25
    +2

    Подход так себе. Мы так задублируем урезанный склад в заказах со всемы вытекающими проблемами.

    База склада пухнет - придется держать всю инфу по связанным сервисам.
    Неактуальные данные - количество на складе отстает на время обработки consumer.
    Связанные данные (например поставщик) которые раньше тянулись через graphql недоступны либо их придется тянуть всегда и держать у себя (опять же причина того что база пухнет).
    Модификация данных (надо поле добавить или поменять) превратится в пляски с бубном для синхронного обновления всех связанных баз.
    Пробросить доп поле в заказ птребует модификации базы.

    Я бы сказал предложенное решение очень спорное и применять надо ссильно подумава, а надо ли нам это.


  1. vagon333
    11.11.2022 19:31

    Если автор (не переводчик) предлагает Pub/Sub + кеширование, то решение не ново. Имеет массу подводных камней и по подпискам и по валидации кеша.
    Не понятно, почему сфокусировали на микросервисах если проблема классическая для любого веб-приложения: Pub/Sub + кеширование на клиенте.


  1. AirLight
    11.11.2022 20:44

    Я сознательно сделал одну большую систему на REST и не жалею. У каждого подхода есть свои недостатки к которым можно сделать соответствующие подпорки. Например, в критичных местах прописать механизмы повторных запросов.

    Более того, на рынке даже существуют специальные готовые сетевые решения, которые решают проблему обрывов, например Istio для докера.

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

    Было бы более грамотно всё-таки более непредвзято посмотреть на обе альтернативы и описать плюсы и минусы каждой.


  1. murkin-kot
    12.11.2022 11:50
    +2

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

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

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


  1. rukhi7
    14.11.2022 07:23

    Вернемся на шаг назад. Почему же Blue, чтобы справиться с поставленной перед ним задачей, нужно стабильно получать данные от Purple? В соответствии с Постулатами сервис-ориентированной архитектуры (SOA), каждый сервис должен быть автономен.

    Может быть Blue, не может справиться с поставленной перед ним задачей, без данных от Purple,потому что функциональность не правильно разделили между микросервисами?

    Получается: нарушили

    Постулат сервис-ориентированной архитектуры (SOA): каждый сервис должен быть автономен

    и пытаемся выкрутиться, может просто не надо было нарушать?


  1. SF52
    14.11.2022 11:02

    А как поддерживать актуальность данных в Fulfilment без временной связности? Допустим развернули сервис пуляем первый запрос оказывается что в локальном хранилище нет инфы по запрошенному товару, что дальше? Идем на склад за инфой? Через брокер? Почему не сделать синхронный запрос ведь информация нужна здесь и сейчас. Я не помню как в предложенной реализации решить эту проблему. Предполагается делать БД в двух экземплярах для Fulfilment и для Warehouse, или как?


  1. SidMS
    14.11.2022 11:02

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

    Сервис Fulfillment занимается выполнением заказа. Вот только остаётся непонятным почему этот сервис вдруг стал решать может ли он что-то взять со склада.

    Тут не хватает бизнес логики, а именно: 1. при создании заказа мы должны зарезервировать товар на складе, если это возможно 2. при положительном резервировании заказ подтверждается. 3. при не возможности резервации товара заказ отклоняется.

    В соответствии с этими требованиями, сервис Fulfillment при получении заказа должен опубликовать сообщение, что заказ был создан, а самому заказу необходимо добавить статус "в обработке". Сервис Warehouse подписывается на это сообщение и проверяет у себя может ли он зарезервировать товар. Если товара достаточно на складе, то публикуется сообщение, что заказ был зарезервирован. На это сообщение уже подписан Fulfillment и при получении его меняет статус заказа на "обработан". Если Склад не может подтвердить заказ, он так же публикует сообщение, что заказ не может быть выполнен. А сервис по работе с заказами, получив это сообщение, должен сменить статус заказа на "отклонён".

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

    Микросервисы это инструмент, и любым инструментом необходимо уметь пользоваться. К сожалению данная статья показывает только то, что автор не разобрался с вопросом в полной мере.