Всем привет, меня зовут Сергей Прощаев. Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce, а ещё преподаю на курсах разработки и архитектуры в OTUS.
Сегодня разберу ситуацию, в которой оказывался почти каждый, кто строил highload‑системы: один сервис начинает сыпать событиями быстрее, чем другой успевает их переваривать.
Представьте: сервис Y генерирует неограниченный поток событий — например, логи от тысяч устройств, клики пользователей или изменения в БД. А сервис X способен обработать не больше 500 RPS (или 60 RPM — для простоты возьмём RPS). Требование: сохранить порядок обработки в соответствии с очерёдностью поступления (FIFO). Кандидаты на архитектурное собеседование часто сразу предлагают очередь. Мол, ставим брокер сообщений — и дело в шляпе.
Но дьявол — в деталях: какой брокер? как гарантировать порядок в распределённой системе? и что делать с overflow‑событиями, когда очередь переполнена?

Я не раз видел, как на этом месте в стопор заходили даже сильные команды. Поэтому сегодня — полноценная диагностика. Даю условие, вы пробуете решить сами, а потом разберём правильный ход мысли, лучшие практики и одну реальную историю из боевого опыта.
Условие задачи
Дано:
Сервис Y (источник) — производит события. Скорость непредсказуема, может достигать 10 000 событий/сек.
Сервис X (приёмник) — потребляет события. Его максимальная пропускная способность: 500 событий в секунду. Превышение приводит к ошибкам, троттлингу или падению.
Требование: порядок обработки должен совпадать с порядком поступления. Для всей системы или внутри логического ключа? Пока уточним — «глобальный порядок».
Дополнительно: события нельзя терять. Под «не терять» в этой статье мы понимаем durable storage и возможность последующего replay, а не обязательную немедленную успешную обработку каждого сообщения.
Вопрос (попробуйте ответить, прежде чем читать дальше):
Какое архитектурное решение вы выберете, чтобы гарантировать порядок, не превысить лимит X и обработать переполнение?
Запишите свой вариант. А теперь посмотрим, какие ошибки чаще всего совершают.
Эту задачу удобно использовать как самопроверку. Похожий формат есть во вступительном тесте по Highload Architecture: он помогает увидеть, где проседает понимание очередей, ordering, backpressure и идемпотентности.
Типовые неправильные ответы (и почему они не работают)
Ошибка 1. «Поставим RabbitMQ classic queue — и всё»
Классическая очередь RabbitMQ на одном узле действительно даёт FIFO. Поведение при переполнении зависит от политики очереди и flow‑control настроек: брокер может блокировать publisher, paging’овать сообщения на диск или отклонять публикации (с опциональной отправкой в DLX). Однако при длительном накоплении больших очередей и требовании строгого порядка операционная сложность обычно растёт быстрее, чем у Kafka. RabbitMQ отлично подходит для transactional messaging и умеренных backlog’ов, но не для нашего сценария с глубоким overflow.
Ошибка 2. «Kafka с одной партицией решит всё»
Kafka с одной партицией — да, порядок внутри партиции гарантирован. Но проблема overflow: когда накапливается миллион необработанных событий, а retention настроен на сутки — мы просто храним их, но потребитель всё равно читает со скоростью 500 RPS. Ограничения нет! Если X не успевает, очередь будет расти бесконечно. Kafka управляет переполнением через retention policy (время или размер лога), а не через выборочное удаление отдельных сообщений. Поэтому при агрессивном retention вы рискуете потерять часть необработанного диапазона событий целиком, что нарушит требование «не терять».
Ошибка 3. «Redis Streams + потребитель на 500 RPS — быстро и просто»
Redis Streams хорош для низких задержек, но его работа с долгим хранением больших очередей — болезненна. Проблема не в самой persistency (AOF/RDB есть), а в memory pressure, стоимости trimming и операционной сложности при глубоком backlog. При переполнении (maxlen) вы можете указать стратегию усечения, но она либо убивает производительность, либо теряет порядок. Не наш метод.
Два подхода к порядку: строгий FIFO и per‑key ordering
Прежде чем двигаться дальше, важно признать: глобальный FIFO в распределённой системе почти всегда означает отказ от горизонтального масштабирования обработки (single writer bottleneck). Это фундаментальный компромисс, а не ограничение брокера. Чем строже требования к порядку, тем ниже потенциальный ceiling горизонтального масштабирования системы.
Опытные команды различают два типа требований:
Вариант A — Strict global FIFO
Все события во всей системе обрабатываются в точности в порядке поступления.
Следствие: нельзя выборочно перемещать часть сообщений в DLQ (нарушит порядок).
При ошибке обработки одного события — всё останавливается (retry, остановка consumer, parking queue с полной остановкой downstream, затем replay всего диапазона).
Один из типовых вариантов реализации — Kafka с одной партицией и одним логическим consumer pipeline. Но это не единственный способ (можно использовать transactional outbox, log-based sequencer или сериализацию через БД).
Применимо только в системах с очень низкой нагрузкой или там, где порядок жёстко задан регулятором (например, некоторые финансовые транзакции).
Вариант Б — Per‑key ordering (порядок внутри ключа)
Порядок гарантируется только для событий одного entity (accountId, orderId, userId).
Разные entity обрабатываются параллельно.
Следствие: DLQ допустим, но с оговорками (см. ниже).
Это реалистичный выбор для 99% highload‑систем. В статье я буду называть его business FIFO (per‑key ordering).
В этой статье мы сперва разберём строгий подход, а затем покажем, где и как можно использовать DLQ без нарушения гарантий.
Правильный ход мысли: два пути
Путь 1. Strict global FIFO (без DLQ)
Если бизнес требует абсолютного порядка и не допускает выноса сообщений в отдельную очередь, то:
Выбираем Kafka с одной партицией (или другой механизм единой сериализации).
Не используем DLQ для выборочного перемещения.
При проблемах с конкретным сообщением — останавливаем consumer (пауза в
KafkaConsumer), делаем ретраи с экспоненциальной задержкой.Если ошибка не исправляется, переводим consumer в parking mode: все новые сообщения остаются в очереди, потребление не идёт, пока оператор не вмешается.
Для борьбы с переполнением — только backpressure на источник (о нём ниже).
Никакого selective DLQ — это железное правило.
Путь 2. Per‑key ordering (business FIFO), DLQ допустим с уточнениями
Если требование звучит как «порядок важен в рамках одного заказа/пользователя», то:
Используем Kafka с партиционированием по ключу (например,
accountId). Порядок сохраняется внутри партиции (а значит, внутри ключа).Разные ключи могут обрабатываться параллельно разными consumer’ами.
При ошибке для одного ключа мы можем переместить сообщения этого ключа в DLQ, но:
Важное уточнение: такой подход (commit offset после отправки в DLQ) допустим только если бизнес позволяет временно пропустить проблемное событие внутри ключа с последующим replay/reconciliation. Например, для аналитики или нечувствительного к causality лога.
Если же causal ordering внутри ключа критичен (баланс счета, резервирование товара, конечный автомат платежа), consumer должен приостанавливать обработку именно этого ключа до успешного retry.
Нюанс реализации в Kafka: методpause()работает на уровне партиции, а не на уровне ключа. Если один ключ «залип», а партиция общая, то другие ключи внутри той же партиции тоже остановятся. Чтобы локализовать влияние, команды иногда используют key‑aware executors или более мелкое партиционирование. Это важный operational nuance, который нужно учитывать.
Восстановление: отдельный воркер читает DLQ и пытается переобработать. Порядок внутри ключа в DLQ сохраняется, если при перемещении оставлять тот же ключ и ту же партицию (через
ProducerRecordс явной партицией).
Ниже показана схема для варианта Б при условии, что бизнес допускает временный пропуск проблемного события (рис.2).

