Всем привет, меня зовут Сергей Прощаев. Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech, а также преподаю на курсах по архитектуре и разработке в OTUS. Сегодня я открываю серию статей, посвященных, наверное, самому важному элементу в распределенных системах — каналам обмена сообщениями.

Мы часто говорим о брокерах: Kafka, RabbitMQ, ActiveMQ. Мы спорим о форматах данных: JSON, Protobuf, Avro. Но, по моему опыту, настоящая архитектурная проблема почти всегда начинается с неправильно спроектированного канала. Вы просто выбрали не тот тип взаимодействия, и ваша система, которая красиво выглядела на диаграмме, на проде превращается в монстра с непредсказуемым поведением. Давайте разберемся, как не попасть в эту ловушку.

Почему канал — это не просто труба

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

Главное, что нужно понять: приложение, которое отправляет данные, может вообще не знать, кто именно на другом конце. И в этом сила. Мы не связываем системы жестко, как это происходит при REST‑запросах, где мы должны знать URL получателя, его доступность и формат. Вместо этого мы постулируем: «Я публикую событие о создании заказа в канал new-orders». И на этом моя работа как отправителя закончена. Выбор канала и есть неявное указание получателя.

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

Две дилеммы, с которых начинается архитектура

Первая дилемма — это статичность набора каналов. Должны ли разработчики заранее знать, куда слать и откуда читать сообщения? В 95% случаев — да. Это не баг, а фича. Когда я проектирую платежный шлюз в своем проекте, я должен быть уверен, что запрос на проведение транзакции упадет именно в канал payment-execution, а ответ придет в payment-callback. Мы фиксируем эту топологию на этапе проектирования. Спонтанное создание каналов во время выполнения — почти всегда прямой путь к орфанным сообщениям, которые никто никогда не прочитает.

Исключения есть, но они лишь подтверждают правило. Например, шаблон «Запрос‑Ответ» (Request‑Reply), который мы подробно разберем в одной из будущих статей. Там отправитель запроса может динамически создать временный канал для ответа и передать его адрес в заголовке сообщения. Это элегантно, но это частный случай, а не общая практика.

Вторая, еще более коварная дилемма: кто определяет набор каналов? Система или приложение? Вот вам реальный кейс из моей практики. Мы внедряли новую функцию в большой продукт. Команда просто добавила еще один канал для своих нужд в общую шину. Разработчики были довольны — они решили свою задачу. А через месяц мы получили дикий поток сообщений (Message Storm), потому что на этот новый канал никто не подписался, а хранились они вечно. Прод упал. Не потому, что код был плохой, а потому, что архитектор не задал на старте простой вопрос: «Кто будет потребителем этих данных?»

Набор каналов в системе должен рождаться в итеративном процессе, а не назначаться сверху. Приложения говорят: «Мне нужен такой‑то канал для такой‑то цели», и он создается. Но когда приходит новое приложение, оно сначала должно изучить уже существующую топологию, а не плодить свою. Это культура совместного использования ресурсов.

Однонаправленные каналы: почему это не баг, а основа асинхронности

Этот вопрос до сих пор вводит в ступор новичков. «Как так, канал — это же труба, данные должны ходить туда‑сюда!» — говорят мне на собеседованиях. И это ошибка мышления.

Канал сообщений — это не сокетное соединение. Это логическая ёмкость, данные в которой текут в одну сторону. Если вы попытаетесь использовать один канал и для запросов, и для ответов без механизма корреляции, система перестанет понимать, какой ответ какому запросу соответствует. Я видел, к чему это приводит на проде. Разработчик использовал общую очередь для команд и ответов, а correlation ID не проставил. Сервис хватал первое попавшееся сообщение, и в половине случаев это был чей‑то ответ, а не новый запрос. Внешне всё работало, но часть операций выполнялась дважды, а часть терялась. Мы потратили полночи, пока восстанавливали консистентность данных и отлавливали дубликаты.

Правило простое: для двустороннего взаимодействия между сервисами A и B нужно два однонаправленных канала. Один для сообщений от A к B, другой — от B к A. Это делает поведение системы предсказуемым.

Точка‑Точка vs. Публикация‑Подписка: самый дорогой выбор

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

Если я, как отправитель, помещаю данные в канал, и мне критически важно, чтобы их обработал один и только один получатель, я использую канал по принципу «Точка‑Точка» (Point‑to‑Point Channel). В JMS он реализован через концепцию Queue (очередь), а в мире Kafka, например, через consumer group, где одна партиция читается одним потребителем в группе.

