Привет! Меня зовут Стас Кондратьев, я backend-разработчик в hh.ru. В прошлом году выступал на конференции HighLoad++ с докладом о том, как мы поэтапно выделяли микросервис чатов из монолитного сервиса откликов. Рассказал про проблемы, решения и подходы, которые мы применяли.
Теперь хочу поделиться этим опытом с Хабром. Кому удобнее смотреть — прикладываю видео доклада.
В статье не будет концептуально новых идей. Это подробный разбор нашего практического опыта — подводные камни, с которыми мы столкнулись, и способы их решения.
Надеюсь, кому-то наш опыт будет полезен!
Немного контекста
В hh.ru в IT работает около 600 человек, из них 60 команд — в техническом департаменте. У каждого разработчика и тестировщика есть свой стенд. Мы активно используем AI, а сервисы разрабатываются под высокую нагрузку и отказоустойчивость.
Статья будет разбита на 5 основных частей, в которых я расскажу, как мы поэтапно занимались выделением нового микросервиса чатов из откликов:
Выделение в новый модуль
Разделение связей с монолитным сервисом
Разделение SQL Join-запросов
Разделение транзакций
Выделение нового сервиса
Речь пойдет о сервисе Negotiations, который отвечает за отклики и чаты. Его характеристики:
нагрузка — 20 000 запросов в секунду
более 2 миллионов откликов создаётся ежедневно
за функционал разработки отвечают три команды
сервис построен на микросервисной архитектуре и развёрнут в 22 репликах
стек: Java, PostgreSQL, Kafka, ElasticSearch, Scylla
Для начала дам короткое вводное по нашим терминам: откликом называется сущность в базе, которая создается, когда соискатель откликается на вакансию или работодатель делает приглашение соискателю на вакансию. В дальнейшем мы будем для упрощения называть это откликом (topic) и не будем разделять понятия.
Отклик хранится в базе в таблице negotiation_topic и имеет свои связанные таблицы, которые нас пока не интересуют. И есть сущность чатов, которая хранится в той же базе и имеет свои таблицы — chat, chat_message, chat_participant и прочее.

Как связаны отклики и чаты
Чаты с откликами связаны один к одному: каждому отклику соответствует один чат
Чатов без откликов не существует
Отклик с чатом создается в одной транзакции — это ключевой момент, на котором многое завязано и к которому мы ещё вернёмся
В какой-то момент бизнесу понадобились чаты без откликов — как отдельная платформа с ботами, рассылками и другими функциями. Чтобы её развивать в таком качестве, стоило бы разорвать связь с откликами, иначе сервис перерос бы в ещё больший монолит с новыми бизнес-сущностями.
Поэтому мы приняли решение выделить чаты в отдельный микросервис, который будет отвечать за общую логику чатов без знания о клиентах (например, откликах). Если правильно выстроить архитектуру, такой подход:
Упростит разработку
Позволит масштабироваться отдельно от отклика
Добавит отказоустойчивости всей системе.
Этап 1. Выделение модуля
На выходе нам нужен абстрактный сервис чатов — назовём его Chat engine. Он не должен знать ничего о логике откликов или других клиентов и отвечать должен только за обобщённый функционал чатов. А клиенты (сервис откликов и т. д.) уже будут обращаться к сервису чатов и выполнить необходимую бизнес-логику, используя API.
Возникает вопрос: можно ли прямо сейчас разбить монолит на два сервиса? У нас есть обширная кодовая база с завязками между откликами и чатами, есть транзакции, SQL Join-запросы, логические связки, прямые походы в БД в разные доменные области данных. Перенести весь код чатов в новый сервис за один релиз невозможно. Процесс растянется, часть кода останется в откликах, а другая переедет в новый сервис чатов. Придётся дублировать код, чтобы поддерживать работоспособность в обоих сервисах. Это сложно и рискованно, особенно с учётом постоянного развития сервиса.
Поэтому мы решили действовать через промежуточный модульный этап, где мы в том же сервисе выделим вначале отдельный модуль чатов. Подключим его как зависимость к монолиту (откликам). И потом уже из модуля выделим новый сервис Chat engine. На схеме ниже приведена верхнеуровневая идея: слева — распил в модуль (все в одном сервисе, но в разных модулях), справа — финальная картина.

План выглядел так:
Выделить доменную область чатов в новый модуль, изолированный от монолита. Здесь выделяется вся абстрактная логика: создание чата, отправка сообщений и т. д. Что относится к абстрактной логике чатов а что должно остаться в откликах бывает иногда спорно. Мы исходили из принципа: можем ли мы представить функционал абстрактными данными чатов, и будут ли им пользоваться еще клиенты кроме откликов — если да то выносим. А вот к примеру API добавить менеджера вакансии в чат — это уже API откликов не чата, поскольку чаты не должны знать про вакансии и нет смысла это абстрагировать.
Все завязки чатов на данные об откликах или операции с откликами вынести в абстрактные интерфейсы, которые реализуются в откликах. Это позволит не добавлять зависимость в модуль чатов на модуль откликов.
В модуле откликов заменить походы в доменную область данных чатов в базе на вызовы Java-сервисов (подразумеваются Java-классы). Это же касается удаления hibernate-связанных сущностей: вместо вызова связанных таблиц через foreignKey вызываем Java-сервисы чатов, которые возвращает нужные данные (как если бы у нас данные не были связаны ключами).

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