Главная мысль, которую следует вынести из диаграммы: при порядке внутри ключа DLQ возможен, но только если вы осознаёте, когда это безопасно, а когда нужно останавливать обработку партиции (с побочным эффектом блокировки всех ключей в ней).
Ограничение скорости продюсера: правда о Kafka Quotas
Вернёмся к моменту с квотами. Kafka quotas ограничивают:
байты в секунду (network throughput),
запросы в секунду (request rate),
но не бизнес‑RPS.
Одно сообщение может быть 10 байт, а может — 10 МБ. Поэтому полагаться только на квоты для контроля именно количества событий — ошибка.
Как правильно:
Используйте token bucket на стороне приложения-продюсера. Условно: разрешаем 550 токенов в секунду, каждый вызов забирает 1 токен. Если токенов нет — продюсер ждёт или буферизует.
Kafka quotas оставьте для защиты брокера от перегрузки по байтам/запросам — это дополнительный, но не основной механизм.
Для контроля бизнес‑RPS на продюсере реализуйте token bucket или leaky bucket на клиенте. Kafka quotas — это защита от abuse по трафику, а не точный rate limiter событий.
Что такое head‑of‑line blocking и почему это важно
Один из самых болезненных эффектов в FIFO‑системах — head‑of‑line blocking. Представьте: первое сообщение в очереди требует вызова медленного внешнего API с таймаутом 30 секунд. Все последующие события (сотни, тысячи) будут ждать, пока это одно обработается или упадёт. Порядок сохраняется, но пропускная способность падает катастрофически.
Как с этим бороться:
В strict FIFO — только увеличение скорости обработки одного сообщения, асинхронная передача в фоновый поток с последующим подтверждением (сложно).
В per‑key ordering — проблема локализуется внутри одного ключа. Другие ключи продолжают обрабатываться, но из‑за особенностей Kafka (pause на уровне партиции) вы всё равно можете заблокировать соседние ключи.
На практике многие команды осознанно отказываются от global FIFO именно из‑за head‑of‑line blocking.
Я бы предпочёл всегда проверять на этапе требований: действительно ли бизнесу нужен глобальный порядок? Часто оказывается, что достаточно порядка внутри сущности.
Идемпотентность и exactly‑once: почему без них любые ретраи опасны
Любой replay, retry или DLQ почти гарантированно приводит к повторной доставке событий. Если ваш downstream‑обработчик не идемпотентен, после восстановления очереди вы получите двойные списания, повторные уведомления или повторную резервацию ресурсов.
Базовое правило: каждый обработчик должен уметь определять, что событие с данным eventId уже было обработано. Самый простой способ — хранить идентификаторы обработанных событий в БД (с уникальным индексом) или использовать idempotency key, который клиент передаёт при отправке.
Коротко про exactly‑once semantics: даже Kafka EOS (Exactly Once Semantics) не решает проблему глобальной exactly‑once обработки между брокером и внешними системами — она гарантирует атомарность чтения-записи внутри Kafka, но не защищает от дублей при вызове внешнего API. Поэтому архитектуру всё равно приходится строить вокруг идемпотентности на уровне бизнес-логики.
В нашей задаче, где возможны retries, replay и DLQ, без идемпотентности не обойтись. Это обязательное требование, а не опция.
Backpressure через лаг: как делать правильно
Простого правила «если лаг > X, шлём backpressure» недостаточно. Lag — реактивная метрика. К тому моменту, когда вы увидели большой лаг, очередь уже переполнена, а продюсер успел отправить новые пачки.
В production‑системах комбинируют:
скорость роста лага (если за последние 10 секунд лаг увеличивается на 1000 сообщений/сек, нужно действовать упреждающе);
латентность обработки одного сообщения (если время обработки выросло в 2 раза — возможно, проблема в downstream);
насыщение downstream‑зависимости (база, API, очередь в другом сервисе);
queue time SLA (пользовательское соглашение о максимальном времени ожидания).
Пример: если лаг < 100k, но растёт со скоростью 10k/сек, и через 10 секунд мы превысим порог — нужно снизить продюсера заранее. Это называется предсказательный backpressure.
Сравнение брокеров
Брокер |
Гарантия порядка |
Управление overflow |
Backpressure |
Рекомендация для задачи 500 RPS + пики 10k |
Kafka (1 парт.) |
да (строгий внутри партиции) |
retention (время/размер) — риск потери диапазона |
через мониторинг lag и квоты (байты) |
оптимально для per‑key ordering |
RabbitMQ |
только на одной незеркал. очереди |
publisher blocking, paging, reject‑publish, DLX |
flow control (частично) |
подходит для умеренных backlog’ов |
Redis Streams |
да |
trimming дорогой, memory pressure |
нет |
только для малых объёмов |
Pulsar |
да (ключ + партиция) |
retention, DLQ, backlog quota |
есть |
хорош, но тяжелее внедрения |
Реальная история: как терялись платёжки (и что с приоритетами)
В одной крупной логистической платформе был сервис заказов (наш Y), который при распродаже выдавал до 15 000 событий/сек. А сервис инвентаризации (X) — только 400 обновлений в секунду (ограничение базы данных). Поставили Kafka с одной партицией. В «чёрную пятницу» лаг вырос до 2 миллионов сообщений. Время обработки заказа подскочило с 2 секунд до 10 минут.
Что они сделали:
Ввели двухуровневую очередь — Kafka + отдельный приоритетный канал для срочных заказов через Redis.
Важно: такой подход требует явного отказа от глобального FIFO между обычными и приоритетными событиями. Он подходит только для систем, где порядок важен внутри одного заказа, но не между разными заказами. Это осознанный компромисс ради сохранения срочных операций. Однако нужно помнить о возможной проблеме priority inversion.Настроили token bucket на продюсере, чтобы сгладить пики (не полагаясь на квоты Kafka).
Добавили автоматический мониторинг лага и его скорости роста с сигналом backpressure (через отдельный топик управления).
Этот подход встречается в highload‑командах, которым нужно балансировать между гарантиями порядка и пропускной способностью. Он не претендует на универсальность, но показывает рабочий компромисс.
Вывод: какой навык проверяет задача
Если вы дочитали до этого места — отлично. А теперь честно: какой тип порядка вы выбрали в начале?
Задача про динамические квоты и лимиты проверяет не знание конкретного брокера, а умение:
различать strict global FIFO и per‑key ordering;
понимать, когда DLQ безопасен, а когда — нет (и как обрабатывать causal ordering с учётом partition‑level pause);
проектировать идемпотентность и учитывать exactly‑once ограничения;
строить backpressure не только по лагу, но и по его скорости и другим метрикам;
осознанно идти на компромиссы (приоритетные каналы, отказ от глобального порядка, понимание throughput ceiling).
Senior‑архитектор сначала задаст несколько уточняющих вопросов, а потом уже предложит решение. Если вы хотите научиться задавать правильные вопросы — добро пожаловать на следующий шаг.