Вспомните классический пример: система биржевой торговли. Когда клиент нажимает кнопку «Купить», сообщение о покупке акций должно уйти в обработку ровно один раз. Мы не можем допустить, чтобы один и тот же ордер на покупку был исполнен дважды. Канал «Точка‑Точка» дает нам эту гарантию. Он же лежит в основе шаблона «Конкурирующие потребители» (Competing Consumers), который позволяет нам масштабировать обработку, просто добавляя новые экземпляры сервиса‑получателя. Это основа построения высоконагруженных воркеров, которую я использую в FinTech‑проектах.

Теперь другой сценарий. Мы совершили ту самую сделку по покупке акций. Об этом событии нужно оповестить всех: клиентский интерфейс (чтобы обновить баланс), риск‑менеджмент (чтобы пересчитать риски), отдел compliance (чтобы залогировать операцию), сервис нотификаций (чтобы отправить пуш). Если я отправлю это событие в канал «Точка‑Точка», его получит только один, самый быстрый потребитель. Остальные просто промолчат. Это провал!

Здесь в игру вступает канал «Публикация‑Подписка» (Publish‑Subscribe Channel). Он работает как шина оповещений о событиях. У него один логический вход, но брокер доставляет копию сообщения каждому активному подписчику — каждый получает свой экземпляр и подтверждает его независимо от остальных.

Я хочу подсветить одну Best Practice, которая родилась из реальных проектов. Это использование символов подстановки (wildcards) в именах каналов публикации‑подписки. Предположим, у нас есть иерархия каналов для заказов:

  • MyCorp/Prod/OrderProcessing/NewOrders.

  • MyCorp/Prod/OrderProcessing/CompletedOrders.

Приложение риск‑менеджмента, которому важно отслеживать вообще все, что происходит с заказами, может подписаться на MyCorp/Prod/OrderProcessing/* и получать сообщения из всех дочерних каналов. При этом издатели всегда публикуют в точно заданный физический канал и ничего не знают о подписчиках. Такая топология безумно упрощает жизнь, когда проект начинает расти, и количество подписчиков увеличивается с 2 до 200.

Однако у правила «один ко многим» есть и темная сторона — хранение. В «Точка‑Точка» сообщение удаляется сразу после получения одним потребителем. В «Публикация‑Подписка» каждый подписчик двигается в своём темпе — один может уйти вперёд, второй отстать, третий вообще лечь. Брокер хранит сообщения для каждого подписчика независимо, и если не настроить политики очистки (retention), отставший потребитель может привести к переполнению диска. Именно поэтому Message Expiration и retention limits — не галочка для аудита, а критичный элемент архитектуры, без которого ваш брокер превратится в бомбу замедленного действия.

Вот как можно визуализировать ключевое архитектурное различие между двумя типами каналов:

Рис. 1 Канал "Публикация-Подписка" (Topic)
Рис. 1 Канал «Публикация‑Подписка» (Topic)

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

Рис. 2 Канал "Точка-Точка" (Queue)
Рис. 2 Канал «Точка‑Точка» (Queue)

На рисунке 2 изображён канал «Точка‑Точка». Издатель отправляет торговый ордер в очередь, и на этом его работа завершена. Система гарантирует, что сообщение получит ровно один из двух исполнителей — тот, кто первым успеет его захватить. Именно этот механизм исключает двойное исполнение одного и того же ордера и лежит в основе шаблона «Конкурирующие потребители», позволяя безопасно масштабировать обработку добавлением новых экземпляров сервиса.

На третьей диаграмме (рис. 3) показан путь сообщения при включённой гарантированной доставке. Отправитель получает подтверждение от брокера только после того, как сообщение надёжно записано на диск самого брокера. С этого момента ответственность за сохранность берёт на себя брокер: он будет хранить сообщение и повторять попытки доставки до тех пор, пока потребитель не подтвердит обработку. Это даёт уверенность, что данные не потеряются, но за надёжность приходится платить производительностью.

Заключение и анонс следующей статьи

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

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

В следующей статье мы глубоко нырнем в наш первый конкретный шаблон — канал «Точка‑Точка» (Point‑to‑Point Channel). Разберем, как он реализован, и расскажу, куда пропадают сообщения, которые никто не смог обработать.

Если вы хотите научиться развивать системное мышление и проектировать интеграции, приглашаю вас на открытые уроки в OTUS, которые регулярно проходят на курсах по архитектуре.

8 июня, 19:00«RabbitMQ vs Kafka. Как выбрать подходящий брокер сообщений?»
Продолжение темы выбора канала: когда нужна очередь, когда событийная модель и как не ошибиться с брокером.

15 июня, 20:00«Системы обмена сообщениями: RabbitMQ и Kafka»
Практический разбор того, как паттерны Point-to-Point и Publish-Subscribe работают в RabbitMQ и Kafka.

16 июня, 20:00«Использование брокера сообщений Apache Kafka в распределенных очередях»
Урок про Kafka, consumer groups и распределённую обработку сообщений без дублей, потерь и хаоса в каналах.

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

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