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 | лог | в потоке | да, ограниченно | своя, лёгкая |
| Таблица в БД | очередь | по запросу | да (это же строки) | никакой дополнительной |
Как выбирать.
- Нужен ли брокер? Меньше сотен сообщений в секунду и один потребитель → таблица в БД.
- Готовы ли эксплуатировать кластер? Нет → управляемый сервис.
- Нужны ли история и несколько независимых групп потребителей? Да → лог (Kafka, Pulsar, JetStream).
- Нужны ли маршрутизация, приоритеты, отложенная доставка? Да → очередь (RabbitMQ, Service Bus).
- Всё перечисленное сразу и есть команда эксплуатации? → 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 + идемпотентная обработка = наблюдаемый эффект ровно один раз.
Как делать идемпотентного потребителя.
- У сообщения есть стабильный идентификатор — доменный ключ (номер заказа) надёжнее технического, потому что переживает переотправку.
- Обработка и запись отметки «обработано» происходят в одной транзакции с изменением данных. Отметка — строка с уникальным индексом по идентификатору; повторная вставка падает и служит сигналом дубликата.
- Естественная идемпотентность лучше журналов:
UPDATE ... SET status='paid' WHERE id=? AND status='pending'безопасен при повторе сам по себе. - Хранение отметок ограничено по времени — окно дедупликации должно превышать максимальную задержку повторной доставки.
Порядок — отдельная гарантия. 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 (таблица исходящих событий в той же транзакции).
- В той же транзакции, что и изменение данных, вставляется строка в таблицу
outbox: тип события, полезная нагрузка, ключ, время. - Отдельный процесс — relay (ретранслятор) — читает неотправленные строки, публикует их в брокер и помечает отправленными.
- Публикация 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) сообщение блокирует партицию. Если не повторять — теряются данные при временном сбое БД. Различать «попробуй ещё раз» и «это не заработает» приходится явно.
Как работает.
- Классификация ошибки. Временная (недоступна БД, таймаут, 5xx у зависимости, 429) → повтор. Постоянная (не проходит валидацию, нет обязательного поля, ссылка на несуществующую сущность) → сразу в сторону, повтор бесполезен.
- Повторы с задержкой. Внутри процесса — только короткие; длинные повторы держат поток. Для длинных используют retry-топики (топики повторов). Сообщение с ошибкой публикуется в топик с задержкой — 5 с, 1 мин, 10 мин, — и его потребитель читает сообщение позже. В очередях эту роль играют отложенные сообщения или отдельные очереди повторов.
- DLQ (dead letter queue, очередь недоставленных сообщений) — конечная остановка после исчерпания попыток. К сообщению прикладывается контекст: причина, стек, число попыток, исходный топик и offset.
- DLQ — не архив, а очередь задач для людей. На неё нужен алерт, владелец и инструмент повторной отправки после исправления.
- Идемпотентность обязательна (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?