Приветствуем, Хабр.

Сегодня мы хотим напомнить вам об одной важной книге, которую в очередной раз допечатали в январе текущего года: «Создание событийно‑управляемых микросервисов». Мы пытаемся развить эту тему в разрезе «для начинающих архитекторов», рады были бы пообщаться с потенциальным автором, который также разделяет наш интерес. Чтобы был более понятен интересующий нас уровень сложности и круг тем, предлагаем ознакомиться с переводным обзором этой темы; статья сентябрьская, найдена в блоге «The Scalable Thread».

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

Событие — это просто краткое сообщение, означающее «эй, что-то случилось!» Например, UserClickedButtonPaymentProcessed или NewOrderPlaced. Сервисы подписываются на события, касающиеся их работы, и соответствующим образом на них реагируют. Благодаря такому событийно-ориентированному подходу, получаются устойчивые и гибкие системы. Правда, выстраивать такие системы и поддерживать их в больших масштабах оказывается удивительно сложно.

Управление версиями формата сообщений

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

Например, событие OrderPlaced может выглядеть так:

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

Проблема в том, что другие сервисы, например, OrderConfirmationEmailService, по-прежнему могут ожидать версию в первом формате. Получив новое сообщение, они не будут знать, что делать с полем  shippingAddress. Хуже того, если исчезнет поле, на которое они полагались при работе, они просто откажут.

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

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

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

  • Реестр схем: это центральный словарь всех «секретных кодов» к вашим событиям. Перед тем, как отправить сообщение, сервис сверится с реестром, чтобы убедиться в корректности и совместимости формата этого сообщения. То есть, не позволит сервисам отправлять «непонятные записки».

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

Наблюдаемость и отладка

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

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

Теперь представьте: вам звонит клиент и жалуется, что сделал заказ, а письма с подтверждением не получил. Что могло пойти не так?

  • Может быть, OrderService не смог опубликовать событие?

  • Может быть, NotificationService его не получил?

  • Может быть, он получил событие, но не смог подключиться к почтовому серверу?

Отладка таких случаев может быть затруднена, так как вы не видите всю картину целиком.

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

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

Обработка отказов и потеря сообщений

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

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

Но что, если в сервисе возник долгоживущий баг, и из-за этого бага сервис отказывает всякий раз, когда пытается обработать конкретное сообщение? Брокер будет снова и снова пытаться доставить это сообщение, а сервис так и будет отказывать, пока брокер не исчерпает лимит попыток. Чтобы справиться с этой ситуацией, используется очередь недоставленных сообщений (DLQ). После нескольких безуспешных попыток доставки брокер отправляет в эту очередь то сообщение, из-за которого происходит отказ. В результате порочный цикл разрывается, и сервис может перейти к обработке других, корректных сообщений. Разработчики могут позже проверить содержимое DLQ и разобраться, что не так с сообщением, вызывавшим проблему.

Идемпотентность

При гарантированной «как минимум однократной» доставке возникает ещё одна нетривиальная проблема: что делать, если сообщение будет доставлено более чем один раз? Такое возможно, если сервис успеет обработать событие, но откажет до того, как успеет сообщить брокеру сообщений: «Я справился!» Брокер, полагая, что сообщение так и не было обработано, вновь доставит его после перезапуска сервиса.

В случае события IncreaseItemCountInCart дважды полученное сообщение — это большая проблема. Клиент хотел положить в корзину заказов один предмет, а теперь там два. А в случае события  ChargeCreditCard, сумма будет дважды списана с карты.

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

  1. Попадался ли ему уже ранее ID этого события?

  2. Если да — он просто игнорирует дубль и сообщает брокеру: «Да, всё готово».

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

Так гарантируется, что, даже если одно сообщение будет доставлено 100 раз, действие будет выполнено всего один раз.

Согласованность в конечном счёте

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

В событийно-ориентированных системах гарантиями частично жертвуют ради масштабируемости и устойчивости всей системы. В них работа идёт по модели, предполагающей согласованность в конечном счёте. Например, как только пользователь обновит адрес, сервис CustomerService обновит собственную базу данных и опубликует событие AddressUpdated. Сервисы ShippingService и BillingService подпишутся на это событие, но получить его (и, соответственно, обновить свои данные) могут лишь спустя сотни миллисекунд. Этот пример я привёл только для контекста, но в идеале адрес нужно хранить в одном конкретном месте, а с событиями передавать только её id.

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

  • Написать пользовательский интерфейс, в котором удобно переждать такую задержку.

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

  • Сойтись на том, что при обработке некритичных данных небольшая задержка приемлема.