Перейти к содержанию

B05 · Асинхронность и обмен сообщениями#

Всё, что не нужно клиенту немедленно, стоит вынести из синхронного пути. Это самый действенный приём масштабирования после кэша. И он же переносит систему в мир, где сообщения дублируются, приходят не по порядку и иногда не приходят вовсе.

Блок сознательно не сводится к Kafka: выбор брокера идёт после выбора модели взаимодействия, а половине систем брокер вообще не нужен. Модели взаимодействия, гарантии, транзакционность и обработка ошибок разобраны здесь наравне с брокерами.

Навигация: MSG-01 · MSG-02 · MSG-03 · MSG-04 · MSG-05 · MSG-06 · MSG-07 · MSG-08 · MSG-09 · MSG-10 · MSG-11


MSG-01 · Модели взаимодействия: команда, событие, запрос-ответ#

Шаг W2

TL;DR. Прежде чем выбирать брокер, надо определить характер сообщения: команда адресована исполнителю, событие сообщает о факте, запрос требует ответа. Это разные контракты и разная связанность.

Проблема. «Отправим сообщение» — не решение, а откладывание трёх решений:

  • кто знает о ком — адресат или подписчики;
  • ждёт ли отправитель результата;
  • что произойдёт, если получателя сейчас нет.

Ответы определяют не только технологию, но и то, придётся ли переписывать интеграцию при добавлении второго потребителя.

Ось 1 — тип сообщения.

Тип Формулировка Кто знает о ком Можно ли отклонить
Команда Императив: СписатьСоСчёта Отправитель знает адресата Да — получатель может отказать
Событие Прошедшее время: ЗаказОплачен Источник не знает подписчиков Нет — факт уже случился
Запрос Вопрос: ДайБалансСчёта Отправитель знает адресата и ждёт ответ —

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

Ось 2 — адресация.

  • Point-to-point (competing consumers, конкурирующие потребители). Сообщение получает один из потребителей. Так распределяются задачи: N обработчиков разбирают очередь, параллелизм регулируется их числом.
  • Publish-subscribe (издатель — подписчик, fan-out). Сообщение получает каждый подписчик. Так распространяются события: у каждого потребителя своя копия и своя позиция чтения.

Одна и та же система обычно использует обе модели. Событие публикуется всем группам подписчиков, а внутри группы конкурирующие потребители делят работу. Так устроены группы потребителей (consumer groups) в Kafka (MSG-04) и связка «обменник (exchange) → очередь» в RabbitMQ (MSG-03).

Ось 3 — ожидание ответа.

  • Fire-and-forget — отправил и забыл; надёжность обеспечивает брокер.
  • Запрос-ответ поверх брокера — очередь для ответа плюс идентификатор корреляции. Технически возможно, но чаще всего это признак того, что взаимодействие синхронное по смыслу. Тогда честнее прямой вызов (NET-10): его проще отлаживать. Оправдано, когда нужны буферизация пиков или гарантированная доставка запроса.
  • Асинхронный колбэк — ответ приходит отдельным событием или webhook (MSG-10). Подходит для длительных операций.

Насколько «толстое» событие. Три уровня, между которыми выбирают осознанно:

  • Уведомление — только идентификатор и тип (заказ 42 оплачен). Минимальная связанность по данным, но каждый потребитель идёт за подробностями в источник — растёт нагрузка на него и появляется временна́я связанность.
  • Событие с состоянием (event-carried state transfer) — полезная нагрузка содержит всё нужное. Потребитель автономен, источник разгружен; плата — дублирование данных, версионирование схемы (NET-18) и риск обработать устаревшую версию.
  • Event sourcing (ARCH-07) — поток событий сам служит источником истины.

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

Trade-offs и режимы отказа. Асинхронность оплачивается отложенной согласованностью: результат виден не сразу, и UI должен показывать промежуточное состояние. События с состоянием превращают схему события в публичный контракт, ломать который нельзя. Если команду отправили через брокер, а она не выполнилась, нужен отдельный механизм, чтобы выяснить её судьбу. Иначе операция теряется молча.

Что мерить.

  • число потребителей на тип события — рост подтверждает, что событийная модель окупилась;
  • задержку от факта до реакции;
  • долю запрос-ответ поверх брокера — высокая доля означает, что связь на деле синхронная;
  • долю обработанных устаревших версий событий.

Связано: MSG-02 очередь vs лог · ARCH-04 event-driven · MSG-07 outbox · NET-18 контракты · CASE-02 кейс: лента новостей

Источники: Martin Fowler — What do you mean by "Event-Driven"? · Hohpe & Woolf — Enterprise Integration Patterns · microservices.io — Communication style


MSG-02 · Очередь против лога#

Шаг W4

TL;DR. Очередь раздаёт задачи и забывает их; лог хранит поток событий, который каждый читает в своём темпе. Выбор между RabbitMQ и Kafka — про это, а не про производительность.

Проблема. «Нужна очередь» — недостаточная формулировка: за ней стоят две разные модели. В одной сообщение адресовано исполнителю и после выполнения исчезает. В другой событие — факт, который может понадобиться нескольким потребителям, в том числе тем, которых ещё нет. Если выбрать не ту модель, систему придётся перестраивать при добавлении второго потребителя.