Это абстрактные Java-интерфейсы, созданные в модуле чатов, а реализация интерфейсов уже в модуле откликов. Сервис чатов вызывает их, а дальше либо получает данные об откликах (связки которых мы будем разрывать позже), либо выполняет операцию с ними.
Что даёт такой подход?
Визуальное выделение зависимости данных чатов от откликов (через интерфейсы)
Простоту контроля нового кода — новые завязки логики чатов на отклики нужно делать через интерфейсы. Это проще контролировать, особенно когда в сервисе живёт несколько команд
Результат этапа
Выделенный модуль нового сервиса
Модуль монолита, который работает с модулем чатов как с библиотекой-клиентом
Этап 2. Разделение связей с монолитным сервисом
Вторая часть проекта — разрыв логических связок модуля чатов с модулем откликов по интерфейсам, созданным ранее. Мы использовали для этого три способа.

Разрыв через интеграционный слой. Самый простой способ: интеграционный сервис ходит в разные сервисы (чаты или отклики), собирает данные, аккумулирует их и возвращает ответ.
Разрыв через event-driven подход. В этом случае изменяем архитектуру, используя событийный подход взаимодействия сервисов. Сервис чатов (пока ещё модуль) отправляет абстрактные события сервиса (например, о создании чата или отправке сообщений), а не самостоятельно выполняет какую-то логику по работе с откликами. А сервисы клиентов подписаны на эти события и асинхронно выполняют необходимые операции сами. Таким образом, ответственность за обработку доменной логике клиентов переходит под их контроль.
Разрыв через редизайн модели данных сервисов. В некоторых ситуациях модель данных сервиса не позволяет разбить связь через интеграционный слой или event driven-подход. Например, если нужно, чтобы сервис чатов изменял поведение чатов, опираясь на данные по откликам. Тогда нам требуется предоставить необходимые данные по откликам в модели данных чатов или изменять бизнес-логику. В этом случае для каждого бизнес-кейса возможны индивидуальные решения.
У разных систем разные модели данных, сервисы и требования — поставленная задача слишком индивидуальная, чтобы её обобщать. Поэтому рассмотрим конкретные примеры.
Пример: Разрыв через интеграционный слой
Один из наших примеров — получение сообщений в чате, когда часть данных приходит из доменной области откликов. Концептуально эту связь легко разорвать с помощью интеграционного слоя, который сначала получит данные по чатам, затем запросит по откликам.

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

Что теперь делать с этой связью? Наш вариант — редизайн модели данных. Мы переложим динамическую проверку в контекст чатов.
Представить её можно абстрактным функционалом блокировки:
Сервис чатов по своей базе проверяет, не заблокирован ли чат для определенного типа пользователя.
Если заблокирован — возвращаются данные о блокировке.
Блокировками управляют сервис-клиенты, в данном случае — сервис откликов. На момент превышения лимита откликов для неподтвержденного работодателя сервис соберёт все необходимые отклики, чаты которых надо заблокировать, и отправит в Kafka задачу на блокировку чатов.

Разблокировка происходит также асинхронно — после подтверждения работодателем учётной записи.
Пример: Разрыв через event-driven подход
Отдельно хочется рассказать про event-driven подход и сложности, которые он может нести как часто встречаемый паттерн в микросервисной архитектуре. Рассмотрим этот подход на функционале перерасчета данных отклика.
Отклик (сущность в базе) хранит некоторые кэшированные данные, которые зависят в том числе от изменений в чате. Например, время последнего изменения отклика для соискателя или работодателя зависит от времени последнего сообщения, количества сообщений и пр. Это нужно для отображения части этой информации в карточке отклика и для внутренней работы сервиса.
Раньше чаты при любой операции (например, при отправке нового сообщения в чат) отправляли в Kafka задачу на перерасчёт данных в отклике (Recalculation task). Но нам требуется, чтобы чаты не знали о доменной области откликов — они должны перестать сами вызывать операцию перерасчёта. Для этого можно начать с чатов слать абстрактное событие о новом сообщении в чате. Его будет слушать сервис откликов, отфильтровывать чаты откликов и сам запускать перерасчёт.

