Очереди сообщений в системном дизайне: Kafka, RabbitMQ и гарантии доставки

Зачем нужна асинхронность, чем брокер отличается от распределённого лога, как устроены гарантии доставки, партиции, DLQ и outbox и как выбрать между Kafka и RabbitMQ на интервью.

21 мин чтениясистемный дизайн · kafka · rabbitmq · очереди сообщений

Коротко

  • Очередь разрывает связь по времени. Отправитель не ждёт получателя, пики нагрузки оседают в буфере, а одно событие могут прочитать несколько сервисов.
  • Брокер и лог — разные модели. RabbitMQ в классическом режиме следит за каждым сообщением и удаляет его после подтверждения. Kafka дописывает события в лог, а читатели сами двигают свою позицию.
  • Рабочий стандарт — at-least-once плюс идемпотентный потребитель. Exactly-once в Kafka работает внутри Kafka, но не распространяется на письмо клиенту или HTTP-вызов.
  • Порядок всегда локальный. В Kafka он гарантирован внутри партиции. В RabbitMQ его ломают повторная доставка и конкурирующие потребители.
  • Запись в базу и отправку в брокер атомарно связывает outbox. Иначе одна из двух операций рано или поздно потеряется.

Не путай с очередью как структурой данных: FIFO в памяти процесса не переживает падение сервиса. Здесь речь об отдельной реплицируемой системе.

Зачем нужна асинхронность

Представь оформление заказа. Сервис синхронно ходит в платежи, склад, письма, аналитику и рекомендации. У каждого доступность 99,9%. Перемножаем:

0,9995≈0,9950{,}999^5 \approx 0{,}995

Заказ теперь доступен на 99,5%: это около 3,6 часа простоя в месяц вместо 43 минут. Задержки складываются, а из-за упавшей аналитики клиент не может купить товар.

Платёж действительно нужен до ответа клиенту. Остальное может подождать: сервис заказа публикует OrderCreated, а письма и аналитика читают событие в своём темпе.

Что это даёт:

  • Развязка по времени. Сервис писем лежит десять минут — письма уйдут позже, заказы продолжат оформляться.
  • Выравнивание нагрузки. На распродаже приходит 10 000 заказов в секунду, а воркеры обрабатывают 2000. Лишнее ждёт в очереди и не роняет базу.
  • Веерная рассылка. Новому сервису не нужно менять отправителя: он просто подписывается.
  • Повторы. Упавшую обработку можно повторить без участия клиента.

И что это стоит:

  • Согласованность становится итоговой: клиент может не сразу увидеть результат.
  • Появляются дубли, нарушенный порядок и «застрявшие» сообщения.
  • Брокер — ещё одна критичная система, которую нужно мониторить и обновлять.
  • Отлаживать сложнее: без сквозного идентификатора запроса цепочку событий не восстановить.

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

Брокер очередей и распределённый лог

Брокер похож на стопку заявок: воркер берёт заявку, выполняет, подтверждает, и она исчезает. Состояние каждого сообщения хранит сервер: кому отдано, что подтверждено. Прочитал один — остальным не достанется. Чтобы письма и аналитика получили по копии, нужны две очереди.

Лог похож на книгу с закладками. События только дописываются в конец, чтение ничего не меняет. Каждая группа читателей хранит свою закладку — offset. Новый сервис может прочитать всё с начала, а после бага в обработке можно перемотать закладку назад.

Отсюда главное следствие: брокер распределяет работу, лог хранит историю.

Граница размылась с обеих сторон. В RabbitMQ есть streams — по сути, неразрушающий лог с чтением по offset. В Kafka есть share groups (Queues for Kafka): несколько потребителей читают одну партицию и подтверждают записи по отдельности. Заметки к Kafka 4.2 называют их готовыми к продакшену. Функция молодая, поэтому в реальном проекте сначала проверь версии кластера и клиентов.

Kafka и RabbitMQ: чем отличаются

Таблица сверена с документацией Kafka 4.4 и RabbitMQ 4.x на ноябрь 2026 года. Детали меняются от версии к версии.