Как работает.

Очередь (RabbitMQ, SQS, ActiveMQ). Брокер хранит сообщение до подтверждения и удаляет после. Несколько потребителей конкурируют за сообщения — задачи распределяются между ними. Сильные стороны:

  • гибкая маршрутизация: обменники (exchanges), ключи, шаблоны;
  • приоритеты;
  • отложенная доставка;
  • подтверждение на уровне отдельного сообщения;
  • простой возврат в очередь при сбое.

Прочитать «то, что было вчера», нельзя — сообщение уже удалено.

Лог (Kafka, Pulsar, Kinesis, Redpanda). События дописываются в конец партиции и хранятся заданный срок независимо от того, кто их прочитал. Потребитель хранит offset (смещение) — свою позицию. Отсюда три свойства:

  • несколько независимых групп потребителей читают один поток;
  • историю можно перечитать (replay) — например, при выкатке нового потребителя или после бага;
  • порядок гарантирован в пределах партиции.

Как выбирать

Признак задачи Очередь Лог
Сообщение — задача одному исполнителю ✅ —
Событие интересно нескольким подписчикам — ✅
Нужен повтор истории и восстановление состояния — ✅
Нужна сложная маршрутизация и приоритеты ✅ —
Нужен строгий порядок по ключу — ✅ (в партиции)
Нужны отложенные задачи и точечный повтор ✅ сложнее (MSG-08)
Предельно высокая пропускная способность — ✅

Trade-offs и режимы отказа. Лог требует продумывать партиционирование: ключ определяет и порядок, и распределение нагрузки; неудачный ключ даёт перекос (DATA-10). Число потребителей в группе не может превышать число партиций — потолок параллелизма закладывается заранее. В очереди параллелизм свободен, но порядок теряется, как только исполнителей больше одного. Хранение истории в логе — это дисковый объём и политика ретенции, которую надо согласовать с DATA-19.

Что мерить.

  • глубину очереди и лаг потребителей — в сообщениях и в секундах;
  • возраст самого старого необработанного сообщения;
  • скорость обработки против скорости поступления;
  • перекос по партициям.

Связано: MSG-04 Kafka · MSG-06 гарантии доставки · ARCH-04 event-driven · MSG-09 backpressure

Источники: Kleppmann, DDIA, гл. 11 · RabbitMQ — Tutorials и модель обменников · Kafka — Design · ByteByteGo — Kafka vs. RabbitMQ vs. Messaging Middleware vs. Pulsar


MSG-03 · RabbitMQ и модель AMQP#

Шаг W4

TL;DR. Брокер с явной маршрутизацией: производитель публикует в обменник (exchange), привязки определяют, в какие очереди попадёт сообщение. Сильная сторона — гибкость доставки и подтверждение каждого сообщения по отдельности.

Проблема. Задачи нужно распределять между исполнителями, разные типы сообщений — направлять разным обработчикам, часть — выполнять с приоритетом или отложенно, сбойные — убирать в сторону. В логе (MSG-02) всё это приходится строить самому; в AMQP это встроенная модель.

Как устроено.

  • Производитель → обменник → привязка (binding) → очередь → потребитель. Производитель не пишет в очередь напрямую — в этом ключ к гибкости: маршрутизация меняется настройкой привязок, без изменения кода.
  • Типы обменников:
    • direct — точное совпадение ключа маршрутизации;
    • topic — совпадение по шаблону (order.*.created);
    • fanout — копия во все привязанные очереди (publish-subscribe);
    • headers — по заголовкам.
  • Подтверждения. Потребитель подтверждает (ack) каждое сообщение. Неподтверждённое возвращается в очередь при обрыве соединения. Сообщение, отклонённое через nack/reject с requeue=false, уходит в dead-letter exchange (обменник «мёртвых» сообщений) — штатный механизм DLQ (dead letter queue, очередь недоставленных сообщений; MSG-08).
  • Prefetch (QoS) — сколько неподтверждённых сообщений брокер отдаёт одному потребителю. Значение 1 даёт честное распределение и защиту от того, что быстрый потребитель простаивает, пока медленный держит пачку.
  • Publisher confirms (подтверждения публикации) — подтверждение брокером факта приёма; без них производитель не знает, дошло ли сообщение.
  • TTL (время жизни сообщения), приоритеты, отложенная доставка — на уровне сообщения или очереди. Отложенную доставку делают плагином или связкой TTL плюс dead-letter — так строят очереди повторов с нарастающей задержкой.
  • Quorum-очереди — реплицируемые через Raft (DIST-05), пришли на смену зеркалируемым классическим и сегодня служат умолчанием для надёжности.
  • Streams — появившийся позже режим append-only лога с повторным чтением: RabbitMQ закрывает часть сценариев, ради которых раньше брали Kafka.

Когда применять. Распределение задач между исполнителями; сложная маршрутизация по типам и признакам; приоритеты и отложенное выполнение; умеренные потоки (тысячи–десятки тысяч сообщений в секунду). Когда нет: предельно высокая пропускная способность, необходимость хранить и перечитывать историю, несколько независимых групп потребителей одного потока — это профиль лога (MSG-04).