Но здесь есть две сложности:
В событии о новом сообщении в чате может оказаться недостаточно данных для выполнения перерасчета отклика; то есть — доменных данных события может оказаться недостаточно для обработки подписчиками.
Потеря контроля над управлением обработки событий. То есть мы будем теперь обрабатывать все события независимо от того, нужны ли они нам или нет, и придется отфильтровывать нужные, что в некоторых случаях бывает затруднительно из-за недостатка данных.
Разберём на примере функционала перевода отклика по статусам.
При переводе отклика в новый статус сервис откликов выполняет необходимые изменения у себя в доменной области.
Затем вызывается root код создания сообщения в чате в обход всей предыдущей логики с отсылкой в Kafka задачи на перерасчет отклика, и сервис откликов запускает перерасчет сам с специфичными данными. Что это за данные – для простоты контекста мы не будем углубляться, но они влияют на то, как ведется перерасчет.
По нашей новой архитектуре перерасчёт отклика запускается на событие о новом сообщении в чате. В итоге перерасчёт запускается дважды (см. график ниже: красная стрелка — запущенный перерасчёт напрямую при редактировании отклика сервисом откликов, синяя — перерасчёт, запускаемый на событие о новом сообщении в чате). Здесь возникает рейс кондишен, который не решается, так как инициаторы событий перерасчёта приходят из двух разных топиков в Kafka и нет точки синхронизации.

Получается, чтобы решить проблему, нужно избавиться от одного из инициаторов перерасчёта. Мы решили оставить перерасчёт только на событие о новом сообщении в чате для консистентности. Но для этого в событие ChatMessageCreated нужно прокинуть метаданные, необходимые для перерасчёта отклика.
Проблема в том, что эти данные не входят в доменную область чатов — они специфичны для клиента (откликов).
И здесь мы ввели понятие контекста события — абстрактных данных, которые прокидываются в API сервиса чатов (в данном случае при создании нового сообщения). Сам сервис чатов ничего о них не знает — ему доступно только то, что их надо взять из API и переложить в событие Kafka. Клиенты сервиса чатов, используя API чатов, могут прокидывать абстрактный контекст события, указывая необходимую информацию для последующей обработки событий Kafka. Это может быть traceId, источник операций или другие необходимые для обработки данные, ограниченные только разумным размером.
В нашем случае проблема решилась через расширение API отправки сообщений в чат (пока что это всего лишь Java метод в модуле чатов) новой мапой <String, String> eventContext. Сервис откликов прокидывал её при редактировании отклика в API создания сообщения чата, а затем использовал эти данные при выполнении перерасчёта отклика на событие о новом сообщении в чате.

Заключение этапа
Мы опирались на три основных способа разделения бизнес-логики между сервисами. Они не универсальны и не подходят для любой ситуации. Разделение логики по микросервисам — это всегда комбинация подходов, и для наших задач нам пригодились именно три.
На выходе мы получили модуль нового сервиса без контекста монолита. При этом транзакции и SQL Join-запросы мы пока не трогали.
Этап 3. Разделение SQL Join-запросов
Пока мы не разделяли связки между сервисами, которые использовали SQL Join-запросы (когда в одном SQL-запросе используются данные как чатов, так и откликов). Однако теперь необходимо решить проблему джойнов.
Мы использовали два подхода:
Редизайн модели данных, в том числе через денормализацию данных
Редизайн функциональности
Варианты с in-memory объединением и агрегатными базами данных не рассматривали — они не подходили под наши юзкейсы и были бы overkill по ресурсам. Рассмотрим пример из нашего опыта — архивацию чатов.
Каждую ночь у нас запускается процесс архивации чатов и откликов. За ночь архивируется около 2 миллионов чатов и столько же откликов. Архивация зависит как от состояния чата, так и отклика: отклик должен быть готов к архивации по своим внутренним правилам, а в чате не должно быть сообщений в течении 30 дней.
(На момент написания статьи логика уже изменилась, но это выходит за рамки темы)
Технически это выглядело так:
В модуль чатов приходит http-запрос на начало архивации. Он попадает на реплику сервиса
Реплика идёт в БД и через Join-запрос отбирает все нужные чаты на архивацию
Затем рассылает в Kafka задачи на архивацию
Асинхронно по Kafka запускается процесс архивации чатов
По архивации чата запускается асинхронно процесс архивации отклика, также по Kafka

Теперь нужно было избавиться от Join-запроса между откликами и чатами. И желательно изменить архитектурно логику, по которой сервис чатов управляет архивацией откликов.
Мы рассматривали два варианта:
Управление архивацией на стороне чатов. Сделать функционал архивации, в котором клиенты помечают чат как готовый к архивации, а сервис чатов выполняет процесс через n дней после отсутствия сообщений в нём. Таким образом исчезает необходимость в Join-запросе с данными по чатам, так как условие архивации разносится по двум сервисам: сервис откликов проверяет готовность к архивации и помечает чат по своим правилам, а сервис чатов сам архивирует чат через n дней без новых сообщений. На архивацию чата архивируется отклик, как и сейчас. Но здесь возникает сложность: у разных клиентов могут быть различные требования к архивации чатов. Например, для откликов это n дней без сообщений, для кого-то это просто n дней после пометки чата архивным и т.д. Иными словами, придется добавить и поддерживать правила архивации.
Управление архивацией на стороне клиентов. Есть путь проще. В общем случае только клиенты могут знать, когда чат больше не нужен, и только они могут дать команду на архивацию. И мы можем поменять местами инициатора архивации и заархивировать вначале отклики, а затем чаты:

