Всем привет, меня зовут Сергей Прощаев. Я Tech Lead и руководитель направления Java/Kotlin разработки в FinTech и E‑commerce, а ещё преподаю на курсах архитектуры и разработки в OTUS. Сегодня хочу поговорить о том, на чём спотыкаются почти все, кто начинает строить микросервисную архитектуру с очередями, — о канале «точка‑точка» (Point‑to‑Point Channel), семантике доставки и скрытых граблях, которые превращают надёжную интеграцию в источник ночных инцидентов.

Представьте: вы запускаете сервис обработки банковских транзакций. Нагрузка растёт, вы добавляете второй экземпляр, чтобы очередь разбиралась быстрее. И вдруг клиенты сообщают о двойных списаниях, а в логах одна и та же транзакция выполняется дважды. Вы лихорадочно проверяете конфигурацию брокера и осознаёте: вместо канала с семантикой «одно сообщение — один получатель» вы использовали publish‑subscribe модель, и теперь каждый consumer получает полную копию сообщения.
Знакомая картина? За годы работы с распределёнными системами я наступил на все возможные грабли, связанные с каналами сообщений, и сегодня разложу по полочкам типичные ошибки и покажу, как правильно строить надёжную доставку.
Ошибка 1: «Я просто добавлю ещё один подписчик»
Самое частое заблуждение — считать, что любое масштабирование слушателей решает проблему производительности. Когда очередь растёт, разработчик добавляет consumers, и если под капотом используется Point‑to‑Point Channel, это действительно работает: каждый новый consumer становится конкурирующим потребителем (Competing Consumer), и сообщения распределяются между ними — одно сообщение достаётся только одному. Но если изначально был настроен канал с семантикой publish‑subscribe, каждый новый подписчик получает свою копию каждого сообщения. Так рождаются дублирующиеся платежи, повторные отправки писем и задвоенные складские операции.
Важнейший нюанс, который надо понимать: модель Queue/Topic радикально отличается в разных брокерах.
В классическом JMS Topic — это всегда publish‑subscribe: каждый подписчик получает копию. Хотите конкурирующих потребителей — используйте Queue. Достаточно перепутать createQueue и createTopic — и модель доставки меняется кардинально:
// JMS: так делать нельзя, если нужна доставка одному получателю Topic topic = session.createTopic("orders"); MessageProducer producer = session.createProducer(topic); producer.send(message); // каждый подписчик получит копию
А здесь мы используем класс Queue:
// JMS: правильный вариант для команд, выполняемых однократно Queue queue = session.createQueue("orders"); MessageProducer producer = session.createProducer(queue); producer.send(message); // сообщение получит только один consumer
В Kafka Topic с consumer group — это фактически модель конкурирующих потребителей: сообщение обрабатывается одним consumer в пределах партиции. Поэтому для команд ключ партиционирования должен обеспечивать маршрутизацию всех сообщений одного бизнес‑объекта в одну партицию. В RabbitMQ модель competing consumers достигается связкой exchange → queue → consumers. SQS и NATS реализуют схожую модель competing consumers, но с иными гарантиями доставки.
Ошибка возникает не из‑за Topic как такового, а из‑за смешения семантики команд и событий. Именно путаница «отправляю команду» vs «публикую событие» приводит к дублированию.
Пример из классики: в системах биржевой торговли запрос на покупку или продажу ценных бумаг — это команда, которая должна быть выполнена ровно одним экземпляром сервиса. Если команда уйдёт в канал с publish‑subscribe, её получат все торговые роботы, и на биржу улетит несколько одинаковых поручений. Поэтому архитекторы биржевых платформ всегда используют канал с семантикой «одному потребителю» для таких команд.
Ошибка 2: «Брокер подтвердил — значит, всё в порядке»
Вторая системная проблема — настройка режима подтверждения. В JMS по умолчанию часто стоит AUTO_ACKNOWLEDGE. Многие думают, что брокер считает сообщение доставленным сразу при получении, но по спецификации JMS подтверждение связано с успешным завершением обработки сообщения, однако конкретный момент ack зависит от реализации провайдера и настроек prefetch. Если consumer падает во время обработки, сообщение может быть переотправлено — это не DUPS_OK, но и не «подтвердили — всё».
Правильный подход для критичных операций — ручное подтверждение (CLIENT_ACKNOWLEDGE) или транзакционные сессии. Сообщение подтверждается только после того, как бизнес‑логика успешно отработала и зафиксировала результат в базе:
Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE); MessageConsumer consumer = session.createConsumer(queue); Message msg = consumer.receive(); try { processMessage(msg); // бизнес-логика msg.acknowledge(); // подтверждаем только после успешной обработки } catch (Exception e) { // подтверждения нет — сообщение вернётся в очередь }
Без такого паттерна вы рискуете потерять данные при любом внезапном отказе — особенно если брокер настроен на auto‑ack, а consumer упал сразу после получения.
Ошибка 3: Ни одна очередь не даёт exactly‑once из коробки
Здесь нужно сказать прямо: Queue реализует модель competing consumers, при которой сообщение передаётся одному потребителю за попытку доставки, но не означает, что:
не будет redelivery;
не возникнет дублирование при сбое сети или ребалансировке consumers;
сообщение обработается ровно один раз без дополнительных мер.
В распределённых системах exactly‑once — это иллюзия, требующая усилий с обеих сторон: брокера и consumer. Поэтому для критичных операций обязательна идемпотентность на уровне бизнес‑логики.
Что это значит на практике:
Используйте idempotency key — уникальный идентификатор операции.
Храните обработанные ключи в базе данных или Redis с TTL, а проверку и сохранение выполняйте атомарно.
При повторной доставке consumer должен распознать дубликат и просто подтвердить сообщение, не повторяя бизнес‑операцию.
// Продакшен-вариант идемпотентного consumer — атомарный захват ключа String idempotencyKey = message.getStringProperty("idempotencyKey"); if (idempotencyRepository.tryAcquire(idempotencyKey)) { processMessage(message); // выполняем только если ключ захвачен } message.acknowledge(); // подтверждаем независимо от исхода
В связке с transactional outbox и дедупликацией на уровне брокера (где поддерживается) это даёт семантику, близкую к exactly‑once. Без этого — вы просто надеетесь на удачу.
Ошибка 4: Отсутствие канала для «плохих» сообщений
Даже при идеальной валидации рано или поздно в очереди оказывается сообщение, которое consumer не может обработать: повреждённый формат, несуществующий идентификатор, просроченный токен. Если просто игнорировать ошибку и подтверждать получение — данные потеряются. Если возвращать в очередь — оно будет бесконечно циркулировать, создавая «отравленную» очередь и парализуя обработку.
В реальных продакшен‑системах Dead Letter Queue настраивается на уровне брокера: retry policy, backoff, poison message handling. Consumer не должен сам решать, когда сообщение «плохое» — это делает брокер после исчерпания лимита повторных попыток. В JMS это можно реализовать вручную, но правильнее использовать встроенные механизмы брокера, которые мы настраиваем при развёртывании очереди.
Ошибка 5: Отказ от конкурирующих потребителей ради «строгого порядка»
Иногда команды, боясь нарушения очерёдности, запускают строго один consumer и отказываются от масштабирования. Point‑to‑Point не гарантирует глобальный порядок без дополнительных усилий. Если вам критичен порядок, используйте партиционирование по ключу (например, ID клиента в Kafka), а внутри партиции держите одного потребителя. Для остальных сценариев смело добавляйте конкурирующих потребителей — это основной паттерн горизонтального масштабирования обработки очередей.
Лучшие практики, которые мы внедрили в своих проектах
На основе пережитого я сформулировал несколько правил, которые сейчас использую как чек‑лист при проектировании интеграций.
Разделяйте каналы по типам данных (Datatype Channel). Не валите команды, события и запросы в одну очередь. Для каждого типа сообщения — свой канал, чтобы consumers не ломались на неподходящем формате. У нас был случай, когда в очередь заказов вдруг начали попадать heartbeat‑сообщения от мониторинга — consumer тупо падал с ClassCastException. Разделение каналов решило проблему.
Выбирайте канал под семантику сообщения. Команды — в канал, где сообщение обрабатывается одним потребителем в рамках своей модели доставки (JMS Queue, Kafka Topic + consumer group, RabbitMQ queue). События — в publish‑subscribe. Это архитектурное разделение, а не «Queue хороший, Topic плохой».
Используйте гарантированную доставку избирательно. Для платёжных поручений — постоянное хранение (PERSISTENT) и транзакционность. Для потоковых котировок — допустимо NON_PERSISTENT, потому что скорость важнее, а потеря одного тика некритична.
// Отправка критичного сообщения с гарантированной доставкой producer.send(message, DeliveryMode.PERSISTENT, Message.DEFAULT_PRIORITY, Message.DEFAULT_TIME_TO_LIVE);
Обязательно настройте Dead Letter Queue и алертинг. Эта пара спасла меня в одном fintech‑проекте, когда поставщик данных внезапно изменил формат сообщения. Все «кривые» сообщения ушли в DLQ, основной поток продолжал работать, а мы спокойно поправили десериализатор.
Мониторьте глубину очереди и возраст сообщений. Если очередь растёт быстрее, чем обрабатывается, — это ранний сигнал, что нужна ещё одна реплика consumer или оптимизация кода.
Ошибка |
Признак / Симптом |
Что проверить в своей системе |
Использование publish‑subscribe для команд |
Дубли операций при добавлении новых потребителей |
Используется ли Queue (JMS) / consumer group (Kafka) для команд? |
Надежда на AUTO_ACKNOWLEDGE |
Потеря сообщений при падении consumer'а |
Настроен ли CLIENT_ACKNOWLEDGE или транзакционная сессия? |
Отсутствие идемпотентности |
Повторная обработка при ребалансировке/сбоях |
Есть ли idempotency key и атомарная проверка в БД/Redis? |
Нет Dead Letter Queue |
«Отравленные» сообщения циркулируют, блокируя очередь |
Настроена ли DLQ с retry policy и backoff на уровне брокера? |
Отказ от конкурирующих потребителей |
Очередь не масштабируется, растёт latency |
Используются ли competing consumers? При необходимости — партиционирование. |
Реальная история: как publish‑subscribe для команд стоил 312 двойных списаний
Как‑то раз меня пригласили на аудит архитектуры платёжного сервиса небольшого финтех‑стартапа. Команда гордилась, что они «легко масштабируются»: добавили второй экземпляр сервиса, и всё завелось. Но через час после деплоя в службу поддержки посыпались обращения от пользователей о двойных списаниях.
Оказалось, разработчики использовали JMS Topic — publish‑subscribe — для команды «executePayment», полагая, что сообщения будут автоматически распределяться. В реальности каждый экземпляр получал полную копию, и 312 платежей за час были проведены дважды. Инцидент разруливали всю ночь: экстренно переключили на Queue, добавили идемпотентность на уровне сервиса и написали скрипт возврата дублей. С тех пор я всегда начинаю ревью архитектуры с вопроса: «Покажите, какие каналы вы используете, под какую семантику и почему».
Схема правильной архитектуры
Описанные практики укладываются в простую, но надёжную схему: один канал «точка‑точка», несколько конкурирующих потребителей, отдельный канал для ошибочных сообщений и идемпотентная обработка (рис. 2).

На рисунке 2 показано, как издатель отправляет команду в канал «точка‑точка». Конкурирующие consumers параллельно разбирают очередь, каждый с идемпотентной обработкой. Проблемные сообщения через retry policy брокера попадают в Dead Letter Queue, не блокируя основной поток.
А вот типичный ошибочный сценарий, когда команду отправляют в канал с publish‑subscribe семантикой, и сообщение разлетается всем слушателям:

На рисунке 3 показано, как издатель отправляет сообщение в канал с семантикой publish‑subscribe. Оба consumers получают одно и то же сообщение и обрабатывают его независимо — это неминуемо ведёт к дублированию действий.
Давайте подведём итоги
Канал «точка‑точка» реализует модель competing consumers, при которой сообщение обрабатывается одним потребителем за попытку доставки. Правильный выбор семантики канала под тип сообщения (команда vs событие), ручное подтверждение, Dead Letter Queue, разделение по типам данных и обязательная идемпотентность превращают хрупкую интеграцию в предсказуемую и масштабируемую систему.
Если вы прямо сейчас проектируете обмен сообщениями между сервисами, проведите простой чек: какая семантика у канала, как обрабатываются ошибки, что будет при redelivery и есть ли идемпотентность на уровне бизнес‑логики?
Эти вопросы — минимальный чек‑лист, с которого стоит начинать проектирование любой интеграции на очередях. На самом деле все эти ошибки проверяют один навык: умение различать семантику доставки (команда vs событие) и проектировать обработчики с учётом реальных гарантий брокера.

Если тема очередей в микросервисах для вас сейчас не абстрактная архитектурная теория, а часть реальной продакшен‑задачи, приходите на бесплатные открытые уроки OTUS.
Уроки проходят в рамках онлайн‑курсов, их ведут преподаватели‑практики. На уроках можно познакомиться с экспертами, протестировать формат обучения и задать вопросы по своим сценариям.
8 июня, 19:00 — «RabbitMQ vs Kafka. Как выбрать подходящий брокер сообщений?»
Разберём, чем отличаются RabbitMQ и Kafka, в каких задачах каждый брокер сильнее и как выбрать подходящий инструмент под архитектуру проекта.15 июня, 20:00 — «Системы обмена сообщениями: RabbitMQ и Kafka»
Поговорим о принципах работы брокеров сообщений, очередях, событиях, доставке данных и типичных сценариях использования RabbitMQ и Kafka в распределённых системах.16 июня, 20:00 — «Использование брокера сообщений Apache Kafka в распределенных очередях»
На уроке рассмотрим, как Kafka помогает строить асинхронную обработку данных, распределённые очереди и устойчивые интеграции между сервисами.
А если хотите системно прокачаться в проектировании микросервисов, API, интеграций и распределённых систем — загляните в каталог курсов OTUS по архитектуре. [В каталог]
Там собраны программы для тех, кто хочет разбираться не только в отдельных технологиях, но и в инженерных решениях, на которых держится production.