Альтернативы

Вариант Когда лучше Цена
RabbitMQ Маршрутизация, приоритеты, per-message ack (подтверждение каждого сообщения) Не хранилище истории, ниже потолок пропускной способности
Kafka Поток, replay, несколько групп Нет точечного ack и приоритетов
Управляемая очередь (SQS) Не хочется эксплуатировать Меньше возможностей маршрутизации
Таблица-очередь в БД Малый поток, транзакционность с данными Не масштабируется (MSG-05)

Trade-offs и режимы отказа. RabbitMQ спроектирован так, что очередь должна быть почти пустой: она транспорт, а не хранилище. Растущая очередь давит на память и включает flow control (торможение производителей). При достижении порогов брокер останавливает публикацию, и для приложения это выглядит как внезапная недоступность брокера. Порядок нарушается при prefetch > 1 и возврате сообщений в очередь: сохранить его можно только режимом единственного активного потребителя. Бесконечный requeue сбойного сообщения — способ зациклить обработчик (MSG-08).

Что мерить.

  • глубину очередей и число неподтверждённых сообщений;
  • скорость публикации против скорости доставки;
  • срабатывания flow control и сигналов по памяти и диску;
  • возраст самого старого сообщения;
  • глубину DLQ.

Связано: MSG-02 очередь vs лог · MSG-08 повторы и DLQ · MSG-09 backpressure · MSG-05 карта брокеров

Источники: RabbitMQ — AMQP 0-9-1 Model Explained · RabbitMQ — Quorum Queues · RabbitMQ — Consumer Prefetch · Hohpe & Woolf — Enterprise Integration Patterns · ByteByteGo — Kafka vs. RabbitMQ vs. Messaging Middleware vs. Pulsar


MSG-04 · Kafka: устройство, скорость, сценарии#

Шаг W4

TL;DR. Kafka — распределённый лог: топики делятся на партиции, партиции реплицируются, потребители читают по offset (смещению). Kafka быстр за счёт последовательного ввода-вывода, батчей и минимума копирований.

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

Как устроено.

  • Топик → партиции. Партиция — упорядоченный неизменяемый лог. Ключ сообщения определяет партицию, поэтому порядок гарантирован для одного ключа, а не для топика.
  • Offset — у потребителя. Позицию хранит группа потребителей, а не брокер. Отсюда независимость групп и возможность перечитать.
  • Репликация. У партиции есть лидер и последователи. Набор синхронных реплик — ISR (in-sync replicas) — определяет, что считать подтверждённым. Стандартная настройка — acks=all плюс min.insync.replicas=2: при ней запись не теряется при отказе одного узла.
  • Ретенция (срок хранения) — по времени или объёму либо log compaction (уплотнение лога): хранится последнее значение для каждого ключа. Так топик превращается в снимок состояния.

Почему быстрый.

  • запись только в конец файла — последовательный ввод-вывод вместо случайного;
  • страничный кэш ОС вместо собственного;
  • батчи и сжатие целыми пачками;
  • отдача данных потребителю без лишних копирований между буферами (zero-copy);
  • нет учёта каждого сообщения по отдельности — вся бухгалтерия сводится к offset.

Типовые сценарии.

  • шина событий между сервисами;
  • сбор метрик, логов и кликстрима;
  • доставка изменений из БД в аналитику через CDC (change data capture; MSG-07);
  • источник для потоковой обработки;
  • буфер, сглаживающий пики перед медленным потребителем.

Когда применять. Много событий, несколько независимых потребителей, нужны порядок по ключу и возможность повтора. Когда нет: для десятков сообщений в секунду и одного потребителя Kafka — это кластер, который надо эксплуатировать. А задачу решает таблица в БД или управляемая очередь.

Альтернативы

Вариант Когда лучше Цена
Kafka Высокий поток, replay, несколько групп Эксплуатация кластера, партиционирование
Pulsar Нужны разделение хранения и вычисления, мультитенантность Меньше экосистема
Управляемые очереди (SQS, Pub/Sub) Не хочется эксплуатировать Нет replay в привычном виде, лимиты
Таблица-очередь в БД Малый поток, уже есть БД Не масштабируется, конкуренция за строки

Trade-offs и режимы отказа. Число партиций трудно уменьшить и небезопасно увеличивать при завязке на порядок по ключу — при добавлении партиций ключи переезжают. Медленный потребитель копит лаг; при выходе за пределы ретенции данные теряются безвозвратно. acks=1 теряет сообщения при смене лидера. Ребалансировка группы останавливает обработку — частые ребалансировки из-за таймаутов сильно снижают пропускную способность.

Что мерить.

  • лаг по группам и партициям;
  • скорость производителей против скорости потребителей;
  • размер ISR и число недореплицированных партиций;
  • частоту ребалансировок;
  • использование диска относительно ретенции.

Связано: MSG-02 очередь vs лог · MSG-06 гарантии · MSG-07 outbox и CDC · MSG-11 pipeline

