Событийная архитектура

Событийная архитектура (Event-Driven Architecture) связывает части системы через события: компонент сообщает, что уже произошло, например «пользователь зарегистрирован», и не ждёт ответа. Событие уходит в брокер, а брокер доставляет копию каждому, кто на него подписан. Отправитель не знает, сколько у него подписчиков и что они сделают, поэтому новую реакцию можно добавить, не трогая его код. За эту свободу платят: событие может прийти дважды или с опозданием, данные у частей системы какое-то время расходятся, а путь одного действия через систему труднее проследить.

Проблема

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

Решение

Сервис аккаунтов только записывает аккаунт и публикует событие UserRegistered - факт в прошедшем времени со своим id. Между ним и остальными стоит брокер: на каждого подписчика у него своя очередь, и каждая получает копию события. Подписчики - письма, статистика, аудит - работают сами, каждый со своим хранилищем, и никому не отвечают. Если подписчик лежит, событие ждёт в его очереди. Брокер гарантирует доставку хотя бы один раз, поэтому подписчик помнит id обработанных событий и не делает работу дважды. Хранить сами события как источник истины - это event sourcing, а раздельные модели чтения и записи - следующая тема, CQRS.

Как это работает

У термина нет одного значения, и это первое, что о нём стоит знать. Мартин Фаулер в статье «What do you mean by "Event-Driven"?» (7 февраля 2017), выросшей из встречи коллег в Thoughtworks в конце 2016 года, различает четыре вещи, которые называют этим словом: уведомление о событии (event notification), перенос состояния в событии (event-carried state transfer), event sourcing и CQRS. Раньше идею событий как основы распределённых систем развивал Дэвид Лакхэм в книге «The Power of Events» (2002) - введении в обработку сложных событий (complex event processing).

Пример - уведомление о событии: по Фаулеру, система сообщает об изменении в своей области и почти не ждёт ответа. Событие здесь несёт адрес, то есть всё, что нужно подписчикам, и спрашивать сервис аккаунтов им не приходится - это уже шаг к переносу состояния в событии. Event sourcing идёт дальше: каждое изменение записывается как событие, а текущее состояние восстанавливают, проигрывая журнал. В примере этого нет: аккаунты хранят текущее состояние, emails.

Команда и событие различаются направлением ответственности. Команда «зарегистрируй» обращена к конкретному получателю и ждёт ответа: получилось или нет. Событие UserRegistered - факт о прошлом, его нельзя отклонить, и отправитель не знает, кто его прочтёт. Поэтому в примере есть и то и другое: страница отправляет команду и получает ответ, а сервис аккаунтов публикует событие и не ждёт никого. Если событие на деле означает «сделай письмо», а отправитель ждёт, что письмо уйдёт, это команда под чужим именем.

Механизм доставки описан в «Enterprise Integration Patterns» Грегора Хопа и Бобби Вульфа (2003) как Publish-Subscribe Channel: канал доставляет копию события каждому получателю. В примере это брокер с очередью на подписчика, и именно поэтому упавший подписчик ничего не теряет: его копия ждёт. Новый подписчик получает новую очередь, и прошлых событий в ней нет. Брокер с хранимым журналом, как Kafka, держит события и после доставки, и новый подписчик может прочитать их с начала.

Документация Kafka называет три гарантии доставки: не больше одного раза (сообщения могут потеряться, но не повторяются), хотя бы один раз (не теряются, но могут повториться) и ровно один раз. Производитель, не получивший подтверждения, не знает, записано ли сообщение, и отправляет его снова - отсюда дубликаты. С версии 0.11.0.0 у Kafka есть идемпотентный производитель и транзакции, и «ровно один раз» достижимо при чтении и записи внутри Kafka, но для внешних систем, по той же документации, нужна их помощь. По умолчанию Kafka гарантирует «хотя бы один раз».

Поэтому подписчику нужен идемпотентный получатель (Idempotent Receiver у Хопа и Вульфа): получатель, который безопасно принимает одно и то же сообщение несколько раз. Способов два: убирать дубликаты на стороне получателя или сделать само сообщение таким, что повтор ничего не меняет. В примере первый: подписчик хранит id обработанных событий. Почтовый сервис - внешняя система, и пометку с отправкой нельзя сохранить одной транзакцией: если пометить событие до отправки, сбой между ними потеряет письмо, а если после - даст второе. Это окно можно лишь сделать маленьким.

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