KafkaRabbitMQ
МодельПартиционированный реплицируемый логExchange и очереди; streams — отдельный тип с логом
После чтенияОстаётся до конца retention (по умолчанию 7 дней) или compactionУдаляется после ack; в streams хранится по времени и размеру
ПолучениеПотребитель сам забирает данные (pull)Обычно брокер отправляет сам (push), лимит задаёт prefetch
Учёт прогрессаОдин offset на партицию и группуПодтверждение каждого сообщения
МаршрутизацияТопик и ключ партиции, остальное на клиентеExchange типов direct, topic, fanout, headers
Масштабирование чтенияВ группе партицию читает один потребительКонкурирующие потребители на одной очереди; у quorum-очереди все операции идут через её лидера
ПорядокВнутри партицииFIFO очереди, но повторы и несколько потребителей его ломают
Повторное чтениеШтатно: перемотать offsetВ очереди нет, в streams есть
ПриоритетыНетЕсть, детали зависят от типа очереди
DLQУ обычных групп нет: делает приложение, фреймворк или Kafka ConnectШтатно через dead letter exchange; в streams нет
Отложенная доставкаШтатно нет, обычно отдельные топикиОбычно TTL плюс DLX
Exactly-onceИдемпотентный продюсер и транзакции внутри KafkaВ очередях нет; в streams есть дедупликация при публикации
Типичные задачиПотоки событий, аналитика, CDC, несколько независимых читателейФоновые задачи, сложная маршрутизация, подтверждение каждого сообщения

Цифр пропускной способности в таблице нет намеренно: они зависят от размера сообщений, репликации и дисков. Фраза «Kafka быстрее» на интервью ничего не доказывает — важнее назвать требования к хранению, маршрутизации и повторному чтению.

Две вещи, которые выдают устаревшие знания:

  • Kafka с версии 4.0 работает только в режиме KRaft, ZooKeeper удалён.
  • Зеркалирование классических очередей удалено в RabbitMQ 4.0. Для репликации теперь используют quorum-очереди и streams.

Гарантии доставки

ГарантияКак получаетсяЧем платим
At-most-onceПодтвердили до обработки или вообще без подтвержденийСообщения теряются при сбое
At-least-onceПодтвердили после обработки, отправитель повторяет при ошибкеВозможны дубли
Exactly-onceИдемпотентность и транзакции в ограниченных границахСложность, задержка, только внутри этих границ

Разница между первыми двумя — в одной строке: когда двигать позицию. Вот симуляция падения потребителя на сообщении m1:

def run(log, commit_before, crash_on): offset = 0 # закоммиченная позиция, переживает рестарт handled = [] crashed = False while offset < len(log): msg = log[offset] crash_now = msg == crash_on and not crashed if commit_before: offset += 1 if crash_now: # offset сдвинут, обработки не было crashed = True continue handled.append(msg) else: handled.append(msg) if crash_now: # обработали, offset не сдвинут crashed = True continue offset += 1 return handled log = ['m0', 'm1', 'm2', 'm3'] print('commit до обработки: ', run(log, True, crash_on='m1')) print('commit после обработки:', run(log, False, crash_on='m1')) # commit до обработки: ['m0', 'm2', 'm3'] # commit после обработки: ['m0', 'm1', 'm1', 'm2', 'm3']

В первом случае m1 потерян, во втором обработан дважды. Дубль почти всегда дешевле потери, поэтому по умолчанию выбирают at-least-once.

Так выглядит это на настоящем клиенте Kafka (confluent-kafka):

from confluent_kafka import Consumer, KafkaException, Producer BOOTSTRAP = 'localhost:9092' TOPIC = 'orders' producer = Producer({ 'bootstrap.servers': BOOTSTRAP, 'acks': 'all', 'enable.idempotence': True, }) def on_delivery(err, msg): if err: print('не доставлено:', err) # в проде: метрика и алерт for i, status in enumerate(['created', 'paid', 'shipped']): producer.produce( TOPIC, key='order-1', # события одного заказа попадут в одну партицию value=status, headers={'event-id': f'order-1-{i}'}, on_delivery=on_delivery, ) producer.flush() consumer = Consumer({ 'bootstrap.servers': BOOTSTRAP, 'group.id': 'billing', 'enable.auto.commit': False, # offset двигаем сами, после обработки 'auto.offset.reset': 'earliest', }) consumer.subscribe([TOPIC]) def process(msg): print(msg.partition(), msg.offset(), msg.key(), msg.value()) while True: msg = consumer.poll(1.0) if msg is None: continue if msg.error(): raise KafkaException(msg.error()) process(msg) # должен быть идемпотентным consumer.commit(message=msg, asynchronous=False)

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

