8.1Общая теория
Начни с того, кто инициирует передачу, где хранится сообщение и что означает подтверждение. Разбери at-most-once, at-least-once и идемпотентность на временной линии, а затем оцени, где порядок вообще существует.
Зачем вообще асинхронность
При синхронном вызове мало дождаться ответа: вызываемый сервис обязан быть жив прямо сейчас. Такая связанность называется temporal coupling, и именно она превращает набор микросервисов в распределённый монолит: падает почта - не создаётся заказ.
Брокер даёт три вещи, и стоит проговаривать их именно так:
- Развязка (decoupling). Продюсер знает только имя топика или exchange. Он не знает, сколько у события подписчиков, живы ли они и что они с ним сделают. Чтобы добавить четвёртого потребителя, код меняют только у него.
- Сглаживание пиков (load levelling). Брокер работает буфером. Продюсер может выдавать 20 000 сообщений в секунду в пиковую минуту, а консьюмер спокойно переваривать 3 000/с следующие полчаса. Без буфера пришлось бы держать железо под пик.
- Надёжность. Сообщение лежит на диске и переживает падение потребителя. Синхронный HTTP-запрос при 500-й ошибке теряется целиком - если только вызывающий сам не построил себе очередь ретраев, то есть тот же брокер, только плохой.
Ответ «асинхронность нужна для развязки и надёжности» звучит как заученный. В сильном ответе всегда есть и цена: клиенту теперь нельзя сказать «готово» (нужен статус/поллинг/вебхук), появились дубликаты (нужна идемпотентность), порядок больше не глобальный, отладка стала многошаговой (нужны correlation id и трассировка через заголовки сообщений), и сам брокер стал единой точкой отказа, которую надо кластеризовать и мониторить.
Push vs pull
Вопрос в том, кто начинает передачу: брокер или потребитель. В модели push («толкать») брокер сам шлёт сообщение, как только оно появилось. В pull («тянуть») консьюмер сам приходит и спрашивает: «есть что-нибудь?». Это не деталь реализации: от неё зависит, где именно копится работа, когда потребитель не успевает.
Через backpressure медленный получатель заставляет быстрого отправителя сбавить темп. Бытовой пример: раздача на кухне ресторана. Если повар ставит тарелки на стойку быстрее, чем официанты их разносят, стойка переполнится, и дальше возможны только два исхода: либо повар видит, что места нет, и притормаживает (обратное давление сработало), либо тарелки летят на пол (потери). В push тормозить отправителя приходится специально, в pull это выходит само собой: не пришёл за сообщением - оно просто осталось лежать у брокера.
Чистого push и чистого pull почти не бывает. RabbitMQ толкает, но с кредитным окном
(basic.qos(prefetch)): брокер не пошлёт больше N неподтверждённых сообщений.
Kafka тянет, но с long polling: запрос висит на брокере до fetch.max.wait.ms
или пока не накопится fetch.min.bytes, поэтому пустых опросов почти нет
и задержка остаётся единицами миллисекунд.
Гарантии доставки
Три уровня, и все три называют по-английски, потому что русские варианты не прижились. Читаются они буквально: at-most-once значит «не более одного раза», at-least-once - «не менее одного раза», exactly-once - «ровно один раз». Формально речь не о «доставке», а о том, сколько раз может проявиться эффект обработки: не сколько раз сообщение прилетело по сети, а сколько раз списались деньги или ушло письмо.
Разница между уровнями в порядке двух действий. Подтверждение и обработка происходят в разные моменты, и между ними всегда есть щель, в которую можно упасть. Если подтвердить раньше, чем сделать, падение потеряет работу. Если позже - после падения работу сделают дважды. Третьего варианта нет, и пример ниже показывает, почему его не бывает в принципе.
Что физически значит «ровно один раз», видно на бытовом примере. Курьер привёз посылку и должен отметить в системе, что доставил. Он позвонил в дверь, отдал коробку - и в этот момент у него сел телефон, отметка не ушла. Диспетчер видит: заявка не закрыта. Что произошло, он не знает: курьер вообще не доехал или доехал, отдал и не отчитался. Вариантов у диспетчера ровно два: послать вторую посылку (получатель получит две) или не посылать (может остаться ни с чем). Способа узнать правду у него нет, и дело не в плохой системе: оборвался единственный канал связи. Между брокером и консьюмером такое случается десятки раз в сутки.
| Гарантия | Механика | Потери | Дубли | Где встречается |
|---|---|---|---|---|
| at-most-once | Подтвердить (ack / commit offset) до обработки, либо вообще не подтверждать | да | нет | метрики, телеметрия, UDP-подобная логика, Redis Pub/Sub |
| at-least-once | Подтвердить после успешной обработки, при сбое - переотправка | нет | да | дефолт в Kafka, RabbitMQ, SQS, JetStream - 95 % прода |
| exactly-once | Недостижимо в общем случае. На практике - at-least-once плюс дедупликация | нет | нет эффекта | Kafka EOS внутри Kafka; везде ещё - «effectively once» |
Почему exactly-once невозможен: проблема двух генералов
Классическая формулировка: две армии на холмах по обе стороны от долины, в долине противник. Атака удастся только при одновременном ударе. Гонцы ходят через долину, и их могут перехватить. Доказано, что никакого протокола с конечным числом сообщений, гарантирующего согласованное решение, не существует. Доказательство от противного: возьмём кратчайший корректный протокол; его последнее сообщение может потеряться, но раз протокол корректен и без него, это сообщение лишнее, и мы получили протокол короче. Повторяем, пока сообщений не останется вовсе, и приходим к противоречию.
Прикладной перевод: отправитель никогда не может достоверно узнать, был ли получен и обработан его последний запрос. Не получив ответа, он вынужден выбирать между «повторить» (риск дубликата) и «не повторять» (риск потери). Брокер этот выбор не отменяет, а лишь берёт на себя. Сюда же относится FLP-теорема: в асинхронной системе, где может отказать даже один узел, детерминированный консенсус за конечное время невозможен.
«Exactly-once доставка невозможна. Достижима exactly-once обработка, и только как сочетание at-least-once доставки с идемпотентным потребителем или транзакцией, которая атомарно фиксирует и результат работы, и факт потребления. То есть проблема решается не на транспорте, а end-to-end, на прикладном уровне.» Именно это и хотят услышать.
Две отдельные вещи, которые маркетинг слепил в одну. Во-первых, идемпотентный продюсер
(enable.idempotence=true, включён по умолчанию с Kafka 3.0): продюсер получает
PID и нумерует записи sequence number по партиции, брокер отбрасывает повтор с уже виденным
номером. Это убирает дубли от ретраев самого продюсера, и только в пределах одной
сессии продюсера. Во-вторых, транзакции: атомарная запись в несколько партиций плюс
атомарный коммит offset входного топика через sendOffsetsToTransaction. Вместе они
дают exactly-once для сценария read-process-write внутри Kafka. Как только обработчик
делает HTTP-вызов, шлёт письмо или пишет в стороннюю БД без общей транзакции, - гарантия
заканчивается: откатить письмо Kafka не умеет.
Идемпотентность консьюмера
Операция идемпотентна, если даёт один и тот же результат, сколько бы раз
её ни повторили: один раз, три раза, сто. Так устроена кнопка вызова лифта. Нажал её
один раз или продолбил пятнадцать - лифт приедет один раз, состояние «лифт вызван»
от повторов не меняется. А вот кнопка «взять талончик» в очереди неидемпотентна: каждое
нажатие выдаёт новый номер. Слово латинское: idem - «тот же», potens -
«имеющий силу»; формально f(f(x)) = f(x).
Раз дубликаты неизбежны, обработчик устраивают так, чтобы повторная обработка того же сообщения не меняла результат. Путей два, и они принципиально разные.
Путь первый: сделать саму операцию идемпотентной по природе. Он лучше, потому что
ничего не надо хранить. UPDATE users SET status='active' WHERE id=$1 идемпотентен,
а UPDATE accounts SET balance = balance - 100 - нет. Приёмы: UPSERT
по естественному ключу, установка абсолютного значения вместо инкремента, оптимистическая
блокировка по версии (WHERE version < $2), условная вставка
ON CONFLICT DO NOTHING.
Путь второй: явная дедупликация по ключу. Дедупликация означает «вести список уже сделанного и сверяться с ним»: у каждого сообщения есть ключ, обработчик перед работой смотрит, нет ли этого ключа в списке, и если есть, молча пропускает сообщение. Как журнал на вахте: фамилия уже записана - второй раз не пускаем. Этот путь нужен, когда операция принципиально неидемпотентна (списание денег, отправка письма, вызов внешнего API). Ключ дедупликации выбирают в таком порядке:
- Бизнес-ключ:
payment_id,order_id + event_type,aggregate_id + version. Самый надёжный вариант: такой ключ переживает и переотправку продюсером, и повторную генерацию события. - Message ID от продюсера: UUID, который кладут в заголовок при первой отправке. Генерируют его один раз, а не на каждом ретрае.
- Координаты в брокере (
topic:partition:offset) работают только внутри одной партиции и ломаются при перекладывании через retry-топик. Это крайний вариант.
-- Таблица дедупликации. Хватает одного PRIMARY KEY.
CREATE TABLE processed_messages (
consumer text NOT NULL, -- имя группы: один и тот же msg могут есть разные консьюмеры
message_key text NOT NULL, -- бизнес-ключ или message_id
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, message_key)
);
-- Чистка: без неё таблица растёт вечно. Старое удаляют пачками по этому индексу:
-- секционировать по processed_at нельзя, ключ таблицы обязан её включать.
CREATE INDEX ON processed_messages (processed_at);
// Дедупликация и бизнес-логика обязаны быть в одной транзакции.
// Иначе получаем ту же проблему двойной записи, только в миниатюре.
func (h *Handler) Handle(ctx context.Context, m Message) error {
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // no-op после успешного Commit
// ON CONFLICT DO NOTHING: если строка уже есть, RowsAffected == 0
res, err := tx.ExecContext(ctx,
`INSERT INTO processed_messages (consumer, message_key)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
h.group, m.Key)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
// Уже обрабатывали. Коммитить нечего, просто подтверждаем брокеру.
return nil
}
if err := h.apply(ctx, tx, m); err != nil { // вся бизнес-логика через tx
return err
}
return tx.Commit()
}
- Ключ генерируется на каждой попытке. Продюсер делает
uuid.New()внутри цикла ретраев - дубликаты получают разные ключи, и дедупликация не срабатывает. Ключ должен рождаться один раз, вместе с событием. - Дедуп в Redis, бизнес-данные в Postgres. Между
SETNXиCOMMITможно упасть: ключ отметили, работу не сделали, повтор пропустят. Redis годится как быстрый фильтр перед транзакцией, но источником истины должна быть та же БД, что и данные. - Дедуп-таблица без ретеншена. Через полгода это самая большая таблица в базе,
и
INSERTв неё обходится дороже самой бизнес-операции.
Персистентность
Персистентность гарантирует, что сообщение переживёт перезапуск брокера. Для этого сообщение мало принять: его записывают туда, откуда оно достанется после включения. Обратный режим называется transient: сообщение живёт только в оперативной памяти брокера и исчезает вместе с процессом.
Тонкость, на которой ловят: «записано на диск» не равно «не потеряется». Между
write() и физическим носителем стоит page cache. Вызов write()
возвращает успех сразу, как только байты легли в этот кэш, а на пластину или флеш они попадут
когда-нибудь потом. Заставить сбросить их прямо сейчас умеет только fsync;
без него отключение питания стирает всё, что ещё лежало в кэше.
- Kafka сознательно не делает fsync на каждую запись: пишет в page cache,
а сбрасывает по
log.flush.interval.messages/.ms(по умолчанию практически «никогда, отдано ОС»). Надёжность держится не на диске одного узла, а на репликации:acks=all+min.insync.replicas=2приreplication.factor=3. Данные потеряются, только если одновременно упадут несколько брокеров. - RabbitMQ идёт от сообщения: очередь должна быть
durable, сообщение помеченоdelivery_mode=2(persistent), и обязательно нужны publisher confirms, иначеbasic.publishработает как fire-and-forget, и брокер вправе молча потерять сообщение. Quorum queues добавляют репликацию через Raft.
За персистентность платят задержкой и пропускной способностью. Поэтому в проде встречается смешанный режим: критичные события (платежи) идут persistent с подтверждениями, а метрики и телеметрия - transient, потому что терять их не страшно, а платить за них полной надёжностью дорого.
Ordering: где порядок есть, а где его нет
Под ordering понимают порядок сообщений: гарантию, что консьюмер увидит события в той же последовательности, в какой их отправлял продюсер. Звучит как мелочь, но без неё «заказ отменён» иногда приходит раньше «заказ создан», и обработчик не понимает, что ему отменять.
Общее правило: порядок гарантируется только внутри одной единицы упорядочивания. Так называют минимальный кусок системы, где сообщения гарантированно выстроены в одну линию. Таких единиц в системе много, и между собой они никак не синхронизированы. В Kafka эту роль играет партиция (один физический файл-лог внутри топика, подробно в 8.2), в RabbitMQ - очередь при единственном консьюмере и без requeue, в JetStream - стрим (или subject-фильтр внутри него). Глобального порядка «по топику» нет ни у кого, иначе не было бы горизонтального масштабирования.
| Что ломает порядок | Почему | Что делать |
|---|---|---|
| Несколько партиций / очередей | Между ними нет общих часов | Ключ партиционирования = идентификатор сущности |
| Несколько консьюмеров на одной очереди | Обрабатывают параллельно, финишируют вразнобой | Один консьюмер, либо шардировать по ключу |
| Параллелизм внутри консьюмера | Горутина на сообщение = гонка | Пул воркеров, где воркер выбирается как hash(key) % N |
| Ретраи с requeue / retry-топики | Сообщение уезжает в конец, следующие обгоняют | Останавливать обработку ключа, либо принять расхождение |
| DLQ и ручной перезапуск | Сообщение вернётся часы спустя | Версионирование: отбрасывать событие, если версия устарела |
Продюсер с max.in.flight.requests.per.connection > 1 без идемпотентности | Повтор первого батча встанет после второго | enable.idempotence=true - тогда безопасно до 5 |
Глобальный порядок почти никогда не нужен, нужен порядок на сущность: события одного
заказа не должны перемешаться, а события разных заказов друг другу безразличны. Отсюда рецепт:
ключ сообщения = order_id, и всё остальное решается само. Если же порядок нужен
по-настоящему сквозной, самый честный ответ на собесе звучит так: «делаем одну партицию и теряем
масштабирование, либо кладём в событие версию/timestamp и делаем обработчик устойчивым
к перестановке (last-write-wins по версии)».
Split brain у кластеров брокеров
Сеть делится на две части (network partition). Обе половины видят, что «остальные умерли», обе выбирают себе лидера и обе начинают принимать записи. Когда сеть починят, останутся две расходящиеся истории, и корректно слить их нельзя: сообщения либо теряются, либо дублируются, либо оказываются в разном порядке у разных потребителей.
Защищаются все одинаково: через кворум большинства (N/2+1 узлов). Смысл в том, что два любых большинства из одного набора узлов
обязательно пересекаются хотя бы в одном узле, а он не проголосует за два разных решения
сразу. Поэтому две половины разорванной сети не могут обе набрать кворум, и второй
лидер не появляется. Реализовано это у всех по-разному:
- Kafka. Раньше контроллер и метаданные жили в ZooKeeper (кворум ZAB), с KIP-500
переехали в саму Kafka, в KRaft (Raft-кворум контроллеров). Production-ready с 3.3,
миграция с ZooKeeper готова для продакшена с 3.6, а в Kafka 4.0 (2025) ZooKeeper удалён совсем.
Данные страхует
unclean.leader.election.enable=false(так по умолчанию): если ни одна реплика из ISR (подробно в 8.2) не доступна, партиция становится недоступной, но не расходящейся. Это осознанный выбор CP в терминах CAP. - RabbitMQ. Классические зеркалируемые очереди split brain переживали плохо:
при автоматическом слиянии одна половина просто откатывалась, теряя сообщения. Поэтому
они объявлены устаревшими в 3.9 и удалены в 4.0, а штатным вариантом стали quorum queues
на Raft: пишет только большинство, меньшинство отказывает в записи. Для кластера
настраивают
cluster_partition_handling = pause_minorityилиautoheal. - NATS JetStream ведёт отдельную Raft-группу на каждый стрим,
R=3илиR=5.
Не значит. acks=all означает «все реплики из текущего ISR подтвердили».
Если ISR схлопнулся до одной реплики (остальные отстали или упали), acks=all
превращается в acks=1, и падение этого брокера означает потерю. Ровно для этого есть
min.insync.replicas=2: при меньшем ISR продюсер получит
NotEnoughReplicasException и запись не пройдёт. Правильная пара:
RF=3 и min.insync.replicas=2. С ней кластер переживает падение одного брокера
без потерь и без остановки записи.
Очередь vs топик
Базовых моделей обмена две, и различаются они тем, сколько потребителей увидит одно сообщение.
От выбора зависит, кто знает о потребителях. Через очередь идёт команда («сделай задачу»), и отправитель обычно знает, что её выполнят. В топик уходит факт («заказ создан»), и отправителю всё равно, кто на него отреагирует. На собесе по архитектуре из этого вырастает вопрос про commands vs events: команды маршрутизируют в конкретную очередь и валидируют, события публикуют в топик и не валидируют, потому что событие уже случилось и отменять нечего.
Вопросы
8У синхронной цепочки HTTP-вызовов два свойства, которые под нагрузкой становятся смертельными. Доступность перемножается: четыре сервиса по 99,9 % дают 99,6 %, то есть примерно три часа простоя в месяц вместо сорока минут. А задержка суммируется, и клиент ждёт худшую ветку, даже если ему интересна только первая.
Три эффекта, которые даёт брокер
- Развязка (decoupling). Продюсер публикует факт в топик и не знает ни числа подписчиков, ни их состояния. Появился пятый потребитель события «заказ создан» - команда заказов не выкатывает ни строчки кода. Это разница между «изменение в N местах» и «изменение в одном».
- Сглаживание пиков (load levelling). Брокер держит буфер на диске. В чёрную пятницу продюсер выдаёт 30 000 сообщений/с, а консьюмеры продолжают жевать по 4 000/с; растёт лаг, но ничего не падает. Без буфера пришлось бы держать железо под пик, который бывает два раза в год.
- Надёжность. Сообщение лежит в персистентном логе. Потребитель может лежать двадцать минут, подняться и доесть накопившееся. Синхронный запрос при недоступности адресата просто теряется, если только вызывающий сам не построил себе очередь ретраев, то есть плохой брокер.
Чем добить ответ
Обязательно назвать цену, иначе ответ звучит как реклама. За асинхронность платят
конечной согласованностью вместо мгновенной (клиент нажал кнопку, а эффект наступит через
секунду или через минуту), дубликатами, которые надо обрабатывать, потерей сквозного порядка,
отсутствием простого стектрейса (нужен трейсинг с пробросом traceparent в заголовках
сообщения) и ещё одной инфраструктурной системой, которую надо мониторить и обновлять.
Хорошая формулировка: «асинхронность - это не оптимизация, а смена контракта: вместо
ответа мы отдаём обещание».
Когда вызывающему нужен результат прямо сейчас, чтобы показать его пользователю, это работа для синхронного вызова, а «запрос-ответ через две очереди» превращается в RPC с ручной корреляцией, таймаутами и мусорными очередями. Асинхронность оправдана там, где ответ не нужен немедленно: побочные эффекты (письма, индексация, аналитика), долгие задачи, интеграция с внешними системами и веерная рассылка событий.
Push
Брокер сам отправляет сообщение, как только оно появилось: RabbitMQ basic.consume,
core NATS, вебхуки. Задержка минимальная, брокер видит всех потребителей и может
балансировать между ними. Но если потребитель медленнее потока, у него растёт очередь
в памяти, пока процесс не убьёт OOM. Лечится кредитным окном: basic.qos(prefetch=N)
означает «не шли больше N неподтверждённых». По сути так вручную делают то, что pull
даёт даром.
Pull
Потребитель сам запрашивает следующую порцию: Kafka fetch, SQS long polling,
JetStream pull consumers. Плюсы: естественное обратное давление, крупные батчи (эффективнее
по CPU и сети), возможность перечитать с любого offset, простая семантика при добавлении
потребителей. Расплачиваться приходится задержкой на опрос и пустыми запросами; их убирает
long polling: Kafka держит запрос на брокере до fetch.max.wait.ms (500 мс
по умолчанию) или пока не наберётся fetch.min.bytes, поэтому при живом трафике
данные приходят практически мгновенно.
RabbitMQ толкает с кредитами и под нагрузкой ведёт себя «pull-подобно». Kafka тянет
с long poll и по задержке похожа на push. JetStream умеет обе модели явно:
push-консьюмер с MaxAckPending (те же кредиты) и pull-консьюмер с
Fetch(batch). Правильный вывод для собеса: модель доставки определяет,
где копится очередь при перегрузке - в памяти потребителя или на диске брокера.
Второе почти всегда лучше.
Три уровня
- При at-most-once подтверждаем до обработки. Упали в середине - сообщение потеряно навсегда. Осмысленно для метрик и телеметрии, где потеря дешевле дубликата.
- С at-least-once подтверждение идёт после успешной обработки. Если упасть между обработкой и подтверждением, брокер переотправит сообщение, и будет дубль. Это дефолт Kafka, RabbitMQ, SQS, JetStream и 95 % прода.
- exactly-once обещает ровно один эффект, но как свойство транспорта в общем случае недостижим.
Почему невозможно
Проблема двух генералов. Две армии по краям долины, атака удастся только одновременная, гонцов перехватывают. Доказано: протокола с конечным числом сообщений, гарантирующего согласованное решение, не существует. Доказательство от противного: возьмём кратчайший корректный протокол; его последнее сообщение может потеряться, но раз протокол корректен и без него, оно лишнее, значит, есть протокол короче; повторяем до нуля сообщений и получаем противоречие. Прикладной перевод: отправитель принципиально не может узнать, был ли обработан его последний запрос, и вынужден выбирать между риском дубликата и риском потери. Рядом стоит FLP (1985): в асинхронной системе с возможным отказом одного узла детерминированный консенсус за конечное время невозможен.
Что делать на практике
Признать at-least-once и сделать обработчик идемпотентным, либо атомарно фиксировать результат работы и факт потребления в одной транзакции. Это и есть «effectively once». Kafka EOS относится ко второму варианту: транзакция покрывает запись в выходные партиции и коммит offset входных, то есть работает только для read-process-write внутри Kafka. Как только обработчик отправляет письмо или дёргает внешний API, гарантия испаряется.
Кандидат говорит «в Kafka есть exactly-once, включаем и всё». Follow-up: «а если консьюмер пишет результат в Postgres - что даст транзакция Kafka?» Правильный ответ: ничего, потому что коммит offset в Kafka и коммит в Postgres идут двумя разными транзакциями без общего координатора; нужен либо идемпотентный апсерт в Postgres, либо inbox-таблица, либо outbox на стороне записи.
Путь первый: операция идемпотентна сама по себе
Он лучше, потому что не требует хранить состояние. UPDATE users SET status='active'
WHERE id=$1 идемпотентен, UPDATE accounts SET balance = balance - 100 - нет.
Приёмы: INSERT ... ON CONFLICT DO NOTHING или DO UPDATE по естественному
ключу, запись абсолютного значения вместо дельты, оптимистическая блокировка по версии
(WHERE version < $2), «перевод в состояние» вместо «переключения состояния».
Путь второй: явная дедупликация
Нужен, когда операция неидемпотентна по природе: списание, письмо, вызов внешнего API.
Ключ выбираем в таком порядке. Лучше всех бизнес-ключ (payment_id,
order_id + event_type, aggregate_id + version): переживает
и переотправку продюсером, и повторную генерацию события. Дальше message_id, созданный
один раз вместе с событием и положенный в заголовок. Последними идут координаты
в брокере (topic:partition:offset): работают только внутри одной
партиции и ломаются при перекладывании через retry-топик.
CREATE TABLE processed_messages (
consumer text NOT NULL, -- имя группы: одно сообщение едят несколько консьюмеров
message_key text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, message_key)
);
-- PARTITION BY RANGE (processed_at) здесь не выйдет: первичный ключ секционированной
-- таблицы обязан включать processed_at. Старое чистят DELETE пачками по индексу.
CREATE INDEX ON processed_messages (processed_at);
Вставку в дедуп-таблицу и бизнес-изменения делают одной транзакцией. Если их разнести, между ними можно упасть, и повтор отбросят, хотя работа не сделана. Дедуп в Redis допустим только как быстрый фильтр перед транзакцией, но источником истины он быть не может.
Хранить ключи вечно нельзя. Разумный горизонт равен максимальному времени жизни сообщения в системе с запасом: retention топика плюс максимальная задержка retry-цепочки плюс время, за которое реально разбирают DLQ. Обычно от суток до недели. Об этом почти никто не говорит, поэтому, если назвать окно ретеншена, ответ сразу выделится.
write()
и пластиной стоит page cache ядра.Без персистентности брокер превращается в буфер в оперативной памяти: перезапуск пода, OOM или падение узла стирают всё, что не успели прочитать. Персистентность нужна везде, где потеря сообщения означает потерю денег или расхождение данных между сервисами: платежи, изменения статусов, любые события, по которым другой сервис строит своё состояние.
Две разные реализации
- Kafka. Сознательно не делает
fsyncна каждую запись: пишет в page cache, сброс отдан ОС (log.flush.interval.messagesпо умолчанию практически «никогда»). Надёжность даёт репликация, а не диск одного узла:acks=allприreplication.factor=3иmin.insync.replicas=2. Запись пропадёт, только если питание отключится сразу у нескольких брокеров. Это осознанный размен: fsync на запись стоил бы порядка производительности. - RabbitMQ. Идёт от сообщения: очередь объявлена
durable, сообщение помеченоdelivery_mode=2(persistent), и обязательно включены publisher confirms. Без confirmsbasic.publishработает как fire-and-forget: TCP-запись прошла, а брокер мог не принять, и ты об этом не узнаешь. Quorum queues добавляют репликацию через Raft и пишут на диск всегда.
«Очередь durable - значит, сообщения не потеряются?» Нет. durable у классической
очереди означает, что переживёт перезапуск сама очередь как объект, а не её содержимое.
Нужны обе галочки: durable queue плюс persistent message. И даже этого мало без publisher
confirms - потерять можно на участке «продюсер отправил, брокер ещё не записал».
Что ломает порядок
- Несколько партиций. Между ними нет общих часов и нет координации. Лечится
ключом партиционирования:
hash(order_id) % partitions, тогда все события одного заказа лягут в одну партицию и порядок сохранится. - Несколько консьюмеров на одной очереди. Сообщения раздаются по кругу и финишируют вразнобой: второе может обработаться раньше первого просто потому, что оно легче.
- Параллелизм внутри консьюмера. «Горутина на сообщение» уничтожает порядок даже
при одной партиции. Если нужны и параллелизм, и порядок, берут пул воркеров с выбором
hash(key) % N: один ключ всегда идёт в одного воркера. - Ретраи. Сообщение уходит в конец очереди или в retry-топик, а следующие его обгоняют. Это самый недооценённый источник расхождений.
- Продюсер с
max.in.flight.requests.per.connection > 1без идемпотентности. Ретрай первого батча приходит после второго. Сenable.idempotence=trueброкер по sequence number отклоняет батч с разрывом в номерах (OUT_OF_ORDER_SEQUENCE_NUMBER), продюсер отправляет заново по порядку, и до 5 in-flight безопасно.
Как отвечать
Сначала спросить, какой именно порядок нужен. Почти всегда нужен порядок на сущность, а не глобальный: события одного заказа не должны перемешаться, события разных заказов друг другу безразличны. Отсюда рецепт в одну строку: ключ сообщения равен идентификатору сущности. Если же порядок нужен по-настоящему сквозной, честный ответ: «одна партиция и один консьюмер, масштабирование теряем», либо «кладём в событие версию и делаем обработчик устойчивым к перестановке (last-write-wins по версии, устаревшее событие отбрасываем)».
Механика: network partition разрезает кластер на группы, которые не видят друг друга. Молчащий узел не отличить от мёртвого, поэтому каждая группа решает, что остальные умерли, и продолжает работу. Когда связность восстанавливается, в двух половинах лежат разные сообщения под одними и теми же offset. Корректного автоматического слияния не существует: любое решение либо теряет данные, либо создаёт дубликаты, либо ломает порядок.
Как защищаются конкретные брокеры
- Kafka. Метаданные и выборы лидера идут через кворум: раньше ZooKeeper (ZAB),
с KIP-500 - KRaft, Raft-кворум контроллеров внутри самой Kafka. Production-ready
с 3.3, миграция с 3.6, в Kafka 4.0 ZooKeeper удалён полностью. Для данных стоит предохранитель
unclean.leader.election.enable=false(дефолт): если ни одной реплики из ISR нет, партиция становится недоступной, но не расходящейся. Это выбор CP в терминах CAP. - RabbitMQ. Классические зеркалируемые очереди split brain переживали плохо: при
автослиянии одна половина откатывалась и теряла сообщения. Они deprecated в 3.9 и
удалены в 4.0; теперь штатно используют quorum queues на Raft. Плюс настройка кластера
cluster_partition_handling:pause_minority(меньшинство само останавливается),autohealилиignore- последний режим и есть разрешённый split brain. - NATS JetStream - Raft на уровне стрима,
R=3илиR=5; при потере кворума стрим недоступен целиком - ни записи, ни чтения.
Упомянуть, что кворум требует нечётного числа узлов: кластер из двух брокеров
не переживает потерю одного (нет большинства), а из четырёх переживает столько же
отказов, сколько из трёх, но стоит дороже. И что acks=all не спасает сам
по себе: он означает «все реплики из текущего ISR», а если ISR схлопнулся до одной,
это фактически acks=1. Поэтому пара RF=3 + min.insync.replicas=2
считается стандартным прод-минимумом.
Очередь (point-to-point)
Несколько консьюмеров подписаны на одну очередь, брокер отдаёт каждое сообщение одному из них. Это паттерн competing consumers: масштабирование = добавить процесс, брокер сам перераспределит нагрузку. Сообщение после подтверждения удаляется. Порядок сохраняется, только пока консьюмер один. Так обычно раздают задачи: сгенерировать PDF, отправить письмо, пересчитать отчёт.
Топик (publish/subscribe)
Продюсер публикует событие, каждый подписчик получает свою копию и обрабатывает независимо. Продюсер не знает о подписчиках вообще. Сообщение не «съедается», а живёт по retention-политике или пока его не заберут все. Сюда ложатся события домена: «заказ создан», на который реагируют склад, биллинг, уведомления и аналитика.
Как это выглядит в реальных брокерах
В RabbitMQ это разные объекты: очередь - это очередь, а pub/sub делается через
fanout-exchange и по отдельной очереди на каждого подписчика. В Kafka обе модели
выражены через один лог и понятие consumer group: внутри группы партиции поделены между
участниками (получается очередь), разные группы читают весь топик независимо
(получается pub/sub). Поэтому в Kafka, чтобы добавить нового потребителя, достаточно
завести новый group.id, и старые потребители об этом даже не узнают.
Из этой пары вырастает архитектурный вопрос. Команда («сними деньги») адресована конкретному обработчику, у неё один исполнитель, её можно отклонить, и живёт она в очереди. Событие («деньги сняты») сообщает о факте в прошедшем времени, у него ноль или много подписчиков, отменять нечего, и место ему в топике. Путаница между ними («отправляем событие SendEmail») обычно означает, что связанность на самом деле никуда не делась.
Что стоит запомнить
- At-least-once допускает повторную обработку, поэтому обработчик должен быть идемпотентным.
- Ack означает завершение этапа доставки, но его точный смысл зависит от брокера и момента фиксации.
- Порядок обычно ограничен очередью, partition или ключом, а не всем кластером.
Частые ошибки
- Не отправляй ack до durable business commit: сбой после ack теряет работу.
- Не обещай exactly-once между брокером и внешней БД без общей транзакции или дедупликации.
- Дедупликационный ключ должен представлять логическую операцию и быть уникален в scope consumer.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Посчитай повтор после сбоя
Consumer прочитал message M, записал заказ в БД, процесс упал до ack. После рестарта брокер доставил M повторно. Сколько раз может выполниться handler и сколько заказов должно появиться?
delivery M -> INSERT order -> crash -> redelivery M
Ответ и разбор
Handler может выполниться дважды, потому что брокер не получил ack. В БД должен остаться один заказ: уникальный message key/operation key и business write должны фиксироваться в одной транзакции. Повтор увидит конфликт уникальности и подтвердит сообщение без повторного эффекта.
Код-ревью
2. Найди окно потери
Код сначала отправляет ack брокеру, а затем делает INSERT заказа в БД. Что будет при падении между шагами и как поменять порядок?
if err := d.Ack(false); err != nil { return err }
return repo.InsertOrder(ctx, msg.Order)
Ответ и разбор
Падение после ack и до INSERT означает безвозвратную потерю заказа: брокер считает сообщение обработанным. Сначала запиши дедупликационную отметку и бизнес-изменение в одной DB-транзакции, затем ack. Если ack потеряется, повтор будет отброшен по уникальному ключу.
Применение
3. Выбери push или pull
Потребителю нужно регулировать скорость обработки, а иногда он временно останавливается на плановые работы. Почему pull-модель может помочь и что остаётся обязанностью системы?
Ответ и разбор
Pull позволяет потребителю забирать ограниченное число сообщений только когда у него есть ёмкость, что упрощает backpressure и остановку. Брокер всё равно должен хранить сообщения по принятой политике, а consumer обязан контролировать lag, параллелизм и graceful shutdown. Push тоже можно ограничивать prefetch, но это другой механизм.
Применение
4. Определи scope дедупликации
Один event получают два независимых consumer: billing и analytics. Какой ключ использовать в таблице processed_messages, чтобы обработка одного не блокировала другого?
Ответ и разбор
Используй составной уникальный ключ (consumer, message_key). Тогда повтор billing будет отклонён, а analytics всё ещё сможет обработать то же событие в своём scope. Глобальная уникальность только по message_key ошибочно заставила бы первого consumer пометить событие обработанным для всех.