В этой схеме отклики архивируются первыми и запускают архивацию чатов. Но остается нерешённым вопрос проверки отсутствия новых сообщений в течение 30 дней. И один из вариантов — это прибегнуть к денормализации данных, хранить на стороне сервиса откликов время последнего сообщения в чате.
Однако для нас это было излишне — я ранее упомянул, что у нас был функционал перерасчета данных по отклику. То есть у нас уже хранились поля последнего изменения отклика для соискателя/работодателя, которые косвенным образом включали в себя время последнего сообщения в чате. Достаточно было изменить условие архивации с «30 дней без сообщений в чате» на «отклик готов к архивации» и «отклик не изменялся в течении 30 дней».
Концептуально проблему с Join мы решили. Однако на практике возникло несколько проблем в процессе реализации, о которых хочется рассказать.
Проблема неконсистентного состояния: начало
Ранее при архивации чата (а он архивировался до отклика) в той же транзакции, где архивировался чат, в данных отклика отмечалось, что чат архивный. Это было нужно, чтобы сервис откликов понимал, в какую базу обращаться за чатами — в архивную или активную.
Теперь ситуация обратная: сначала архивируется отклик, а потом чат. Но главное: когда будет два разных сервиса, мы уже не сможем в одной транзакции архивировать чат и помечать для отклика чат как архивный. Возникает ситуация, когда отклик архивный, а чат ещё нет, и сервис откликов не знает об этом.
Слушать на стороне откликов событие архивации чата и по нему делать пометку, что чат архивный на откликах, проблему не решает: задержка между моментом, когда чат в архиве, а отклик об этом еще не знает, сохранится. Оставалось только не удалять из активной базы чат сразу после копирования в архивную базу, а делать это через время — но это усложнило бы разработку и поддержку.
Мы решили это состояние никак не обрабатывать. Для этого потребовалось бы анализировать, где отклику действительно нужен чат, а где можно обойтись имеющимися данными. И решили оставить так. Предположили, что вероятность столкнуться с проблемой минимальная: шанс открыть архивный отклик именно в момент архивации чата стремился к нулю (максимум — пара минут задержки в пике). В худшем случае пользователь увидит ошибку, а вскоре отклик снова станет доступен.

Но пока перейдём к другим изменениям.
Оптимизация процесса архивации
Раз мы решили переделывать схему архивации чатов и откликов, то заодно решили оптимизировать сам процесс.
Раньше задачи на архивацию чатов отправлялись по одной и с синхронным ожиданием отправки. Поскольку за ночь архивируется 2 млн чатов, то в Kafka отправляется 2 млн отдельных сообщений на архивацию. Это не критично, но излишне. Мы заменили их на использование батчей и стали отправлять в одном сообщении по сотне ID чатов или откликов на архивацию (напомню что обе сущности архивируются по кафке асинхронно и мы поменяли местами иници��тора процесса архивации с чатов на отклики).
Ожидание отправки в текущей схеме не сильно требуется — даже если сообщения не отправятся, процесс архивации подберёт незаархивированные отклики на следующий день.

Однако из-за ликвидации ожидания отправки в Kafka сообщений интенсивность обработки изменилась. Теперь задачи на архивацию идут батчами и асинхронно без ожидания, запуск процесса архивации за 12 секунд отправляет 2 млн ID откликов на архивацию. Эти задачи распределяются по 24 партициям в топике Kafka и начинают обрабатываться. Активную базу это не убивает, но может сказаться на рантайме операций базы (например, получение чатов или откликов). Чтобы этого не допустить, мы решили подстраховаться и ограничили количество одновременно выполняемых операций.
В Kafka нельзя удалять уже созданные партиции — только весь топик. На проде это делать не хотелось — не было точных данных, сколько партиций нужно для архивации. Поэтому мы воспользовались ключом партициирования в Kafka для регуляции нагрузки. Формально у нас оставалось 24 партиции, но отправляли задачи мы только в некоторые из них подбирая динамическими настройками сервиса оптимальное значение. Так мы подобрали оптимальный уровень параллельного выполнения архиваций.
Концептуально всё готово — запускаем на проде.
Переполнение ThreadPoolExecutor
Первый запуск был неудачным.
В коде архивации чатов использовался легаси ThreadPoolExecutor — это объект, который позволяет параллелить выполнение задач в Java. ThreadPool имел ограниченный размер очереди 16 и поток 1 — то есть он ничего не параллелил, оставался там как легаси — и в случае переполнения просто падал.