Потерять можно и до потребителя

At-least-once работает, только если обе стороны делают свою часть.

Kafka. В Java-клиенте acks=all и идемпотентность включены по умолчанию, а в librdkafka идемпотентность включают явно, как в примере выше. Но и этого мало: min.insync.replicas на топике по умолчанию равен 1. Лидер может подтвердить запись в одиночку, если остальные реплики выпали из ISR, и потом потерять её вместе с диском. Документация предлагает типичную схему для надёжности: replication factor 3, min.insync.replicas=2, acks=all.

RabbitMQ. Нужны долговечная очередь, persistent-сообщения, publisher confirms у отправителя и ручные ack у потребителя. Документация прямо называет автоматические подтверждения небезопасными.

Что на самом деле означает exactly-once

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

Что даёт Kafka:

  • Идемпотентный продюсер отсекает дубли от ретраев клиента в рамках одной сессии. Если приложение упало и после рестарта отправило событие заново, для брокера это новая запись.
  • Транзакции атомарно фиксируют цепочку «прочитали из Kafka, записали в Kafka, закоммитили offset». Читатели должны выставить isolation.level=read_committed, по умолчанию стоит read_uncommitted. На этих механизмах построен режим exactly-once в Kafka Streams.

Что не покрывается: письма, платёжные API, запись во внешнюю базу. Сама документация Kafka пишет, что для внешних систем нужна их кооперация. На интервью формулируй так: «exactly-once в обработке получаем как at-least-once плюс идемпотентный эффект».

Идемпотентность потребителя

Идемпотентная обработка даёт тот же результат при любом числе повторов. Способы, от простого к сложному:

  1. Естественная идемпотентность. SET status = 'paid' можно выполнять сколько угодно раз, а balance = balance + 100 нельзя.
  2. Таблица обработанных сообщений. Отметка и эффект пишутся в одной транзакции.
  3. Проверка версии. UPDATE ... WHERE version = ? отбрасывает устаревшие события и заодно помогает при нарушенном порядке.
  4. Ключ идемпотентности для внешнего API. Если провайдер их поддерживает, передаём один и тот же ключ при повторе.

Второй способ на SQLite. В Postgres вместо INSERT OR IGNORE будет INSERT ... ON CONFLICT DO NOTHING:

import sqlite3 db = sqlite3.connect(':memory:') db.executescript(""" CREATE TABLE balances (user_id TEXT PRIMARY KEY, amount INTEGER NOT NULL); CREATE TABLE processed_messages (message_id TEXT PRIMARY KEY); INSERT INTO balances VALUES ('alice', 0); """) def handle_payment(message_id, user_id, amount): with db: # одна транзакция на отметку и эффект inserted = db.execute( 'INSERT OR IGNORE INTO processed_messages VALUES (?)', (message_id,), ).rowcount if inserted == 0: return 'дубликат, пропускаем' db.execute( 'UPDATE balances SET amount = amount + ? WHERE user_id = ?', (amount, user_id), ) return 'зачислено' print(handle_payment('pay-1', 'alice', 100)) # зачислено print(handle_payment('pay-1', 'alice', 100)) # дубликат, пропускаем print(handle_payment('pay-2', 'alice', 50)) # зачислено print(db.execute('SELECT amount FROM balances').fetchone()[0]) # 150