Источники: Kafka — Documentation: Design · Kafka — Replication и ISR · Kleppmann, DDIA, гл. 11 · ByteByteGo — System Design: Apache Kafka In 3 Minutes · ByteByteGo — System Design: Why is Kafka fast? · ByteByteGo — System Design: Why is Kafka so Popular? · ByteByteGo — Top Kafka Use Cases You Should Know · ByteByteGo — Apache Kafka Fundamentals You Should Know · ByteByteGo — System Design: Why is Kafka Popular?


MSG-05 · Карта брокеров: что ещё бывает#

Шаг W4

TL;DR. Kafka и RabbitMQ — не весь выбор. Первый вопрос всё равно другой: нужен ли брокер вообще, или задача решается таблицей в имеющейся БД.

Проблема. Выбор обычно сводят к «Kafka или RabbitMQ», хотя половина систем не нуждается ни в том, ни в другом, а другой половине лучше подходит управляемый сервис. Каждый самостоятельно эксплуатируемый брокер — это кластер, дежурство, обновления и экспертиза. Эту стоимость платят ежемесячно, а не один раз при выборе.

Что ещё есть.

  • NATS / JetStream. Предельно лёгкий брокер с адресацией по темам и встроенным запрос-ответом — редкое свойство, удобное для взаимодействия между сервисами. Базовый режим доставляет без сохранения (at-most-once, не больше одного раза); JetStream добавляет хранение, повторное чтение и дедупликацию. Разворачивается одним бинарником; хороший выбор, когда нужны низкая задержка и простая эксплуатация.
  • Apache Pulsar. Разделяет брокер и хранилище (BookKeeper), поэтому масштабируется по этим осям независимо. Поддерживает и очередь, и лог за счёт режимов подписки: эксклюзивной, разделяемой, по ключу. Есть встроенная мультитенантность, выгрузка старых сегментов в объектное хранилище, георепликация. Плата — более сложная архитектура из нескольких компонентов и меньшая экосистема, чем у Kafka.
  • Управляемые очереди AWS: SQS, SNS, EventBridge. SQS — очередь с таймаутом видимости (visibility timeout) вместо явного ack. Стандартный режим даёт at-least-once (как минимум один раз) без гарантии порядка. FIFO-режим даёт порядок и дедупликацию в пределах группы сообщений, но с более жёсткими ограничениями пропускной способности. SNS раздаёт копии подписчикам (fan-out), EventBridge добавляет маршрутизацию по содержимому и реестр схем. Эксплуатации нет; взамен — лимиты сервиса и привязка к облаку.
  • Google Pub/Sub, Azure Service Bus. Аналогичный класс управляемых решений; Service Bus выделяется сессиями (упорядоченная обработка по ключу) и транзакциями в пределах брокера.
  • Redis Streams. Лог с группами потребителей и списком необработанных сообщений. Дёшево и быстро, если Redis уже в системе. Ограничение — durability (сохранность записанного) на уровне Redis (CACHE-05): при неудачном сочетании настроек и аварии часть сообщений теряется. Подходит для потоков, где допустима потеря, и для умеренных объёмов.
  • Таблица-очередь в реляционной БД. SELECT ... FOR UPDATE SKIP LOCKED даёт корректную конкурирующую выборку без брокера. До тысяч сообщений в секунду это полностью рабочее решение с большим преимуществом. Задача ставится в той же транзакции, что и изменение данных, поэтому проблема двойной записи (MSG-07) исчезает сама. Минусы — нагрузка на БД, отсутствие fan-out и потолок масштабирования.

Сравнение

Система Модель Порядок Повтор истории Эксплуатация
Kafka лог в партиции да своя, тяжёлая
RabbitMQ очередь (+streams) ограниченно только в streams своя, средняя
NATS / JetStream и то, и другое по теме да (JetStream) своя, лёгкая
Pulsar и то, и другое по ключу да своя, тяжёлая
SQS / SNS очередь / fan-out в FIFO-режиме нет управляемая
Redis Streams лог в потоке да, ограниченно своя, лёгкая
Таблица в БД очередь по запросу да (это же строки) никакой дополнительной

Как выбирать.

  1. Нужен ли брокер? Меньше сотен сообщений в секунду и один потребитель → таблица в БД.
  2. Готовы ли эксплуатировать кластер? Нет → управляемый сервис.
  3. Нужны ли история и несколько независимых групп потребителей? Да → лог (Kafka, Pulsar, JetStream).
  4. Нужны ли маршрутизация, приоритеты, отложенная доставка? Да → очередь (RabbitMQ, Service Bus).
  5. Всё перечисленное сразу и есть команда эксплуатации? → Pulsar.

Trade-offs и режимы отказа. Управляемый сервис снимает эксплуатацию, но навязывает лимиты (размер сообщения, сроки хранения, пропускная способность) и усложняет локальную разработку. Лёгкий брокер экономит усилия ровно до тех пор, пока не потребуются гарантии, ради которых существуют тяжёлые. Два брокера в одной системе — распространённый исход эволюции и удвоенная операционная нагрузка. Прежде чем добавлять второй, стоит проверить, не закрывает ли задачу первый.