Когда батчей не было, проблем не возникало: Kafka consumer считывал 10 сообщений и клал их в очередь на выполнение. Теперь консьюмер по-прежнему считывал 10 сообщений, но в каждом сообщении находилось по 100 ID чатов на архивацию. суммарно — 1000. Это не помещалось в ограниченную очередь ThreadPoolExecutor с очередью 16. Executor переполнялся и падал, цикл повторялся, так как Kafka consumer не комитил сообщения из-за невозможности обработать.
Решение здесь простое: мы поменяли политику переполнения ThreadPoolExecutor на ThreadCallerRunPolicy. Это политика блокировки текущего треда, пока не освободятся задачи в очереди.
Вывод: не стоит использовать объекты с переполнением в очередях. Лучше заранее обрабатывать переполнения, не допуская цикличных ошибок.
Проблема неконсистентного состояния: продолжение
Следующий запуск… Снова неудача. В момент архивации поднимается фон рантайм-ошибок с API сервиса с 500 http-статусами.
Оказалось, непродолжительное неконсистентное состояние данных выстреливает заметно чаще, чем мы предполагали. Дело в том, что некоторые автоматизированные внешние клиенты запрашивают отклики постоянно с небольшим интервалом, попадая в зазор между архивацией отклика и чата (от 10 до 150 секунд).
Пришлось всё же подстраховаться и научиться возвращать отклик без чата, используя имеющиеся данные — делали деградацию возвращаемых данных. Впрочем, как станет ясно дальше, нам равно всё равно пришлось сделать поддержку возвращения отклика без данных по чату. Здесь мораль истории не в том как мы для нашего случая исправляли ошибки, а в том что не стоит полагаться на непродолжительность неконсистентного состояния данных какой бы малой вероятность ошибки не казалась.
Что хочется выделить
К проблеме разрыва операций с Join можно подойти с точки зрения редизайна модели данных. Его частный кейс — денормализация данных, кэширование части по нужным сервисам
Ошибки переполнения и в целом персистентные ошибки в очередях стоит обрабатывать
Нагрузку на сервис удобно регулировать с помощью ключа партицирования в Kafka при использовании асинхронных операций
При больших объёмах данных лучше раскатывать функционал постепенно. В тестовом окружении мы не смогли корректно протестировать сценарий, приходилось запускать на проде — и ловили 2-3 ошибки при разных запусках
Не стоит рассчитывать на непродолжительность неконсистентного состояния данных. Рано или поздно состояние выстреливает — и в нашем случае это произошло намного раньше, чем мы рассчитывали
Результат этапа
Модуль чатов и откликов готов без Join-запросов к базе между доменами.
Этап 4. Разделение транзакций: проблема распределенных транзакций
Одна из самых интересных задач при разделении сервиса — разрыв транзакционных запросов, в которых изменяются данные нескольких сервисов. В нашем случае нужно было сохранить транзакционно данные в две базы и синхронно вернуть ответ в том же http-запросе — но как это сделать?
Раньше при создании откликов по API в одной транзакции в базе создавался отклик и чат. И возвращался ответ с ID этих сущностей. После выделения сервисов базы будет две — транзакция на уровне базы исчезает, и теперь нет возможности сохранить сущности в две базы транзакционно и синхронно вернуть ответ.

Рассмотрим, почему синхронный подход сохранения сущностей нам не подходит. Есть два варианта синхронного подхода:

1. Сначала сохраняем отклик, а затем делаем http-запрос вне транзакции на сохранения чата
Если отклик сохраняется первым, но потом инстанс падает, не успевая отправить запрос на создание чата, или не может создать чат по любым другим причинам, то мы получаем отклик без чата, который теперь как то надо до создать или удалить отклик. Поведение API становится сложным и неочевидным — создание отклика никак не гарантирует, что чат тоже создан. Получается, главная проблема синхронного сохранения не решена. Попытки создать чат в рамках транзакции никак не решат проблему, а лишь породят новую: чат может быть создан, а операция сохранения отклика откатится.
2. Создаём по http чат, затем сохраняем отклик
Опять же, если сохранение отклика упадёт — получаем фантомный чат без отклика, который придётся как-то чистить.
Оба вариант ведут к требованиям в фоновых процессах, которые будут проходить по базе откликов или чатов, подчищать фантомные чаты/отклики или создавать недостающие. Это сложно и не решает основной проблемы — создать синхронно две сущности в разных базах консистентно и вернуть ответ — нельзя.
Асинхронный подход
Асинхронный подход через eventual consistency позволяет признать, что внешние сервисы должны быть готовы к созданию откликов с чатами асинхронно, с задержкой. Мы создаём отклик и в той же транзакции отправляем в Kafka задачу на создание чата через паттерн Transactional Outbox.
Паттерн работает так: в транзакции, где создаётся отклик, мы сохраняем данные в базу в отдельную таблицу — нужно отправить задачу в определённый топик Kafka с опредёленными данными (например, задачу на создание чата). В успешном кейсе после завершения транзакции инстанс отсылает в Kafka сообщение, дожидается ухода и удаляет запись из базы. Если инстанс упала, то фоновый процесс, запускаемый с некой периодичностью, подберёт задачку из базы и дошлёт сообщение. Если отклик не удалось создать, то транзакция откатиться и следовательно в Kafka ничего не отправится.
Важный нюанс: паттерн никак не гарантирует Exactly Once семантику из коробки — стоит защищаться отдельными методами.
На схеме это выглядит так:

Этот подход даёт:
Eventual consistency — если отклик создан, то чат гарантированно будет создан, пусть и с задержкой
Отказоустойчивость — сервис откликов не зависит от жизнеспособности чатов при создании или редактировании откликов
Изящную деградацию — при условии, что мы подготовим сервис откликов так, что он может функционировать без данных по чату, и сделаем деградацию данных и операций без нарушения работы всей системы откликов
Однако появляются и новые проблемы:
При создании отклика ожидается, что вернётся ID двух сущностей: чата и отклика
Нужно исключить дубликаты создания чатов в этой схеме
Сервисы ожидают, что чаты с откликами создаются в одно время и не существуют друг без друга — это надо изменить
Решение проблемы отсутствия ID чата
Как нам вернуть ID чата, если он ещё не создан? ID чата — это Long значение, которое берется из сиквенса к базе. А нам нужно его вернуть из сервиса откликов, когда чат ещё не создан.
Первое, что приходит в голову — научить сервисы жить без chatId в ответе на создание чата, а в сервисе откликов по событию создания чата проставлять его асинхронно. Но если взглянуть на карточку отклика, то там есть кнопка «перейти в чат».

Веб мы можем пофиксить как угодно, в том числе деактивировать кнопку при отсутствии chatId. А вот старые мобильные приложения уже не изменить, и они ожидают наличие chatId в ответе — если в таком случае кто-то успеет открыть карточку кандидата или отклика после отклика или приглашения, то приложение упадет. Ждать, пока пользователи обновят версию приложения, слишком долго. Получается, что проставлять chatId асинхронно нельзя.
Можно использовать uuid, проставлять chatId на клиенте (на отклике) в момент его создания и возвращать. Но во внешнем API уже завязались мобильные приложения, что chatId — это Long. И даже если мы сделаем миграцию на бэке, то перевод uuid в Long в API каким-либо алгоритмом практически наверняка породит коллизии.
Остается только один вариант. Архитектурно не самый красивый, но наиболее компромиссный в нашей ситуации — получить chatId по API на сервисе откликов, запрашивая у сервиса чатов (который резервирует значение из сиквенса к базе) при создании отклика. А его уже подставлять в задачу на создание чата по Kafka. Этот путь мы и выбрали, не меняя формат идентификатора.
Проблемы дублей чатов
Теперь поговорим про дубликаты создания чатов. Как их отсекать при создании из Kafka?
Самый простой путь — использовать ключ идемпотентности на уровне базы чатов. Его проставляет клиент (отклики). Это может быть любой ключ, который идентифицирует операцию. При создании чата этим ключом выступает chatId. Для других асинхронных операций (например, отправки сообщений) мы используем отдельное поле идемпотентности, генерируемое клиентом.
Проблема задержки создания чата
Сложность в том, что клиенты ожидают, что отклик без чата не существует.
Задержка порождает концептуально 3 проблемы:
Отклик создан, и пользователь успевает нажать на кнопку «открыть чат», пока чат ещё не создан
Пользователь делает действие с откликом, которое требует наличие чата — скажем, переводит отклик в статус «Пригласить/Отказать». Чат здесь требуется, так как на эти действия отправляется сообщение в чат
Слушатели события создания отклика по Kafka ожидают, что чат уже есть, и начинают с ним работать
Первую проблему нельзя решить на клиенте: старые версии мобильных приложений требуют, чтобы чат был, и изменить их мы уже не можем.
Для поддержки совместимости придется решать проблему на бэке. Один из вариантов — это эмулировать для клиентов, будто чат создан когда его еще нет — то что мы назвали «чат-заглушка». В момент, когда чат запрашивается по ID, интеграционный слой проверяет: если чата нет, но есть отклик, связанный с чатом, то формируется ответ из «чата-заглушки» с подставными данными, но реальным ID чата, в котором отображается сообщение условно «чат создается! подождите, пожалуйста». Когда реальный чат создается, то отрабатывает сокет-событие о новом сообщении в чате, и поскольку ID чата реальное, то заглушка автоматически обновляется реальными данными прозрачно для клиентов, которые даже не знают, что работают с ненастоящим чатом.