Где ошибаются чаще всего:

  • Проверка отдельно от записи. SELECT, затем INSERT без уникального индекса: два потребителя одновременно видят «ещё не обрабатывали». Защищает только ограничение уникальности.
  • Отметка в другой системе. Redis для дедупликации и Postgres для данных не связаны транзакцией. Между двумя записями можно упасть.
  • Идентификатор генерирует потребитель. Его должен задавать отправитель, иначе у дубля будет новый id. Offset тоже плох: при повторной публикации у того же события он другой.
  • Отметки живут меньше окна повторов. Если таблицу чистят раз в сутки, а retention топика неделя, перемотка на три дня назад пройдёт мимо дедупликации.

Порядок и партиции

В Kafka ключ определяет партицию. События одного ключа попадают в одну партицию и читаются в порядке записи:

from zlib import crc32 def partition(key, partitions): return crc32(key.encode()) % partitions events = [ ('order-1', 'created'), ('order-2', 'created'), ('order-1', 'paid'), ('order-2', 'paid'), ('order-1', 'shipped'), ] topic = {p: [] for p in range(3)} for key, event in events: topic[partition(key, 3)].append(f'{key}:{event}') print(topic) # {0: ['order-2:created', 'order-2:paid'], # 1: ['order-1:created', 'order-1:paid', 'order-1:shipped'], 2: []} keys = [f'order-{i}' for i in range(100_000)] moved = sum(partition(k, 6) != partition(k, 8) for k in keys) print(f'при переходе с 6 на 8 партиций сменили партицию: {moved / len(keys):.0%}') # при переходе с 6 на 8 партиций сменили партицию: 75%

Хэш здесь только для иллюстрации. Java-продюсер по умолчанию считает Murmur2, а librdkafka — CRC32. Если в один топик пишут клиенты на разных языках, партиционер стоит настроить явно, иначе один и тот же ключ может попасть в разные партиции.

Что следует из этой схемы:

  • Глобальный порядок — это одна партиция, то есть один параллельный потребитель. Обычно он и не нужен: важен порядок событий одного заказа, а не всех заказов.
  • Партиции ограничивают параллелизм. В группе партицию читает один потребитель, лишние простаивают. При 6 партициях седьмой экземпляр не ускорит обработку.
  • Ключ определяет нагрузку. Если у одного продавца половина трафика и ключ — seller_id, одна партиция будет перегружена, а остальные простаивать.
  • Добавлять партиции опасно. Новые события ключа могут пойти в другую партицию, а старые остаются на месте, и порядок ломается на границе. Уменьшить число партиций Kafka не позволяет. Число выбирай с запасом сразу.
  • Ретраи ломают порядок. Отправил paid в топик повторов — shipped обработается раньше. Блокирующий повтор сохраняет порядок, но останавливает всю партицию. Компромисс: откладывать только сообщения этого ключа или проверять версию в обработчике.

В RabbitMQ очередь выдаёт сообщения по FIFO, но с несколькими потребителями порядок обработки не гарантирован: один воркер медленнее другого, а повторная доставка меняет порядок. Документация предлагает Single Active Consumer, хэширование ключа по нескольким очередям через exchange x-modulus-hash или streams.

Обратное давление

Обратное давление — это сигнал источнику: «притормози, я не успеваю». Без него лишнее копится, пока что-нибудь не упадёт. На интервью важно показать, что очередь не отменяет проблему, а только откладывает.

Считается просто. Пусть приходит 2000 сообщений в секунду, а один потребитель обрабатывает 250:

from math import ceil def consumers_needed(rate_in, rate_per_consumer, headroom=1.5): """Сколько потребителей нужно с запасом на пики и догон.""" return ceil(rate_in * headroom / rate_per_consumer) def catch_up_seconds(lag, rate_out, rate_in): """За сколько секунд разгребём накопленный lag, если успеваем.""" if rate_out <= rate_in: return float('inf') # не догоним никогда return lag / (rate_out - rate_in) print(consumers_needed(rate_in=2_000, rate_per_consumer=250)) # 12 print(catch_up_seconds(lag=600_000, rate_out=3_000, rate_in=2_000)) # 600.0 print(catch_up_seconds(lag=600_000, rate_out=2_000, rate_in=2_000)) # inf

Вывод: 12 потребителей, значит, в Kafka нужно минимум 12 партиций. Если они лежали час, накопится 7,2 млн сообщений. При размере 1 КБ это примерно 7,2 ГБ до репликации. Такие прикидки и ждёт интервьюер.

