Очереди сообщений в системном дизайне: Kafka, RabbitMQ и гарантии доставки
Зачем нужна асинхронность, чем брокер отличается от распределённого лога, как устроены гарантии доставки, партиции, DLQ и outbox и как выбрать между Kafka и RabbitMQ на интервью.
Коротко
- Очередь разрывает связь по времени. Отправитель не ждёт получателя, пики нагрузки оседают в буфере, а одно событие могут прочитать несколько сервисов.
- Брокер и лог — разные модели. RabbitMQ в классическом режиме следит за каждым сообщением и удаляет его после подтверждения. Kafka дописывает события в лог, а читатели сами двигают свою позицию.
- Рабочий стандарт — at-least-once плюс идемпотентный потребитель. Exactly-once в Kafka работает внутри Kafka, но не распространяется на письмо клиенту или HTTP-вызов.
- Порядок всегда локальный. В Kafka он гарантирован внутри партиции. В RabbitMQ его ломают повторная доставка и конкурирующие потребители.
- Запись в базу и отправку в брокер атомарно связывает outbox. Иначе одна из двух операций рано или поздно потеряется.
Не путай с очередью как структурой данных: FIFO в памяти процесса не переживает падение сервиса. Здесь речь об отдельной реплицируемой системе.
Зачем нужна асинхронность
Представь оформление заказа. Сервис синхронно ходит в платежи, склад, письма, аналитику и рекомендации. У каждого доступность 99,9%. Перемножаем:
Заказ теперь доступен на 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 года. Детали меняются от версии к версии.
| Kafka | RabbitMQ | |
|---|---|---|
| Модель | Партиционированный реплицируемый лог | 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 плюс идемпотентный эффект».
Идемпотентность потребителя
Идемпотентная обработка даёт тот же результат при любом числе повторов. Способы, от простого к сложному:
- Естественная идемпотентность.
SET status = 'paid'можно выполнять сколько угодно раз, аbalance = balance + 100нельзя. - Таблица обработанных сообщений. Отметка и эффект пишутся в одной транзакции.
- Проверка версии.
UPDATE ... WHERE version = ?отбрасывает устаревшие события и заодно помогает при нарушенном порядке. - Ключ идемпотентности для внешнего 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от этого спасает, хотя порядок между такими транзакциями не гарантирует. - Рост таблицы. Отправленные строки надо удалять или выносить в партиции по дате, иначе и таблица, и опрос начнут тормозить.
Как выбирать на интервью
Сначала вопросы, потом продукт. Подробнее о том, как вести весь разговор, — в статье о подходах к проектированию систем.
- Нужна ли асинхронность? Ждёт ли кто-то ответа прямо сейчас?
- Сообщение — это задача или факт? Задачу выполняет один воркер и забывает о ней. Факт читают многие и, возможно, не один раз.
- Нужно ли повторное чтение? Новый сервис читает историю, после бага пересчитываем данные.
- Какой порядок нужен и по какому ключу? Этот ключ и станет ключом партиции.
- Что дороже — потеря или дубль? От этого зависят гарантии и настройки.
- Какой объём и как долго хранить? Прикинь сообщения в секунду, размер и retention.
- Кто будет это эксплуатировать? Опыт команды и managed-сервис нередко важнее таблицы отличий.
| Сценарий | Что предложить | Почему |
|---|---|---|
| Письма, миниатюры, отчёты | RabbitMQ | Задачи для воркеров, повтор и DLQ для каждого сообщения |
| Клики, логи, метрики | Kafka | Большой поток, несколько читателей, хранение |
| События заказа для многих сервисов | Kafka, ключ order_id | Независимые группы, порядок по заказу, повторное чтение |
| CDC из базы | Kafka и Debezium | Готовая связка, лог как источник изменений |
| Разные приоритеты и сложная маршрутизация | RabbitMQ | Exchange и приоритеты есть из коробки |
| Деньги и статусы заказов | Любой брокер, 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 есть, а алерта на него нет.
- Запись в базу и публикация в брокер как две независимые операции.
- Брокер предложен там, где клиент всё равно синхронно ждёт ответ.
Главная мысль для интервью: выбор продукта занимает минуту, а основное время уйдёт на обсуждение сбоев — что будет, если упадёт каждый участник между любыми двумя шагами.