Что мерить.

  • стоимость сообщения — в деньгах и в человекочасах эксплуатации;
  • задержку доставки;
  • соответствие лимитам сервиса;
  • число брокеров в системе.

Связано: MSG-02 очередь vs лог · MSG-03 RabbitMQ · MSG-04 Kafka · MSG-07 outbox

Источники: NATS — JetStream · Apache Pulsar — Concepts and Architecture · AWS SQS — Standard и FIFO очереди · Redis — Streams · PostgreSQL — SELECT FOR UPDATE SKIP LOCKED


MSG-06 · Гарантии доставки и дедупликация#

Шаг W6

TL;DR. На практике выбор один: at-least-once (как минимум один раз) плюс идемпотентный потребитель. «Exactly-once» (ровно один раз) — свойство всей цепочки, а не галочка в конфиге брокера.

Проблема. Между отправителем, брокером и получателем есть два места, где теряется ответ, а не сообщение:

  • производитель не получил подтверждение записи — записалось или нет?
  • потребитель обработал сообщение, но не успел подтвердить — обработается ли повторно?

Из этой неопределённости и растут все гарантии.

Как работает.

  • At-most-once (не больше одного раза). Подтверждать до обработки. Быстро, дубликатов нет, потери есть. Годится для метрик и телеметрии.
  • At-least-once. Подтверждать после обработки. Потерь нет, дубликаты есть. Практическое умолчание.
  • Exactly-once. Недостижимо в общем виде между независимыми системами: одновременно записать «эффект» и «подтверждение» атомарно нельзя, если это разные хранилища. Достижимо в частных случаях: транзакции Kafka обеспечивают атомарность «прочитал из топика → записал в топик и сдвинул offset» внутри Kafka. За её пределами нужна дедупликация на стороне получателя.
  • Effectively-once (по эффекту один раз) — практическая формулировка: доставка at-least-once + идемпотентная обработка = наблюдаемый эффект ровно один раз.

Как делать идемпотентного потребителя.

  1. У сообщения есть стабильный идентификатор — доменный ключ (номер заказа) надёжнее технического, потому что переживает переотправку.
  2. Обработка и запись отметки «обработано» происходят в одной транзакции с изменением данных. Отметка — строка с уникальным индексом по идентификатору; повторная вставка падает и служит сигналом дубликата.
  3. Естественная идемпотентность лучше журналов: UPDATE ... SET status='paid' WHERE id=? AND status='pending' безопасен при повторе сам по себе.
  4. Хранение отметок ограничено по времени — окно дедупликации должно превышать максимальную задержку повторной доставки.

Порядок — отдельная гарантия. At-least-once ничего не обещает о порядке при нескольких потребителях. Если порядок важен, нужен ключ партиционирования и обработка одного ключа одним потребителем — либо версия в сообщении и отбрасывание устаревших.

Три гарантии

Гарантия Как достигается Потери Дубликаты Где уместна
At-most-once Подтверждать до обработки Есть Нет Метрики, телеметрия
At-least-once Подтверждать после обработки Нет Есть Умолчание для всего остального
Effectively-once At-least-once + идемпотентный потребитель Нет Нет по эффекту Деньги, заказы, начисления

Trade-offs и режимы отказа. Дедупликация требует состояния, и это состояние — ещё одно хранилище со своей доступностью. Слишком короткое окно пропускает дубликаты при долгом сбое; слишком длинное — растёт. Транзакции Kafka снижают пропускную способность и не распространяются на внешние системы. Идемпотентность по техническому идентификатору не спасает от повторной отправки того же события с новым идентификатором.

Что мерить.

  • долю повторных доставок;
  • число отброшенных дубликатов;
  • размер и возраст журнала дедупликации;
  • число нарушений порядка — их выдаёт версия сообщения.

Связано: NET-16 идемпотентность API · MSG-07 outbox · MSG-08 повторы потребителя · DIST-08 saga

Источники: Kafka — Message delivery semantics · Confluent — Transactions and exactly-once · Kleppmann, DDIA, гл. 11


MSG-07 · Transactional outbox и CDC#

Шаг W6

TL;DR. Нельзя атомарно записать в БД и отправить сообщение. Можно записать и то, и другое в БД одной транзакцией, а отправку сделать отдельным процессом.

Проблема — двойная запись. Код меняет заказ в БД и публикует событие в брокер. Если публикация упала после коммита, событие потеряно навсегда: подписчики не узнают об изменении, данные разъедутся. Если публикация прошла, а транзакция откатилась — подписчики узнали о том, чего не было. Порядок операций не спасает: неатомарность остаётся в любом варианте.

Как работает.

Transactional outbox (таблица исходящих событий в той же транзакции).

  1. В той же транзакции, что и изменение данных, вставляется строка в таблицу outbox: тип события, полезная нагрузка, ключ, время.
  2. Отдельный процесс — relay (ретранслятор) — читает неотправленные строки, публикует их в брокер и помечает отправленными.
  3. Публикация at-least-once (MSG-06), поэтому потребители должны быть идемпотентными.