Senior-архитектор в таких задачах редко начинает с выбора брокера. Сначала он уточняет требования к порядку, лимитам, идемпотентности, backpressure и допустимым компромиссам. Именно это обычно и отличает рабочее архитектурное решение от красивой схемы на собеседовании.
Похожие темы можно будет разобрать на бесплатных уроках OTUS, а также познакомиться с экспертами, посмотреть формат обучения и задать вопросы по своим кейсам.
2 июня в 20:00. «Polyglot Persistence: как современные системы живут с десятками баз данных». Записаться
16 июня в 20:00. «Асинхронная обработка данных в высоконагруженных системах». Записаться
Чтобы оставаться в курсе всех бесплатных мероприятий, подписывайтесь на канал OTUS в Max.
Комментарии (2)

MonkAlex
30.05.2026 17:13Очень странная статья. Ответа на поставленную задачу - нет. Внизу табличка сравнения брокеров и там появляется Pulsar, который не упоминался в статье совсем.
Я всё думал, неужели есть магия, которая 10к в 500 превращает и все счастливы, а по ходу статьи получилось уже не 10к, а пиковые 15к и их можно разгрести (видимо?).
И внезапно решение в статье прозвучало вполне банально - сделать очередь. Любую, которая вас устроит, не забудьте про гарантии доставки и проблемы вокруг них.
Void-Cowboy
а если серьезно то хорошая статья, часто любители микросервисов строят архитектуру с мыслью "брокер все стерпит" оперируя что это же горизонтальное масштабирование вы чего
Вот тут то и NATS и NATS JetStream себя отлично показывает к слову. Даже если провафлились с архитектурой изначально, всегда можно гибко подкрутить на уровне настройки брокера, включая "быстрые костыли" с буферной памятью что бы закрыть вылетающие сообщения прямо сейчас, не останавливая поток. И FIFO в том числе, хотя вы правильно написали что по уму такое решается на уровне сообщения что бы разбирать можно было асинхронно, так как в сообщении есть индекс.