Марк Ричардс и Нил Форд в главе 14 «Fundamentals of Software Architecture» (2020) разбирают две топологии. Брокерная - без центра: компоненты реагируют на события и публикуют новые, как в примере. С посредником (mediator) - центральный компонент ведёт многошаговый процесс и рассылает команды по каналам; так проще управлять ошибками, но посредник снова связывает части. Без посредника проследить поток трудно: Фаулер пишет, что такой поток не виден ни в одном тексте программы. В отличие от микросервисов из прошлой темы, где аккаунты ждали ответа сервиса писем, здесь у действия нет одного стека вызовов.

Нюансы выбора

Ждать или публиковать - если отправителю нужен результат, чтобы ответить пользователю (хватает ли денег, свободен ли адрес), нужна команда с ответом. Если результат ему не нужен, как письмо после регистрации, лучше опубликовать событие и не ждать.

Событие или скрытая команда - событие называют фактом в прошедшем времени (UserRegistered), а не поручением (SendWelcomeEmail). Если производителю важно, чтобы конкретный получатель что-то сделал, и он ждёт подтверждения, честнее отправить команду.

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

Дубликаты и порядок - каждый подписчик проектируют так, будто событие придёт дважды. Если порядок важен для одного объекта, события этого объекта направляют в одну очередь или партицию по ключу.

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

Примеры и история

Martin Fowler, «What do you mean by "Event-Driven"?» (martinfowler.com, 7 февраля 2017) - четыре значения термина: event notification, event-carried state transfer, event sourcing, CQRS, с плюсами и ловушками каждого.

Gregor Hohpe, Bobby Woolf, «Enterprise Integration Patterns» (Addison-Wesley, 2003) - язык шаблонов обмена сообщениями, среди них Publish-Subscribe Channel (копия события каждому получателю) и Idempotent Receiver (получатель, переживающий повторы).

Mark Richards, Neal Ford, «Fundamentals of Software Architecture» (2020) - глава 14 «Event-Driven Architecture Style»: брокерная топология и топология с посредником, асинхронность, обработка ошибок, защита от потери данных, запрос-ответ поверх событий.

David Luckham, «The Power of Events» (Addison-Wesley, 2002) - введение в обработку сложных событий (complex event processing): события, причинность, иерархии и шаблоны событий в распределённых системах предприятия.

Документация Apache Kafka, раздел «Message Delivery Semantics» - определения «не больше одного раза», «хотя бы один раз» и «ровно один раз», идемпотентный производитель и транзакции с версии 0.11.0.0.

Когда применять

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

Примеры из практики

  • Apache Kafka - разработан в LinkedIn, код открыт в начале 2011 года, в октябре 2012-го проект вышел из инкубатора Apache. В эссе «The Log» (декабрь 2013) Джей Крепс, один из авторов Kafka, описал идею: собрать все данные организации в центральный журнал, на который системы подписываются в реальном времени. По документации Kafka, по умолчанию она гарантирует доставку «хотя бы один раз», а порядок - внутри одной партиции.
  • Amazon SNS и SQS - документация AWS описывает схему «fanout»: сообщение публикуется в тему SNS, а SNS отправляет его в каждую подписанную очередь SQS для асинхронной обработки. Про стандартные очереди SQS там же сказано: доставка хотя бы один раз, иногда больше одной копии и иногда не по порядку.

Похожие паттерны

Микросервисная архитектура

Микросервисная архитектура (Microservices Architecture) строит одно приложение как набор небольших сервисов: каждый работает в своём процессе, развёртывается независимо от остальных и сам владеет своими данными. Систему режут по бизнес-возможностям - учётные записи, письма, оплата, - а не по техническим слоям, и сервисы общаются только по сети через API. Общей базы данных нет: чужие данные можно получить, только спросив их владельца. За независимость платят распределённостью: любой вызов может опоздать или потеряться, а согласованность приходится держать без общей транзакции.

CQRS

CQRS (Command Query Responsibility Segregation) разводит запись и чтение по двум разным моделям. Модель записи принимает команды, проверяет правила и хранит данные так, чтобы их было удобно защищать. Модель чтения хранит те же данные уже в том виде, в каком их показывает конкретный экран, и отвечает на запросы, ничего не меняя. Между ними ходят события, поэтому модель чтения отстаёт от записи на какое-то время. Это приём для отдельной части системы, где чтение и запись действительно тянут модель в разные стороны, а не архитектура всего приложения.