Вторая проблема. Как быть, если пользователь пытается совершить действие с откликом, которое требует наличия чата? Например, отправить приглашение на вакансию (на приглашение отправляется сообщение в чат).
Теоретически такие события можно отправлять в определенную очередь в Kafka, где они раскручиваются по созданию чата. Но подход требует осторожности и серьёзных изменений в архитектуре. Нужно избежать обработки события до создания чата и при этом не допустить зависания очер��ди в случае, если чата такого и не должно существовать и оно было отправлено по ошибке. То есть это должен быть единый топик с событиями на создания чата, написания сообщений в чат — и при этом надо гарантировать, что в очередь разные инстансы будут посылать события по чату последовательно, что при нашей реализации паттерна Transactional Outbox концептуально не гарантируется.
В нашем кейсе это слишком сложно: в среднем задержка между созданием отклика и чата редко превышала 50-100 мс. Для обычного пользователя это практически незаметно и трудно воспроизводимо. Исключение — автоматизированные клиенты и сбои в работе сервиса или Kafka. Поэтому мы решили ограничить такие операции и возвращать на уровне API откликов ошибку HTTP 425 TOO EARLY.
Третья проблема. Как быть с клиентами Kafka, которые реагируют на создание отклика и ожидают, что чат уже существует?
Можно перевести клиентов на событие «создание чата», но в 90% случаев таких клиентов интересуют только чаты откликов, а слушать придётся все создания чатов на сервисах.
Решение — перевод таких клиентов на новое событие «чат для отклика создан». Сервис откликов продолжает отправлять в Kafka событие о созданном отклике, но клиенты, которым нужен чат, его не слушают. Вместо этого они подписаны на новое событие, которое отправляется сервисом откликов после создания чата. Так очередь создания чатов слушается только одним сервисом, а остальные освобождаются от лишней нагрузки. На схеме это показано внизу.

Проблемы в асинхронной очереди
Отдельно хочется упомянуть про возможную проблему «застревания» очереди в асинхронной архитектуре сервисов. Невозможно полностью исключить ситуации, когда очередь встанет или лаг обработки внезапно начнёт расти: сбой в Kafka, неверный формат входящего сообщения, проблемы на стороне сервиса обработки событий — вариантов много. Но при правильном подходе последствия таких инцидентов могут быть меньше, чем даунтайм от http-ошибок сервиса при синхронной обработке и высокой связанности между сервисами.
Для минимизации последствий роста лага обработки очереди мы использовали практики:
Валидация сообщении в Kafka. Проверяем формат и, если задача не может быть обработана с такими данными (ошибка формат, дублирующиеся ключи) — пропускаем её без обработки
Если возникает неизвестная ошибка, перекладываем сообщения в retry-очередь. Откуда они будут обрабатываться, пока временная проблема не пройдет или мы не починим причину ошибки
Очень важен мониторинг состояния очереди. Необходимы уведомления, чтобы сразу реагировать, если очередь начинает расти выше обычных лимитов. для этого у нас настроены уведомления в рабочии каналы
Выводы и рекомендации из текущего этапа:
Eventual consistency через паттерн Transactional Outbox — можно использовать не как замену транзакциям, но в многих случаях транзакция и не нужна, достаточно Eventual Consistency, которая гарантирует, что действие обязательно будет выполнено, но позже
Учитывать поддержку внешних клиентов API-системы, часто они не в состоянии быстро подстраиваться под новый формат. В нашем случае это старые версии мобильных приложений: поддержка накладывает определенные ограничения на возможные архитектурные решения
Для гибкости предпочтительнее использовать строковые идентификаторы во внешнем API (которое используют внешние клиенты): в строку всегда можно положить Long, а вот наоборот — сложнее
Использование ключа идемпотентности на уровне базы при обработке событий в Kafka полезно для избегания дубликатов
Результат этапа
Два модуля, которые взаимодействуют друг с другом как два разных сервиса. Единственное исключение — отклики всё ещё ходят в чаты по Java-методам, а не REST.
Этап 5. Выделение сервиса
На последнем этапе остается финальная часть — непосредственно выделить сервис. И здесь сразу стоит сказать: мы не делали миграцию данных чатов в новую базу, оставив жить сервисы в одной базе, но как бы «в разных», не используя транзакции, Join-запросы и возможность получения данных из чужого домена. Это было связано с тем, что на момент выделения микросервиса не было четкого понимания, останемся ли мы для чатов использовать PostgreSQL или потребуются более масштабируемые хранилища в сторону NoSQL.
Выделения сервиса можно разбить на следующие этапы:
Перевести Java-вызовы из модуля откликов в модуль чатов на REST API в новый сервис
Перевести всех клиентов чатов из сервиса Negotiations в новый выделяемый сервис Chat engine
Перевести Kafka-слушателей чатов из сервиса negotiations в новый сервис Chat engine
Схема текущая схема и что хотим получить приведены ниже:


Начнем с первого пункта.
Перевод вызовов из модуля откликов к модулю чатов с Java-вызовов сервисов на REST API
Мы решили реализовать это через промежуточный этап, делая REST вызовы из сервиса монолита в себя же. Выглядит как антипаттерн, но у этого есть логика — так мы уменьшаем время код-фриза на чатах. Если хотя бы один клиент начнёт обращаться к новому сервису, придётся замораживать любые изменения в кодовой базе чатов или дублировать их на два сервиса (старый и новый), что неудобно и чревато ошибками.
Однако если мы подготовим все необходимые API и переведём внутренние Java-вызовы на них, а уже потом выделим новый сервис и переведем запросы на него, мы значительно сократим время код-фриза в кодовой базе чатов.

