Привет, Хабр! Меня зовут Алексей Ситка, я старший разработчик и техлид сервиса уведомлений в Lamoda Tech. Последние годы я занимаюсь проектированием микросервисных приложений из десятков подсистем, в основном в сфере e-commerce. Расскажу, как мы проектировали наш сервис уведомлений, и что у нас получилось. Надеюсь, это будет полезно для тех, кто занимается или интересуется архитектурным планированием. 

Предыстория разработки сервиса

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

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

Продуктовые требования к сервису уведомлений

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

  1. Быстрое подключение к процессам компании, требующим уведомлений.

  2. Возможность оперативно настраивать уведомления по группам опций.

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

  4. Некоторые транспорты уведомлений должны соблюдать режим тишины для пользователей в определенное время суток.

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

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

Архитектурные решения

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

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

  2. В конвейере — несколько операций со своими точками отказа, поэтому возможно применение SAGA в качестве паттерна для разбиения больших транзакций на меньшие. Но вероятно, вам хватит и более простого варианта — принципа At-Least-Once с идемпотентностью обработки. Главное иметь возможность отката и восстановления каждого этапа вместо повторения всей работы с самого начала.

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

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

Для реализации взяли доступные и проверенные инструменты, стандартные для Lamoda: 

  • Язык — Golang: для нас он целевой и актуальный, мы пишем на нем все новые сервисы.

  • База данных — простая и понятная PostgreSQL.

  • Шина для общения между системами компании — Kafka.

  • Брокер для внутренних очередей самого сервиса — RabbitMQ.

  • Вспомогательный ускоритель для кэша и данных «ключ-значение» — Redis. 

  • Приложение изначально должно учитывать особенности облачной инфраструктуры для развертывания в кластере Kubernetes.

Часть 1. Проектируем сервис уведомлений

Простейший событийно-ориентированный сервис уведомлений должен был включать в себя как минимум три контекста: 

  • консьюмер топика Kafka, получающий событие, 

  • рендерер текста сообщения,

  • транспорт для отправки сообщения пользователю.

Получили событие →  создали уведомление → отправили сообщение
Получили событие →  создали уведомление → отправили сообщение

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

Итак, мы начали применять требования к этому минимальному приложению.

Продуктовое требование №1. Быстрое подключение к любому доменному событию

Так как сами события могут генерироваться абсолютно разнородными сервисами, то в качестве примера возьмем флоу заказа.

Здесь я привожу далеко не полный перечень подсистем, вовлеченных в этот флоу. И все они в той или иной мере должны что-то сообщать покупателям. В качестве базового контрактного обязательства для интеграции с нашим сервисом потребитель должен организовать топик в Kafka, в который будет писать свои события через EventBus. При этом на системы не накладывается жестких ограничений на набор данных — что есть по событию, то и присылает. А еще события могут быть как дифами, так и снапшотами. И даже иногда командами (но лучше в EventBus так не делать).

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

Унификация внутренних событий

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

Структура такого события состоит из трех основных частей: 

  • идентификатор, 

  • тип события (или триггер),

  • набор параметров, взятый из события топика.

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

Унификация внутренних событий
Унификация внутренних событий

Продуктовое требование №2. Возможность гибкой настройки уведомлений

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

  • страна,

  • тип доставки,

  • метод доставки,

  • селлер товара,

  • тип заказа.

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

Сопоставление потока событий с настройками уведомлений
Сопоставление потока событий с настройками уведомлений

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

Мы знали, что в исходном событии топика может не хватить всех необходимых данных для составления текста сообщения. 

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

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

Отвечал за это специальный компонент. В него закладывалось знание того, где и как можно получать нужные параметры. А еще он мог делать ретраи на случай, если мастер-система с нужными данными окажется недоступной, и в целом являлся удобным местом для применения различных SRE практик. Само решение достаточно изящное в плане отсутствия влияния любых проблем с внешними API на основной конвейер уведомлений. Внутри сервиса компонент называется External Data Provider или XDP (поставщик внешних данных).

Работа провайдера внешних данных (XDP)
Работа провайдера внешних данных (XDP)

Продуктовое требование №3. Легкое добавление новых транспортов для сообщений

Чтобы легко интегрировать способы отправки, их нужно было абстрагировать до имени транспорта и набора его параметров.

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

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

Отдельный сервис для транспортной инфраструктуры уведомлений
Отдельный сервис для транспортной инфраструктуры уведомлений

В сервисе уведомлений отправку сообщений заменили на постановку на отправку.

Замена отправки на постановку
Замена отправки на постановку

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

Продуктовое требование №4. Рассылка сообщений в ограниченное время суток по часовому поясу пользователя

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

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

Добавляем блок шедулинга для контроля расписания
Добавляем блок шедулинга для контроля расписания

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