Как это устроено в Kafka:

  • Потребитель забирает данные сам, поэтому перегрузить его сложно. Главная метрика — consumer lag, отставание от конца лога.
  • Lag нельзя копить бесконечно: когда данные старше retention удаляются, отставший потребитель их теряет.
  • max.poll.records (по умолчанию 500) ограничивает пачку за один вызов. Если между вызовами poll() проходит больше max.poll.interval.ms (по умолчанию 5 минут), потребителя считают упавшим, и начинается ребалансировка. Для долгой обработки партиции ставят на pause(), а poll() продолжают вызывать.
  • Продюсер копит записи в buffer.memory (по умолчанию 32 МиБ). Когда буфер заполнен, send() блокируется на max.block.ms (по умолчанию минуту), а затем выбрасывает исключение. Приложение должно решить, что с этим делать.

Как это устроено в RabbitMQ:

  • prefetch ограничивает число неподтверждённых доставок. Без лимита брокер завалит потребителя. Документация считает диапазон 100–300 обычно оптимальным.
  • При нехватке ресурсов брокер замедляет публикующие соединения, а при срабатывании alarm по памяти или диску блокирует их во всём кластере. Для отправителя это выглядит как зависшая публикация, поэтому нужны таймауты и publisher confirms.
  • Длину очереди можно ограничить, задав поведение при переполнении.

Когда буфер кончается, вариантов всего три:

СтратегияКакПодходит для
Притормозить источникБлокировка, HTTP 429Заказы, платежи: терять нельзя
ОтбрасыватьСэмплирование, вытеснение старыхМетрики, логи, клики
Масштабировать потребителейБольше экземпляров, если хватает партицийВременный пик, а не постоянная нехватка

Очередь недоставленных сообщений

Сообщение, которое никогда не обработается, называют ядовитым. Например, в нём невалидный JSON. Без ограничения повторов оно будет крутиться вечно, а в Kafka при блокирующем повторе ещё и остановит партицию. DLQ (dead letter queue) — это отдельное место для таких сообщений, куда их откладывают, чтобы очередь шла дальше.

Сначала раздели ошибки:

  • Постоянные (не проходит валидация, нет обязательного поля): повтор не поможет, сразу в DLQ.
  • Временные (таймаут, недоступна база): повторяем с растущей задержкой, и только после лимита попыток — в DLQ.

В RabbitMQ сообщение попадает в dead letter exchange, если его отклонили без возврата в очередь, истёк TTL, очередь переполнена или в quorum-очереди превышен лимит доставок (по умолчанию 20). Пример с pika:

import pika connection = pika.BlockingConnection(pika.ConnectionParameters('localhost')) channel = connection.channel() # Куда попадут сообщения, которые не удалось обработать channel.exchange_declare('orders.dlx', exchange_type='direct', durable=True) channel.queue_declare('orders.dead', durable=True, arguments={'x-queue-type': 'quorum'}) channel.queue_bind('orders.dead', 'orders.dlx', routing_key='orders') channel.queue_declare('orders', durable=True, arguments={ 'x-queue-type': 'quorum', 'x-delivery-limit': 3, 'x-dead-letter-exchange': 'orders.dlx', 'x-dead-letter-routing-key': 'orders', }) channel.confirm_delivery() # брокер подтверждает приём сообщения for body in [b'ok', b'broken', b'flaky']: channel.basic_publish( exchange='', routing_key='orders', body=body, properties=pika.BasicProperties(delivery_mode=2), # persistent ) class PermanentError(Exception): pass def process(body): if body == b'broken': raise PermanentError('невалидный заказ') if body == b'flaky': raise TimeoutError('внешний сервис недоступен') print('обработано:', body) def on_message(ch, method, properties, body): try: process(body) except PermanentError: # Повтор не поможет: сразу в DLQ ch.basic_reject(method.delivery_tag, requeue=False) except Exception: # Возможно, временная ошибка: вернём в очередь ch.basic_reject(method.delivery_tag, requeue=True) else: ch.basic_ack(method.delivery_tag) channel.basic_qos(prefetch_count=100) # не больше 100 неподтверждённых доставок channel.basic_consume('orders', on_message) channel.start_consuming()