Процедура стандартная и в основном рутинная: однако, вероятно, потребуется значительная переделка unit- и интеграционных тестов. Важно помнить про лимиты на tomcat/jetty серверах — ограничения размера входящего запроса, Header и т.п. В нашем случае мы столкнулись с ограничением размера URL-ссылки — опасно, когда в URL в параметрах передается много ID в стиле /chats?chatId=123456&chatId=9876543… и т.д. В таком случае лучше загружать данные по частям (если это действительно необходимо).
Следующий этап — перевод клиентов на новый сервис. Вот здесь мы поднимаем новый сервис и меняем для клиентов хост обращения с сервиса negotiations на сервис Chat engine.

И последний этап — перенос подписчиков Kafka для чатов с Negotiations на Chat engine.

Здесь есть нюанс. Если переносить Kafka-подписчиков с тем же Consumer GroupID, который был до этого в старом сервисе, то не возникнет никаких проблем. Весь перенос — это просто перенос кода. Но если менять GroupID, то возникает проблема. В рамках одного GroupID чтение сообщений в Kafka топике распределяется по подписчикам, но если две отдельные группы подписчиков, то каждая группа читает все сообщения в топике Kafka. В нашем случае в GroupID зашивается имя сервиса — это общая практика в компании. Можно было бы сделать исключение и использовать старый GroudID в новом сервисе, но это обходной путь, о котором потом легко забыть зачем сделали именно так. А следовательно нам надо создать подписчиков с новым GroupID, и две консьюмер-группы будут обрабатывать все сообщения, дублируя обработку, пока старая консьюмер-группа еще существует.
Чтобы это сделать, мы должны гарантировать, что повторная обработка событий не приведет к нарушению работы логики — и здесь как раз пригодятся ключи идемпотентности на асинхронные события. Все события в Kafka на обработку у нас имели ключи идемпотентности или повторная обработка события не была критичной. Поэтому можно было дублировать события и обрабатывать их на обоих консьюмер-группах. Чтобы минимизировать нагрузку от дублирования, мы добавили две динамические настройки в сервис чатов и сервис откликов: они позволяют слушать события в Kafka, но пропускать их обработку. И, следовательно, включили обработку на chat-engine. И, убедившись, что он начал обрабатывать события, отключили обработку на Negotiations без релизов сервисов.
Результат: выделенный микросервис чатов, работающий независимо от модуля откликов.
Итоги
Мы получили отдельный микросервис, способный развиваться независимо на собственном стеке
Повысили отказоустойчивость системы за счёт снижения связанности откликов от чатов
Реализовали изящную деградацию сервисов между чатами и откликами
Получили возможность масштабировать чаты отдельно от откликов
Рекомендации на основании нашего опыта
Выделять микросервис через промежуточный модульный этап — если нет цели полной переписки сервиса
Визуализировать взаимосвязи выделяемого сервиса от монолита (для лучшего понимания связи доменных областей данных) — в нашем случае это было сделано через Java-интерфейсы
Реструктурировать доменные данные сервисов — наверняка понадобится, чтобы представить данные в другой доменной модели, например вынести в другой сервис
Использовать event-driven подход для инверсии контроля с выделяемого сервиса на сервисы-клиенты, оставляя клиентам возможность реагировать на события самим, а не управлять логикой выделяемого сервиса
Использовать eventual-consistency через паттерн Transactional outbox как альтернативу транзакциям
Общие рекомендации из нашего опыта
Использовать строковые форматы ID во внешнем API, если нет железной уверенности, что это никогда не понадобится (не между сервисами внутри системы, а на уровне контрактов с внешними клиентами). Не очень экономно с точки зрения объёма данных, но оставляет гибкость при смене архитектуры
Не полагаться на непродолжительность неконсистентного состояния данных. Практика показывает, что даже если вероятность/продолжительность ситуации мала — рано или поздно (и скорее рано) оно выстрелит
Полезно использовать ключи идемпотентности — для избавления от возможных дубликатов, особенно при асинхронной обработке
Обрабатывать персистентные ошибки в очередях — неконсистентные данные, неправильный формат и прочее
Ключ партицирования может быть полезным для регулировки нагрузки при асинхронной обработке (если архитектура позволяет)
Заключение:
Выделение микросервисов, как и в целом любая архитектурная работа – процесс творческий и зависит от многих факторов: стека, текущей архитектуры, ресурсов, целей и т.д. Поэтому не стоит относиться к нашим рекомендациям как к руководству по выделению. Но возможно, именно наш опыт станет для кого-то рабочим инструмент под конкретную ситуацию.
А как в вашей компании выделяли микросервисы из монолита? Буду рад, если поделитесь вашим опытом в комментариях!