Почему Redis, а не база данных?

Потому что предварительные тесты на таблице БД и на Redis показали явное преимущество последнего в основных операциях, и особенно в скорости выборки сообщений. Насколько она важна, говорит статистика. Сейчас в буфере может накапливаться порядка 100K сообщений, из которых 3/4 – московская временная зона. И когда в ней наступает 8 утра, то буфер должен максимально быстро выдать данные на отправку.

Буфер со скользящим указателем
Буфер со скользящим указателем

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

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

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

Тут важно отметить, что работа с XDP не ограничивается только примерами с рендерингом и шедулером. На этапе маппинга событий или постановки на отправку также частенько не хватает данных. Всеми такими операциями занимается External Data Provider.

Так у нас появился конвейер, превращающий события системы в сообщение пользователям.

Расширяем использование провайдера внешних данных
Расширяем использование провайдера внешних данных

Продуктовое требование №5. Экономия денег через «каскад» транспортов с эскалацией их стоимости

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

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

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

В следующем виде сервис был отправлен в разработку и написан помодульно, как и планировалось.

Срезание углов — какие доработки мы отложили

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

  1. Мы временно отказались от транзакционности в работе брокера сообщений. 

  2. На оркестратор не хватило времени — решили, что будет достаточно хореографии.

  3. Провайдер внешних данных был написан только под потребности конвейера сообщений.

  4. Как следствие предыдущего пункта, пара консьюмеров Kafka синхронно обращалась к API мастер-систем во время детекта событий.

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

Часть 2. Продакшн и инциденты

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

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

2. Февраль — частота пиков растет.

3. Март — пики все плотнее.

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

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

4. Апрель — ситуация стала критической. Пик лага достигал 874 тысяч в течение недели.

5. Июнь — пик в 545 тысячи на три дня.

6. Июль — двойной пик в 2,18 М в течение десяти дней.

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

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

Несмотря на то, что после каждого инцидента проводились точечные работы, принципиальной проблемой оказались те немногие запросы к API внутри транзакции обрабатываемого сообщения из Kafka. Тот самый пункт №4 из отложенных доработок. 

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

Транзакция обработки события
Транзакция обработки события

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

И немного развернем схему.

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

Схлопнем оркестратор и XDP в единый блок на схеме.

После этой оптимизации проблем с лагом больше не было. И если бы не инцидент, который произошел на «Черную пятницу» 2024 года, то мы бы и не узнали, насколько быстро стали работать консьюмеры. Тогда из-за проблем у облачного провайдера обработка заказов встала на шесть часов в самый пик продаж. С учетом х5 трафика к обычному после исправления проблем нас ожидала цунами из событий по заказам. 

И сервис справился. На скрине показано 16 часов работы системы, пик лага достиг всего 560 тысяч событий, и к утру уже все было обработано.

Часть 3. События с расписанием 

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

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

В консьюмеры топика вместе с детекторами были добавлены блоки управления расписанием. Они же отвечают за изъятие уведомления из расписания, если заказ забран из ПВЗ.

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

События с расписанием
События с расписанием

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

Часть 4. Добавляем новый транспорт

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

Схема сервиса уведомлений
Схема сервиса уведомлений

А затем схлопнем диспетчер флоу и немного переработаем схему, чтобы все поместилось.

Схема конвейера сообщений
Схема конвейера сообщений

Для нового транспорта мы сформировали следующие требования:

  • Использование внешнего шаблонизатора — системы Sendsay.

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

  • Нет необходимости в соблюдении режима тишины.

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

Новый транспорт e-mail
Новый транспорт e-mail

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

И тут пришла красивая идея: добавить новый рендерер, который и будет управлять новым процессом. Диспетчер флоу будет динамически слать новые сообщения в отдельную очередь по типу транспорта сообщения. 

А как быть с расписанием? Нам же не нужен шедулинг. Можно попробовать закрыть на это глаза и отправить обработку транзитом. Но тут нужно помнить, что электронная почта — это по сути x2 к трафику сообщений. Тут уже и отдельный обработчик рендеринга заиграл новыми красками по своим возможностям масштабирования. Мы решили посылать события прямо на отправку, создав по сути отдельный конвейер обработки.

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

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

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

Что в итоге?

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

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

Финальная схема сервиса
Финальная схема сервиса

Выводы 

Что мы вынесли из этой истории:

  • В проектировании архитектуры не бывает легких задач, нужно быть к этому готовым.

  • Необходимо стараться прорабатывать продуктовые требования и видеть в них архитектурные контексты. Причем этот навык нужно тренировать постоянно. 

  • Если мыслить потоками данных, то контексты становятся более явными.

  • Не все в архитектуре может быть очевидно сразу. Не нужно гнаться за решением абсолютно всех задач. 

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

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

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

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