Порядок сохраняется, если relay читает и публикует по возрастанию идентификатора и учитывает ключ партиционирования.

CDC (change data capture). Вместо отдельной таблицы читается журнал транзакций БД (WAL в PostgreSQL, binlog в MySQL) — например, через Debezium. Плюсы: не нужно менять код приложения, поток изменений полный и упорядоченный. Минусы: события выражены в терминах строк таблиц, а не домена, и потребители завязываются на схему БД. Поток нужно преобразовывать в доменные события.

Комбинация — CDC поверх таблицы outbox: приложение пишет доменные события, а забирает их не поллер (опрашивающий процесс), а CDC-коннектор. Так получается и доменный контракт, и надёжная доставка без опроса.

Когда применять. Как только изменение данных должно приводить к событию для других систем. Когда нет: если получатель — та же транзакция и та же БД, событие не нужно. Если потеря события безболезненна (аналитика по принципу best-effort, «сколько получится»), можно обойтись прямой публикацией.

Альтернативы

Вариант Надёжность Цена
Прямая публикация после коммита Теряет события при сбое Простота
Transactional outbox Не теряет Таблица, relay, задержка
CDC из журнала БД Не теряет, ничего не меняет в коде Связанность со схемой БД, отдельный компонент
Outbox + CDC Не теряет, доменные события Максимум движущихся частей
Двухфазная фиксация с брокером Теоретически атомарно Блокировки, редко поддерживается (DIST-08)

Trade-offs и режимы отказа. Outbox добавляет задержку от коммита до публикации (миллисекунды при CDC, сотни миллисекунд при опросе) и нагрузку на БД. Таблица outbox без очистки растёт неограниченно. Relay в одном экземпляре — узкое место и точка отказа; в нескольких — требует блокировки или партиционирования, иначе события публикуются не по порядку. CDC ломается при миграции схемы (DATA-17), поэтому изменения схемы и потребителей согласовывают.

Что мерить.

  • задержку от коммита до публикации;
  • размер и возраст неотправленной части outbox;
  • лаг CDC-коннектора;
  • число повторных публикаций.

Связано: MSG-06 гарантии доставки · DIST-08 saga · CACHE-03 инвалидация кэша · DATA-15 OLTP/OLAP

Источники: microservices.io — Transactional outbox · Debezium — Documentation · Kleppmann, DDIA, гл. 11


MSG-08 · Повторы потребителя, DLQ, «ядовитые» сообщения#

Шаг W7

TL;DR. Ошибки делятся на временные и постоянные. Первые повторяют с задержкой, вторые убирают в сторону, иначе одно сообщение останавливает поток.

Проблема. Потребитель упал на сообщении. Если повторять бесконечно — обработка встаёт: в упорядоченном логе следующее сообщение партиции так и не будет прочитано: «ядовитое» (poison) сообщение блокирует партицию. Если не повторять — теряются данные при временном сбое БД. Различать «попробуй ещё раз» и «это не заработает» приходится явно.

Как работает.

  1. Классификация ошибки. Временная (недоступна БД, таймаут, 5xx у зависимости, 429) → повтор. Постоянная (не проходит валидацию, нет обязательного поля, ссылка на несуществующую сущность) → сразу в сторону, повтор бесполезен.
  2. Повторы с задержкой. Внутри процесса — только короткие; длинные повторы держат поток. Для длинных используют retry-топики (топики повторов). Сообщение с ошибкой публикуется в топик с задержкой — 5 с, 1 мин, 10 мин, — и его потребитель читает сообщение позже. В очередях эту роль играют отложенные сообщения или отдельные очереди повторов.
  3. DLQ (dead letter queue, очередь недоставленных сообщений) — конечная остановка после исчерпания попыток. К сообщению прикладывается контекст: причина, стек, число попыток, исходный топик и offset.
  4. DLQ — не архив, а очередь задач для людей. На неё нужен алерт, владелец и инструмент повторной отправки после исправления.
  5. Идемпотентность обязательна (MSG-06): повтор частично обработанного сообщения не должен удваивать эффект.

Влияние на порядок. Отправка сообщения в retry-топик означает, что оно будет обработано позже последующих — порядок для этого ключа нарушен. Если порядок критичен, вариантов два: останавливать обработку ключа целиком до успеха либо переводить весь ключ в «карантин».

Классификация ошибки определяет действие

Тип ошибки Признак Действие
Временная Таймаут, 5xx у зависимости, 429, недоступна БД Повтор с нарастающей задержкой
Постоянная Не проходит валидацию, нет обязательного поля Сразу в сторону — повтор бесполезен
Неизвестная Необработанное исключение Ограниченное число попыток, затем DLQ
«Ядовитая» Падает при каждой попытке и блокирует партицию Карантин ключа, изоляция от общего потока

Trade-offs и режимы отказа. Бесконечные повторы при массовом сбое зависимости превращаются в самоподдерживающуюся нагрузку — нужны бюджет повторов и circuit breaker (предохранитель; REL-02). DLQ без мониторинга молча накапливает потерянные бизнес-операции: это самый частый способ «потерять» платежи. Повторная отправка из DLQ без исправления причины возвращает сообщения обратно в DLQ.