На RabbitMQ 4.4 broken сразу ушёл в orders.dead с причиной rejected, а flaky — после четырёх доставок с причиной delivery_limit.

Здесь есть ловушка: basic_reject и basic_nack с requeue=True ведут себя по-разному. С версии 4.3 basic.nack не увеличивает счётчик неудачных доставок. Я заменил reject на nack и за две секунды получил около 2800 доставок одного сообщения, а в DLQ оно так и не попало. Код, написанный для более старых версий, после обновления может начать работать именно так.

Немедленный возврат в очередь вообще плох для временных ошибок: если сервис лежит, все попытки сгорят за секунду. Документация quorum-очередей советует повторы с задержкой. Обычная схема — отдельная очередь повторов с TTL, у которой DLX указывает обратно на основную.

Ещё две оговорки:

  • По умолчанию dead-lettering в RabbitMQ идёт без внутренних подтверждений, то есть с гарантией at-most-once. Quorum-очереди умеют at-least-once, но это нужно включить отдельно.
  • Лучше задавать DLX через политики, а не через аргументы очереди, как в примере: аргументы нельзя поменять без пересоздания очереди.

В Kafka у обычных групп потребителей нет штатного DLQ. Приложение само пишет проблемную запись в отдельный топик, добавляет в заголовки причину, исходный топик, партицию и offset, и только потом коммитит. Kafka Connect и некоторые фреймворки умеют это из коробки. Для share-групп заметки к Kafka 4.4 описывают встроенный DLQ (KIP-1191).

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

Паттерн outbox

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

Решение: событие пишется в таблицу той же базы и в той же транзакции, что и бизнес-данные. Отдельный процесс, релей, читает таблицу и публикует в брокер:

CREATE TABLE outbox ( seq BIGSERIAL PRIMARY KEY, event_id UUID NOT NULL UNIQUE, aggregate_id TEXT NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), published_at TIMESTAMPTZ ); -- Частичный индекс: релею нужны только неотправленные строки CREATE INDEX outbox_unpublished ON outbox (seq) WHERE published_at IS NULL; -- Запись: заказ и событие в одной транзакции BEGIN; INSERT INTO orders (id, total) VALUES ('order-1', 500); INSERT INTO outbox (event_id, aggregate_id, payload) VALUES (gen_random_uuid(), 'order-1', '{"type": "OrderCreated", "total": 500}'); COMMIT;

Релей в простейшем варианте опрашивает таблицу:

BEGIN; SELECT seq, event_id, aggregate_id, payload FROM outbox WHERE published_at IS NULL ORDER BY seq LIMIT 100 FOR UPDATE SKIP LOCKED; -- ...публикуем в брокер и ждём подтверждения... UPDATE outbox SET published_at = now() WHERE seq = 1; COMMIT;

Если релей упал после публикации, но до COMMIT, отметка не сохранится, и после рестарта событие уйдёт ещё раз.

Outbox не даёт exactly-once. Он гарантирует, что событие будет опубликовано тогда и только тогда, когда транзакция зафиксирована, но возможно больше одного раза. event_id нужен как раз для дедупликации у потребителя.

Как читать таблицу:

  • Опрос (polling). Просто и не требует новой инфраструктуры. Цена — задержка и нагрузка на базу.
  • Чтение журнала транзакций (CDC). Например, Debezium читает WAL Postgres и публикует строки outbox в Kafka. Задержка меньше, но появляется ещё один компонент.

Подводные камни, о которых стоит сказать вслух:

  • Порядок и несколько релеев. SELECT ... FOR UPDATE SKIP LOCKED не даст двум релеям взять одну строку, но события одного заказа могут уйти в обратном порядке. Если порядок важен, делите релеи по aggregate_id или оставьте один активный.
  • Порядок seq — не порядок коммитов. Значение последовательности выдаётся до коммита, поэтому строка с меньшим seq может стать видимой позже. Если релей запоминает «последний отправленный seq», он может её пропустить. Отбор по published_at IS NULL от этого спасает, хотя порядок между такими транзакциями не гарантирует.
  • Рост таблицы. Отправленные строки надо удалять или выносить в партиции по дате, иначе и таблица, и опрос начнут тормозить.