Что мерить.

  • число сообщений в DLQ и скорость их поступления — для критичных потоков алерт на любое ненулевое значение;
  • распределение по причинам;
  • число попыток до успеха;
  • задержку обработки с учётом повторов.

Связано: REL-01 таймауты и повторы · MSG-06 гарантии доставки · MSG-09 backpressure · OPS-10 алертинг

Источники: Confluent — Error handling patterns for Apache Kafka · AWS SQS — Dead-letter queues · Azure Architecture — Retry pattern


MSG-09 · Backpressure и управление потоком#

Шаг W7

TL;DR. Когда источник быстрее приёмника, разница неизбежно где-то накапливается. Backpressure (обратное давление) заставляет источник замедлиться, а не даёт системе исчерпать память.

Проблема. Производитель отправляет 10 000 сообщений в секунду, потребитель обрабатывает 6 000. Разница копится:

  • в неограниченной очереди в памяти — до OOM (нехватки памяти);
  • в брокере — до исчерпания диска и выхода за ретенцию;
  • в пуле соединений — до таймаутов у всех клиентов.

Отказ наступает не постепенно, а обрывом, причём в компоненте, который сам по себе исправен.

Как работает.

  • Ограниченные буферы. Любая внутренняя очередь должна иметь предел. Неограниченная очередь — отложенный отказ по памяти.
  • Pull вместо push. Потребитель сам запрашивает столько, сколько может обработать (модель Kafka, Reactive Streams с запросом n элементов). Backpressure возникает естественно: не запросил — не получил.
  • Явный отказ вместо накопления. Когда буфер полон, вариантов три: отклонить с 429/503 и Retry-After; отбросить сообщения низкого приоритета — load shedding (сброс нагрузки); заблокировать производителя, если он терпит ожидание.
  • Ограничение на входе (EDGE-06) — самый дешёвый способ: не пустить в систему больше, чем она способна обработать.
  • Адаптивные лимиты параллелизма — размер окна подстраивается по наблюдаемой задержке (принцип, аналогичный управлению перегрузкой в TCP).
  • Автоскейлинг потребителей — помогает, если ограничение в вычислениях, и не помогает, если упирается в общую БД.

Когда применять. Везде, где есть асинхронная граница: очередь, поток, пул. Когда нет: если поток заведомо ограничен на входе и буфер физически не может переполниться.

Альтернативы

Стратегия при переполнении Когда уместна Цена
Блокировать производителя Производитель терпит ожидание Давление уходит вверх по цепочке
Отклонять (fail fast, быстрый отказ) Синхронный API Видимые ошибки у клиента
Отбрасывать по приоритету Есть данные, которые не жалко Потеря части данных
Разгружать в долговременный буфер (брокер, диск) Пик кратковременный Задержка, стоимость хранения
Масштабировать потребителя Узкое место в вычислениях Не помогает при общей БД

Trade-offs и режимы отказа. Backpressure не устраняет проблему, а перемещает её к источнику — там тоже нужен план. Блокировка производителя в синхронном сервисе превращается в рост задержки и исчерпание потоков: часто честнее отклонить. Слишком большой буфер даёт «отложенный отказ» — очередь растёт минутами, а сообщения к моменту обработки уже неактуальны.

Что мерить.

  • заполненность буферов и очередей;
  • время ожидания в очереди, а не только время обработки;
  • долю отклонённых и отброшенных сообщений;
  • скорость поступления против скорости обработки;
  • глубину лага во времени.

Связано: EDGE-06 rate limiting · REL-02 circuit breaker · PERF-07 закон Литтла · REL-04 деградация

Источники: Reactive Streams — Specification · Google SRE Book, гл. 21 «Handling Overload» · Netflix — Performance Under Load (adaptive concurrency limits)


MSG-10 · Webhooks#

Шаг W2

TL;DR. Webhook — HTTP-вызов от вас к чужому серверу при наступлении события. Идея простая, вся сложность — в доставке, подписи и повторах.

Проблема. Внешней системе нужно узнавать о событиях, но опрос API раз в минуту создаёт нагрузку и даёт задержку. Webhook переворачивает направление: вы сами вызываете чужой эндпоинт. И сразу получаете чужую надёжность в своей системе: получатель может лежать, отвечать за 30 секунд или молча возвращать 200 без обработки.

Как работает.

  • Подписка: получатель регистрирует URL и список интересующих типов событий; подтверждение владения URL — через challenge-запрос (проверочный запрос) при регистрации.
  • Доставка: POST с телом события. Обязательные поля — уникальный идентификатор события, тип, время, версия схемы полезной нагрузки.
  • Подпись. HMAC от тела и метки времени общим секретом, в заголовке. Получатель должен проверять подпись и отвергать старые метки времени — иначе перехваченный запрос можно воспроизвести. Секрет ротируется без простоя за счёт поддержки двух активных ключей.
  • Повторы. Доставка at-least-once с backoff (экспоненциальной задержкой) и jitter (случайным разбросом; REL-01) на протяжении часов. Получатель должен быть идемпотентным по идентификатору события.
  • Порядок не гарантирован. Событие «заказ оплачен» может прийти раньше «заказ создан». Поэтому в теле передают версию или время, а получатель игнорирует устаревшие.
  • Тонкое или толстое событие. Тонкое (только идентификатор — «заказ 42 изменился») безопаснее: получатель сам запрашивает актуальное состояние и не видит устаревших данных. Толстое (с полной нагрузкой) экономит вызовы, но требует версионирования схемы (NET-17).
  • Observability (наблюдаемость) для получателя: журнал доставок с кодами ответов и возможностью переотправки вручную — обязательная часть, иначе поддержка интеграций невозможна.

Когда применять. Интеграция с внешними системами, где вы — источник событий. Когда нет: для обмена внутри своей системы лучше брокер (MSG-02): он даёт порядок, replay и не зависит от доступности HTTP-эндпоинтов.

Альтернативы

Вариант Когда лучше Цена
Webhook Получатель имеет публичный HTTP-эндпоинт Зависимость от чужой доступности
Опрос API получателем Получатель за NAT/файрволом Задержка и лишняя нагрузка
SSE / WebSocket (NET-12) Нужен поток в браузер Долгоживущее соединение
Общая очередь Обе стороны свои Общая инфраструктура

Trade-offs и режимы отказа. Медленный получатель нагружает вашу систему повторами — нужны таймаут (обычно секунды), circuit breaker (предохранитель) на эндпоинт и автоотключение подписки после длительных отказов. Webhook на пользовательский URL — вектор SSRF (подделки запросов со стороны сервера): адреса во внутренней сети должны быть запрещены. Всплеск событий превращается во всплеск исходящих запросов — нужны очередь и rate limiting (ограничение частоты) на получателя.

Что мерить.

  • долю успешных доставок с первой попытки;
  • задержку от события до доставки;
  • число отключённых подписок;
  • распределение кодов ответа получателей.

Связано: MSG-06 гарантии доставки · NET-16 идемпотентность · SEC-06 безопасность API

Источники: Stripe — Webhooks · GitHub — Securing webhooks · Standard Webhooks · ByteByteGo — Top 3 Things You Should Know About Webhooks!


MSG-11 · Data pipeline: батч и поток#

Шаг W3

TL;DR. Батч обрабатывает ограниченный набор данных по расписанию, поток — бесконечный по мере поступления. Разница проявляется в том, как обе схемы обращаются со временем.

Проблема. Данные нужно доставить из операционных систем в аналитические, обогатить, агрегировать и сделать доступными. Наивный подход «раз в сутки выгружаем всё» не даёт свежести; наивный потоковый — не даёт корректности при опоздавших данных и при необходимости пересчитать историю.

Как работает.

  • Батч (Airflow + SQL/Spark): задания по расписанию, идемпотентные и воспроизводимые — повторный запуск за тот же период даёт тот же результат (это свойство важнее скорости). Просто отлаживать, легко пересчитать историю.
  • Поток (Kafka Streams, Flink, Spark Streaming): непрерывная обработка событий, состояние живёт в процессоре, результаты обновляются постоянно.
  • Время события против времени обработки. Событие произошло в 12:00:59, а пришло в 12:01:03. Агрегат «за 12:00» должен опираться на время события, иначе цифры зависят от задержек сети.
  • Окна: фиксированные (tumbling, каждую минуту), скользящие (sliding, последние 5 минут каждую минуту), сессионные (session, по паузам активности).
  • Watermark (водяной знак) — оценка «событий раньше этого времени больше не ждём». Определяет, когда окно можно закрыть; события после watermark считаются опоздавшими и либо отбрасываются, либо обновляют уже посчитанный результат.
  • ETL против ELT. ELT (сначала загрузить сырое, преобразовать в хранилище) сегодня чаще: дешёвое хранение и мощные движки делают преобразование внутри склада проще и воспроизводимее.

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

Альтернативы

Вариант Свежесть Сложность
Батч по расписанию Часы Низкая
Микробатч (минуты) Минуты Средняя
Потоковая обработка Секунды Высокая
Гибрид: поток для оперативного, батч для точного Обе Две реализации одной логики

Trade-offs и режимы отказа. Дублирование логики в потоке и батче (классическая проблема лямбда-архитектуры) приводит к расхождению цифр — поэтому предпочитают одну реализацию с возможностью переиграть историю. Изменение схемы источника ломает конвейер тихо: значения становятся NULL, а дашборд продолжает рисоваться. Пересчёт истории при потоковой обработке требует повторного проигрывания лога — это ещё один аргумент за хранение сырых событий.

Что мерить.

  • свежесть данных на выходе;
  • успешность и длительность заданий;
  • объём опоздавших событий;
  • сверку контрольных сумм с источником;
  • стоимость обработки на единицу данных.

Связано: DATA-15 OLTP/OLAP · MSG-04 Kafka · MSG-07 CDC

Источники: Kleppmann, DDIA, гл. 10–11 · The Dataflow Model (VLDB 2015) · Apache Flink — Event time and watermarks · ByteByteGo — What is Data Pipeline? | Why Is It So Popular?