Как выбирать на интервью

Сначала вопросы, потом продукт. Подробнее о том, как вести весь разговор, — в статье о подходах к проектированию систем.

  1. Нужна ли асинхронность? Ждёт ли кто-то ответа прямо сейчас?
  2. Сообщение — это задача или факт? Задачу выполняет один воркер и забывает о ней. Факт читают многие и, возможно, не один раз.
  3. Нужно ли повторное чтение? Новый сервис читает историю, после бага пересчитываем данные.
  4. Какой порядок нужен и по какому ключу? Этот ключ и станет ключом партиции.
  5. Что дороже — потеря или дубль? От этого зависят гарантии и настройки.
  6. Какой объём и как долго хранить? Прикинь сообщения в секунду, размер и retention.
  7. Кто будет это эксплуатировать? Опыт команды и managed-сервис нередко важнее таблицы отличий.
СценарийЧто предложитьПочему
Письма, миниатюры, отчётыRabbitMQЗадачи для воркеров, повтор и DLQ для каждого сообщения
Клики, логи, метрикиKafkaБольшой поток, несколько читателей, хранение
События заказа для многих сервисовKafka, ключ order_idНезависимые группы, порядок по заказу, повторное чтение
CDC из базыKafka и DebeziumГотовая связка, лог как источник изменений
Разные приоритеты и сложная маршрутизацияRabbitMQExchange и приоритеты есть из коробки
Деньги и статусы заказовЛюбой брокер, outbox и идемпотентностьНадёжность задаёт архитектура, а не логотип

Пример ответа на минуту:

Письма и аналитику вынесу из синхронного пути заказа: клиенту они не нужны для ответа. Заказ и событие пишу в одной транзакции через outbox, релей публикует в Kafka с ключом order_id, чтобы сохранить порядок внутри заказа. Выбираю Kafka, потому что читателей несколько и аналитика хочет пересчитывать историю. Гарантия — at-least-once, потребители дедуплицируют по event_id. При 2000 событий в секунду и 250 на экземпляр с запасом нужно 12 потребителей, поэтому беру больше партиций, например 24, и слежу за lag. Ядовитые сообщения отправляю в DLQ-топик с алертом. Если бы нужна была только очередь задач без истории, взял бы RabbitMQ: это проще.

Вопросы, которые спросят дальше

«Потребитель обработал сообщение и упал до коммита. Что будет?» Сообщение придёт снова, это норма для at-least-once. Спасает идемпотентность.

«Lag растёт. Что делать?» Выяснить причину: всплеск или медленная зависимость. Если упираемся в CPU — добавить потребителей, но только до числа партиций. Если тормозит база, новые потребители сделают хуже.

«Как не потерять сообщение?» Пройти весь путь: outbox у отправителя, acks=all и min.insync.replicas=2 или publisher confirms и quorum-очередь в брокере, коммит после обработки у потребителя.

«Сделаем exactly-once?» Уточнить границы. Внутри Kafka — транзакции. С внешними системами — идемпотентность.

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

Частые ошибки

  • «Kafka гарантирует порядок» без уточнения, что только в партиции.
  • «Включим exactly-once, и дубли исчезнут». Границы важнее настройки.
  • Автокоммит с асинхронной обработкой. По документации при enable.auto.commit=true (это значение по умолчанию) после сбоя возможны и повторы, и потери, если offset зафиксирован до окончания обработки.
  • Ключ партиции выбран без учёта распределения, в итоге одна партиция перегружена.
  • DLQ есть, а алерта на него нет.
  • Запись в базу и публикация в брокер как две независимые операции.
  • Брокер предложен там, где клиент всё равно синхронно ждёт ответ.

Главная мысль для интервью: выбор продукта занимает минуту, а основное время уйдёт на обсуждение сбоев — что будет, если упадёт каждый участник между любыми двумя шагами.

Потренироваться в спокойной обстановке

Все задачи