8.4Паттерны асинхронной интеграции
Разбери двойную запись между БД и брокером и собери outbox/inbox вокруг локальных транзакций. Затем проверь, что сообщение достаточно версионировано, конкурентно опубликовано без потери и обработано при at-least-once доставке.
Двойная запись - корень всех бед
Задача выглядит безобидно: сохранить заказ в PostgreSQL и опубликовать событие
OrderCreated, чтобы отреагировали склад, биллинг и аналитика. Два действия,
две разные системы, и ни одного способа сделать их атомарно. Какой порядок ни
выбери, между ними остаётся окно, в котором процесс может умереть - под убьёт OOM-killer,
нода уедет на обслуживание, сеть моргнёт.
- «Публиковать внутри транзакции». Брокер в транзакции БД не участвует:
defer tx.Rollback()откатит строки, но уже отправленное сообщение не отзовёт. - «Ретраить публикацию с бэкоффом» уменьшает вероятность, но не убирает её. Ретрай живёт в памяти процесса и умирает вместе с ним.
- «Проверять периодически и досылать» по сути тот же outbox, только без таблицы: придётся сканировать сам заказ и где-то хранить флаг «опубликован». Этот флаг и есть outbox, просто размазанный по бизнес-таблице.
- 2PC / XA в теории подходит, а на практике Kafka не умеет XA, координатор становится единой точкой отказа, зависшие prepared-транзакции держат блокировки и не дают VACUUM убирать старые версии строк. В микросервисах его не применяют.
Transactional outbox
Outbox буквально значит «ящик исходящих», как в почтовом клиенте: письмо ты уже написал и нажал «отправить», но оно ещё не ушло. Зато оно точно существует и точно уйдёт - просто позже. Relay («релей», передатчик) - фоновый процесс, который разгребает этот ящик: берёт неотправленное и толкает в брокер. Вся конструкция называется transactional outbox - «транзакционный ящик исходящих»: запись в него идёт внутри той же транзакции БД, что и само бизнес-изменение.
Выход в том, чтобы сделать публикацию частью той же транзакции БД. Раз атомарности
между базой и брокером не бывает, событие записывают в ту же базу, в таблицу
outbox, тем же COMMIT. Дальше релей вычитывает эту таблицу и
публикует в брокер. Атомарность обеспечивает СУБД, доставку берёт на себя релей, и окна
между ними нет: закоммитилось либо всё, либо ничего.
CREATE TABLE outbox (
id bigserial PRIMARY KEY,
aggregate_type text NOT NULL, -- 'order' - отсюда имя топика
aggregate_id text NOT NULL, -- '42' - станет ключом партиционирования
event_type text NOT NULL, -- 'OrderCreated'
payload jsonb NOT NULL, -- тело события
headers jsonb NOT NULL DEFAULT '{}'::jsonb, -- traceparent, schema_version
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz -- NULL = ещё не отправлено
);
-- частичный индекс маленький: в нём только неотправленные строки
CREATE INDEX outbox_unpublished_idx ON outbox (id) WHERE published_at IS NULL;
func (s *Service) CreateOrder(ctx context.Context, o Order) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() //nolint:errcheck
if _, err = tx.ExecContext(ctx,
`INSERT INTO orders (id, user_id, total, status) VALUES ($1,$2,$3,'new')`,
o.ID, o.UserID, o.Total); err != nil {
return err
}
payload, _ := json.Marshal(OrderCreated{OrderID: o.ID, UserID: o.UserID, Total: o.Total})
if _, err = tx.ExecContext(ctx,
`INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload, headers)
VALUES ('order', $1, 'OrderCreated', $2, $3)`,
o.ID, payload, traceHeaders(ctx)); err != nil {
return err
}
return tx.Commit() // ← всё становится реальным только здесь
}
После COMMIT событие уже существует и будет опубликовано, даже если
процесс умрёт на следующей инструкции: релей работает отдельно, он найдёт строку и
отправит. До COMMIT события нет вообще, так что фантомам взяться неоткуда.
Двойная запись превратилась в одинарную.
Relay через polling
Самый простой вариант: фоновая горутина или отдельный воркер раз в N миллисекунд забирает
пачку неотправленных строк, публикует их и помечает. Нетривиальная деталь тут одна -
FOR UPDATE SKIP LOCKED: с ней можно держать несколько экземпляров релея, и
они не блокируют друг друга и не берут одни и те же строки.
const batch = `
SELECT id, aggregate_type, aggregate_id, event_type, payload, headers
FROM outbox
WHERE published_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED` // ← конкурентные релеи разбирают разные строки
func (r *Relay) tick(ctx context.Context) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() //nolint:errcheck
rows, err := tx.QueryContext(ctx, batch)
if err != nil { return err }
msgs, ids := scanBatch(rows)
for i, m := range msgs {
// ключ = aggregate_id: события одного заказа лягут в одну партицию;
// порядок между ними держится, пока релей один
if err := r.producer.Publish(ctx, topicFor(m), m.AggregateID, m.Payload, m.Headers); err != nil {
return err // строки останутся неотмеченными, отправим в следующий тик
}
_ = i
}
_, err = tx.ExecContext(ctx,
`UPDATE outbox SET published_at = now() WHERE id = ANY($1)`, pq.Array(ids))
if err != nil { return err }
return tx.Commit()
}
Соблазнительно вместо флага published_at хранить одно число
last_id и делать WHERE id > last_id. Подход дырявый,
и дырка коварная: bigserial выдаёт номера вне транзакции. Транзакция
T1 взяла id = 5 и ещё думает, T2 взяла id = 6 и закоммитилась.
Релей в этот момент видит 6, публикует и ставит last_id = 6. Через
миллисекунду коммитится T1, но строку с id = 5 релей уже никогда не
выберет. Событие пропало молча - ровно та беда, от которой мы уходили. Поэтому
отметку ставят на строке (published_at или DELETE), а не в
курсоре. По той же причине нельзя фильтровать по created_at > last_time.
Что ещё надо предусмотреть в polling-релее:
- Уборка. Таблица растёт со скоростью потока событий. Чистят либо пачками через
DELETE ... WHERE published_at < now() - interval '3 days'(не одним запросом на миллион строк - иначе выйдет долгая транзакция и раздутый WAL), либоDELETEсразу после публикации, либо секционированием по дню: старую секцию отцепляют (DETACH PARTITION) и удаляют черезDROP TABLE. Без уборки деградируют индекс и autovacuum, а за ними и весь сервис, а не один релей. - Интервал опроса - компромисс между задержкой и нагрузкой. При 100 мс это 10
лишних запросов в секунду на пустой таблице (дёшево благодаря частичному индексу), при
5 с событие ждёт 5 с. Разумно взять короткий интервал плюс сигнал
NOTIFY outboxпосле коммита, чтобы релей просыпался сразу. - Порядок. Один релей с
ORDER BY idпубликует по порядку, но у нескольких экземпляров соSKIP LOCKEDпорядка нет даже внутри агрегата: два релея могут взять события 4 и 5 одного заказа в разные пачки, и пятое уйдёт раньше. Ключaggregate_idположит их в одну партицию, но в том порядке, в каком они пришли. Если порядок внутри агрегата важен, берут схему «один релей, лидер через advisory lock» или CDC. - Идемпотентность продюсера. С
enable.idempotenceуйдут дубликаты от ретраев внутри одной сессии продюсера, но не те, что появятся при перезапуске релея.
Relay через CDC (Debezium)
При CDC изменения в базе вычитывают не запросами к таблицам, а из журнала транзакций самой СУБД (WAL в PostgreSQL, binlog в MySQL). База и так пишет в этот журнал каждое изменение, чтобы восстановиться после сбоя. CDC-инструмент подключается к нему как ещё одна реплика и превращает каждую запись журнала в сообщение, не делая ни одного запроса к рабочей базе.
Второй вариант релея не спрашивает базу, а читает её журнал.
Debezium подключается к PostgreSQL через логический слот репликации (к MySQL как реплика
по binlog) и превращает каждое изменение строки в сообщение Kafka. Если направить его на
таблицу outbox, получится релей без единого SELECT в рабочую
базу.
| Polling | CDC / Debezium | |
|---|---|---|
| Нагрузка на БД | Постоянные запросы + UPDATE каждой строки (второй проход по heap, больше WAL) | Чтение WAL, обычной нагрузки почти нет |
| Задержка | Половина интервала опроса в среднем | Миллисекунды |
| Порядок | Внутри одного релея; между несколькими - не гарантирован | Строго порядок коммитов в WAL |
| Инфраструктура | Ничего лишнего: горутина в сервисе | Kafka Connect кластер, конфиги коннекторов, мониторинг слотов |
| Схема | Полный контроль над телом сообщения | Нужен Outbox Event Router SMT, чтобы вытащить payload и поставить key/topic |
| Риск | Забыть уборку → распухшая таблица | Слот не читается → WAL не удаляется → диск БД кончается |
С CDC строку в outbox можно не хранить вовсе: вставить и тут же удалить в той же
транзакции. В таблице всегда пусто (нечего чистить, индекс не растёт), а в WAL остаются
обе записи, INSERT и DELETE - Debezium опубликует событие по INSERT, а DELETE
отфильтрует. REPLICA IDENTITY FULL для этого не нужен: INSERT в WAL и так несёт
строку целиком, а Outbox Event Router удаления пропускает. Красиво, но намертво привязывает
тебя к CDC: включить polling «на всякий случай» уже
не выйдет.
В эксплуатации CDC опаснее всего replication slot. Пока слот существует и его не
читают (Connect упал, топик недоступен, коннектор на паузе), PostgreSQL обязан хранить
весь WAL с последней подтверждённой позиции. Диск заполняется за часы, и падает уже не
пайплайн событий, а сама база. Алерты тут обязательны: на
pg_replication_slots.active и на лаг слота в байтах.
Inbox и дедупликация на приёме
Inbox («ящик входящих») - зеркальная пара к outbox. В outbox лежит то, что мы обязаны отправить, а в inbox - то, что мы уже приняли и обработали. Магии в нём нет: обычная таблица с ключами сообщений, по которой консьюмер проверяет, не видел ли он это сообщение раньше.
У консьюмера та же история, только зеркальная: надо сделать работу и запомнить, что сообщение обработано, - снова две операции. Если запоминать отдельным запросом после коммита работы, между ними опять появляется окно: упали, потеряли отметку, обработали второй раз. Решение симметрично outbox: отметку кладут в ту же транзакцию, что и бизнес-работу.
CREATE TABLE processed_messages (
message_id text NOT NULL,
consumer text NOT NULL, -- имя consumer group: каждый обрабатывает своё
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, message_id) -- уникальность и есть весь механизм
);
-- Секционировать по processed_at нельзя: ключ обязан включать processed_at,
-- и дедупликация сломается. Старое удаляют пачками по индексу.
CREATE INDEX ON processed_messages (processed_at);
func (c *Consumer) Handle(ctx context.Context, msg Message) error {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback() //nolint:errcheck
// 1) сначала отметка: INSERT блокирует ключ и сериализует
// конкурентные доставки одного сообщения
res, err := tx.ExecContext(ctx,
`INSERT INTO processed_messages (consumer, message_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`, c.name, msg.ID)
if err != nil { return err }
if n, _ := res.RowsAffected(); n == 0 {
return tx.Commit() // дубликат: уже обрабатывали, просто подтверждаем брокеру
}
// 2) бизнес-работа в той же транзакции
if err := c.apply(ctx, tx, msg); err != nil {
return err // rollback снимет и отметку: сообщение вернётся, обработаем заново
}
return tx.Commit() // 3) отметка и результат становятся видимыми вместе
}
Детали, на которых валятся
- Ключ дедупликации. Лучше всего подходит
message_id, который продюсер сгенерировал один раз и переиспользует при всех ретраях (в outbox это естественно: id строки). Годится и бизнес-ключ видаorder_id + event_type + version. Хеш тела не годится: ретрай с обновлённым timestamp даст другой хеш и проедет как новое сообщение, а два легитимных одинаковых события (два одинаковых списания по 100 ₽), наоборот, схлопнутся в одно. - Область уникальности задаёт пара «консьюмер + message_id», а не один message_id. Иначе первая же группа «съест» сообщение у всех остальных.
- Порядок внутри транзакции: сначала INSERT отметки, потом работа. Так конкурентные доставки одного сообщения (две реплики консьюмера после ребалансировки) упрутся в блокировку по первичному ключу и сериализуются, а не выполнят работу дважды.
- Ack брокеру уходит строго после COMMIT. Ack до коммита возвращает нас к at-most-once со всеми его потерями.
- Ретеншен. Окно дедупликации должно быть шире самого долгого возможного
повтора: retention топика, длительности retry-лестницы, планового replay. Чистят
пачками
DELETEпо индексуprocessed_at, а не одним запросом на миллион строк. Если чистить слишком агрессивно, повторное проигрывание старого топика создаст полный комплект дубликатов. - Побочные эффекты вне БД. Отправленное письмо транзакция не откатит, вызов
внешнего API не отменит. Помогает идемпотентный внешний вызов с
Idempotency-Keyили запись «надо отправить письмо» в свой outbox - тогда эффект уйдёт наружу из релея, а не из середины транзакции.
Если операция естественно идемпотентна, дедупликация не нужна вовсе:
INSERT ... ON CONFLICT DO NOTHING, UPDATE ... SET status='paid',
UPSERT проекции по ключу. Опасны только накопительные операции
(balance = balance + 100, counter++, отправка письма) - именно
ради них и заводят processed_messages. Есть и лёгкий вариант -
SETNX в Redis с TTL: быстро и без нагрузки на БД, но не атомарно с
бизнес-работой (упали между SETNX и работой - сообщение считается обработанным, хотя
его не обработали). Это компромисс для потоков, где дубликат неприятен, а потеря
терпима.
Competing consumers
Несколько одинаковых консьюмеров разбирают одну очередь. Так сервис масштабируют добавлением подов: брокер сам следит, чтобы каждое сообщение досталось ровно одному обработчику, и приложению не нужны ни шардирование, ни координация.
| Система | Как выглядит | Потолок |
|---|---|---|
| RabbitMQ | Несколько basic.consume на одну очередь, раздача по кругу с учётом prefetch | Практически нет; упирается в производительность самой очереди (один Erlang-процесс) |
| Kafka, consumer group | Один group.id, партиции распределяются между участниками | Число партиций: лишние консьюмеры простаивают |
| Kafka, share group (4.2+) | Участники читают одни и те же партиции, записи раздаются и подтверждаются поштучно | Нет, но порядок внутри партиции не гарантирован |
| NATS | Queue group: подписчики с одинаковым именем группы | Нет |
| SQS | Несколько получателей, visibility timeout прячет сообщение на время обработки | Нет |
Что ломается при переходе от одного консьюмера к нескольким:
- Порядок. Это главное. Два консьюмера работают как два независимых потока:
сообщение №2 может завершиться раньше №1, и «заказ оплачен» обработается раньше «заказ
создан». Если порядок важен, его сохраняют шардированием по ключу: в Kafka
партиционируют по
order_id, в RabbitMQ берут плагинconsistent-hash exchangeили явный набор очередей на шард. Порядок тогда гарантирован внутри ключа, а бизнесу большего и не надо: события разных заказов независимы. - Гонки на общих данных. Параллельные обработчики одного агрегата дерутся за
строку в БД. Спасает шардирование (см. выше), оптимистическая блокировка по версии или
SELECT ... FOR UPDATE, но последнее берут осознанно: оно сериализует обработку и ставит потолок пропускной способности. - Poison message. Одно вечно падающее сообщение при
requeue=trueсъедает всех консьюмеров по очереди. Ограничение попыток и DLQ обязательны. - Долгие задачи. Пока обработчик занят, брокер ждёт признаков жизни: в Kafka это
max.poll.interval.ms(превысишь - консьюмера исключат из группы и запустят ребалансировку), в JetStreamAckWait, в SQS продление visibility timeout. Долгие задачи лучше дробить или уносить в отдельный поток с большими таймаутами, чтобы не тащить эти настройки на весь сервис.
Fanout / multicast и job distribution
Эти два режима постоянно путают, хотя разница ровно в одном: сколько раз сообщение будет обработано. При fanout каждый подписчик получает свою копию (широковещание). При job distribution оно достаётся ровно одному из группы (конкуренция). Оба режима даёт одна и та же инфраструктура, и ошибка в конфигурации молча превращает один в другой.
| Система | Fanout (каждому копия) | Job distribution (одному из группы) |
|---|---|---|
| RabbitMQ | fanout/topic exchange + своя очередь на каждого подписчика | Одна очередь + несколько консьюмеров |
| Kafka | Разные group.id у каждого сервиса | Один group.id на все инстансы сервиса |
| NATS | Обычные подписчики на subject | Queue group с общим именем |
| JetStream | Несколько durable-консьюмеров стрима | Один durable-консьюмер, много клиентов |
- «Fanout», у которого одна очередь на всех. Три сервиса подписались на fanout exchange через общую очередь - и каждое событие достаётся одному случайному из них. Снаружи это выглядит как «иногда письмо не приходит» и отлаживается неделями. Правило: сколько подписчиков, столько и очередей.
- Общий
group.idу разных сервисов в Kafka. Ошибка зеркальная: два разных сервиса с одинаковымgroup.idделят партиции, и каждый видит половину потока. Правило:group.id= имя сервиса, никогда не «app» и не имя топика.
Отдельный вопрос - кто плодит копии. В Kafka копия одна: N групп читают один лог, данные хранятся однократно. RabbitMQ при fanout физически размножает сообщение по очередям: у 10 подписчиков будет 10 копий в памяти и на диске. С крупными сообщениями это быстро становится заметно, и лечится claim check: в сообщение кладут ссылку на объект в S3, а не сам объект.
Что класть в событие: notification, state transfer, event sourcing
Наполнить сообщение можно тремя способами, и каждый по-своему распределяет связанность между сервисами. Этот выбор важнее выбора брокера: от него зависит, сможет ли потребитель работать, когда источник лежит.
| Event notification | Event-carried state transfer | Event sourcing | |
|---|---|---|---|
| Что в сообщении | Идентификатор и тип факта | Полное состояние сущности на момент события | Все изменения; события - сами по себе источник истины |
| Состояние живёт | Только в сервисе-источнике | Копия у каждого потребителя | Восстанавливается свёрткой лога, плюс снапшоты |
| Размер | Сотни байт | Килобайты и десятки килобайт | Мелкие события, но их много и хранятся долго |
| Связанность | Обратная рантайм-зависимость: нужен живой источник | Зависимость от схемы: поменял поле - сломал всех | Схема событий вечна: старые записи придётся читать всегда |
| Источник недоступен | Обработка встаёт | Потребитель продолжает работать | Не применимо: лог и есть источник |
| Согласованность | Читаем свежее, но можно поймать гонку «событие быстрее реплики» | Данные слегка устаревшие, зато согласованные с событием | Полная история, любые temporal-запросы |
| Плюс | Тонкий контракт, нет дублирования | Автономия, устойчивость, легко строить проекции | Аудит, replay, пересборка проекций, отладка «как дошли до такого» |
| Минус | N+1 вызовов, доступность перемножается | Трафик, копии данных, PII расползается | Высокая сложность, версионирование навсегда, тяжело удалять данные |
| Когда берут | События редкие, данные крупные, потребителей мало | Дефолт для интеграции доменов; кэши и локальные реплики | Домены, где ценна история: деньги, склад, юридически значимые действия |
У event notification есть неочевидный отказ: сервис orders закоммитил
заказ, опубликовал событие - и shipping тут же пошёл за деталями через
GET /orders/42, а балансировщик отправил запрос на реплику для
чтения, куда изменения ещё не доехали. В ответ приходит 404 на объект, о создании
которого мы только что получили событие. Лечат это чтением с мастера для этого сценария,
ретраем с бэкоффом, номером версии в событии с опросом «пока version < 7 - жди» или
переходом на state transfer, где данные уже лежат в сообщении.
Стоит предложить компромисс: тонкое событие плюс самые нужные поля и номер версии. Большинству потребителей хватает того, что пришло, а тем, кому нужны детали, остаётся один вызов вместо всех. Для больших полезных нагрузок есть claim check: в событии ссылка на S3, а тело лежит там.
Публикация событий в брокер сама по себе не делает систему event-sourced. При event sourcing источником истины служит лог событий, а текущее состояние остаётся производной величиной, которую в любой момент можно выбросить и пересобрать. За это платят: схемы событий живут вечно (десятилетний лог придётся читать сегодняшним кодом, отсюда upcasting), нужны снапшоты, «просто поправить строку в базе» нельзя, а персональные данные по GDPR удаляют через crypto shredding. Здоровая практика: применять подход к одному-двум агрегатам, где история действительно ценна, а не ко всей системе.
Схемы сообщений и их эволюция
Сообщение - такой же публичный API, только без компилятора и без единого места, где его можно посмотреть. HTTP-контракт хотя бы ломается сразу и в тестах, а событие ломается через полгода у команды, о существовании которой ты не знал, - и не падением, а тихим неправильным поведением. Отсюда две практики: описывать схему явно и проверять совместимость машиной.
Protobuf
В protobuf контракт держится на номерах полей, а не на именах: имена в wire-формате не передаются вовсе. Из этого и следуют все правила.
message OrderCreated {
string order_id = 1;
string user_id = 2;
int64 total = 3; // в копейках: менять тип потом будет нельзя
reserved 4, 7 to 9; // номера удалённых полей больше не занимать
reserved "discount_code"; // и имя тоже, чтобы не вернулось с другим смыслом
repeated Item items = 5;
optional string coupon = 6; // explicit presence: отличаем "нет значения" от ""
}
| Изменение | Безопасно? | Что произойдёт |
|---|---|---|
| Добавить поле с новым номером | Да | Старый консьюмер положит его в unknown fields и проигнорирует |
| Переименовать поле (номер тот же) | Да для бинарного формата | Но ломает JSON-маппинг и весь код, который читает поле по имени |
| Удалить поле | Да, с reserved | Старый консьюмер увидит дефолт вместо значения - семантику проверить обязательно |
| Изменить тип поля | Нет | int32 → string меняет wire type: старый читатель отложит поле в unknown fields и увидит ноль, без ошибки |
| Изменить номер поля | Нет | Для старого читателя поле исчезло, а новое пришло под чужим номером |
| Переиспользовать номер удалённого поля | Категорически нет | Самая опасная поломка: старые данные декодируются успешно, но означают другое |
| Добавить значение в enum | Условно | Формат выдержит, а switch без default у консьюмера - нет |
| Сделать поле обязательным по смыслу | Нет | Старые продюсеры его не заполняют; валидация уронит консьюмера |
Поле 4 было string discount_code, его удалили. Через год
другой разработчик добавляет int32 quantity = 4: номер же свободен. Теперь
старое сообщение с discount_code = "SALE" прилетает новому консьюмеру, тот
ждёт varint, получает length-delimited и молча откладывает поле в unknown fields:
quantity = 0, ни одной ошибки. А при совпавшем wire type он прочитает чужое
число и пойдёт с ним дальше. С reserved это
становится ошибкой компиляции .proto и всплывает в единственный момент,
когда её ещё дёшево исправить. reserved "имя" делает то же для
JSON-представления и истории.
Avro и Schema Registry
Avro устроен иначе: схема не зашита в код, а сопровождает данные. Читатель делает
schema resolution: сопоставляет writer schema и reader schema по именам
полей и подставляет default для тех, которых нет. Отсюда правило: у любого
добавляемого поля должен быть default, иначе старые данные перестанут читаться.
Schema Registry - отдельный маленький сервис рядом с брокером: он хранит все версии схем сообщений и раздаёт им номера. Нужен он потому, что сама Kafka о содержимом сообщений ничего не знает и проверить его не может: для неё это просто байты. В реестре договорённость о формате становится машинно проверяемой.
Confluent Schema Registry раскладывает версии по subject (обычно
<topic>-value) и выдаёт им числовые id. В сообщение пишется префикс:
magic byte 0 + 4 байта schema id, дальше тело. Консьюмер вытаскивает id, тянет
схему из реестра (и кэширует) и декодирует. Приятный побочный эффект, который стоит
упомянуть: схема не передаётся с каждым сообщением - только 5 байт.
Но главное в реестре не хранение, а проверка совместимости при регистрации: несовместимую схему он просто не примет, и продюсер упадёт на старте, а не на проде через месяц.
| Режим | Что гарантирует | Что разрешено менять | Порядок деплоя |
|---|---|---|---|
| BACKWARD (дефолт) | Новый консьюмер прочитает данные, записанные старой схемой | Удалять поля; добавлять optional с default | Сначала консьюмеры, потом продюсеры |
| FORWARD | Старый консьюмер прочитает данные новой схемы | Добавлять поля; удалять optional с default | Сначала продюсеры, потом консьюмеры |
| FULL | Оба направления сразу | Только добавление/удаление optional с default | Любой |
| *_TRANSITIVE | То же, но против всех прошлых версий, а не только последней | Строже: цепочка мелких «совместимых» шагов не сможет тихо увести схему далеко от исходной | Любой |
| NONE | Ничего | Всё | Молитва |
В Kafka по умолчанию стоит BACKWARD, потому что в топике лежат старые записи (retention неделя, а то и вечность при compaction), и новый консьюмер обязан их прочитать. FORWARD берут, когда консьюмеров обновить нельзя: внешние клиенты, мобильные приложения, партнёрские интеграции. Продюсер уходит вперёд, а старый код должен продолжать работать. FULL нужен, когда порядок выкатки не контролируется вовсе, а в микросервисах это скорее норма, чем исключение.
Реестр проверяет форму, а не смысл. Добавили в enum status значение
PARTIALLY_REFUNDED - схема совместима, а консьюмер со switch
без default либо паникует, либо тихо считает заказ неоплаченным. Поменяли
total с рублей на копейки, не тронув тип, и схема по-прежнему совместима, а
счета выросли в сто раз. Закрывают это такие правила: tolerant reader; смысл существующего поля не
менять никогда, только добавлять новое; класть schema_version в заголовки
сообщения; держать контракты в общем репозитории с проверкой совместимости в CI, а не в
реестре постфактум.
Backpressure: консьюмер не успевает
Сначала диагноз, потом лечение. Растущий лаг сам по себе ничего не значит: при пике он растёт и возвращается, при проблеме растёт монотонно. Различают их по одной величине: скорость разгребания = consume_rate − produce_rate. Если она устойчиво отрицательная, никакой буфер не поможет - вопрос лишь в том, когда он кончится. На собесе полезно произнести такую оценку: время до катастрофы = свободный объём буфера / (produce − consume). Она сразу переводит разговор из «надо что-то делать» в «у нас четыре часа».
TimeoutException у продюсера или молчаливую потерю хвоста топика.Инструменты по порядку применения
- Горизонтальное масштабирование консьюмеров. Его пробуют первым и в него же первым упираются: в Kafka потолок consumer group задаёт число партиций, лишние поды в группе просто простаивают. В RabbitMQ потолка почти нет, но растёт нагрузка на сам процесс очереди. Прежде чем масштабировать, стоит проверить, что консьюмеры заняты работой, а не ждут БД: если ждут, новые поды только добьют базу.
- Ускорить обработку. Обычно тут и лежит основной резерв: батчить записи в БД
вместо построчных, убрать N+1, вынести побочные вызовы, распараллелить внутри одного
консьюмера воркер-пулом по
hash(key) % N, коммитя только сплошной префикс обработанных офсетов. Так параллелизм растёт без потери порядка по ключу. - Увеличить число партиций, понимая цену. (а) Меняется
hash(key) % N, ключ уезжает в другую партицию, и порядок по ключу ломается на границе изменения: старые события заказа лежат в старой партиции, новые в новой, и два консьюмера обрабатывают их одновременно. (б) Уменьшить число партиций нельзя вообще. (в) Растут файловые дескрипторы, память на брокерах, время ребалансировки и выборов лидеров. (г) Уже лежащие сообщения не перераспределяются, так что сразу легче не станет. - Throttling продюсера, то есть ограничение на источнике: quotas в Kafka
(
producer_byte_rateна client.id/user: брокер задерживает ответы, и продюсер тормозит сам), rate limiter в сервисе,429на HTTP-границе. Обратное давление должно доходить до того, кто порождает трафик, иначе оно копится где-то посередине и рвётся там. - Отбрасывание и приоритеты (load shedding). Заранее решить, что можно потерять.
Аналитику, телеметрию, «пользователь открыл экран» можно, платежи нельзя.
Технически это разные топики или очереди под разные классы с отдельными пулами
консьюмеров (bulkhead); в RabbitMQ
x-max-lengthсoverflow=drop-head(вытесняем самое старое) илиreject-publish(отказываем на входе, что честнее); TTL для данных, которые через минуту бессмысленны, - котировки, статусы онлайна. Осознанное отбрасывание всегда лучше неосознанного: второе всё равно случится, только жертву выберет само. - Буферизация и её предел. Брокер и есть буфер, и копить правильно именно в нём:
диск дешевле памяти консьюмера. Но он конечен: его ограничивают
retention.ms/retention.bytes,x-max-lengthи размер диска. Буфер даёт время дожить до пятого пункта, но не заменяет его.
По ступеням, от дешёвого к дорогому, и обязательно с ценой каждого шага. «Сначала смотрю, пик это или тренд, и считаю скорость разгребания. Потом проверяю, упёрлись ли консьюмеры в CPU или ждут БД: если ждут, новые поды сделают хуже. Дальше по порядку: масштабирование до числа партиций, оптимизация и батчинг, увеличение партиций (с пониманием, что это ломает порядок по ключу и необратимо), троттлинг продюсера и в крайнем случае осознанное отбрасывание того, что не жалко. Буфер брокера при этом не решение, а время на то, чтобы решение применить». Такой ответ показывает, что кандидат думает о системе, а не об одной ручке.
Вопросы
6COMMIT,
а отдельный релей выносит его в брокер. Гарантия - at-least-once, дубликаты остаются.Почему без него ломается
Порядков два, и обе поломки надо назвать. COMMIT, потом publish: если упасть между ними, заказ в базе есть, а события нет и не будет - склад и биллинг о нём не узнают. Publish, потом COMMIT: событие ушло, а транзакция после падения откатилась, консьюмеры реагируют на заказ-призрак и получают 404 из API. Ретраи в памяти не спасают, они умирают вместе с процессом. 2PC/XA формально решает, но Kafka его не поддерживает, координатор блокирующий, а его падение оставляет in-doubt транзакции, держащие блокировки в базе.
Реализация
Таблица outbox(id, aggregate_type, aggregate_id, event_type, payload, headers,
created_at, published_at) в той же базе, что и бизнес-данные, плюс
частичный индекс WHERE published_at IS NULL - он маленький, потому что
покрывает только хвост. В коде один tx.Commit() на оба запроса,
INSERT orders и INSERT outbox. Всё, атомарность обеспечила
СУБД.
Relay: два варианта
- Polling.
SELECT ... WHERE published_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED→ publish →UPDATE published_at. БлагодаряSKIP LOCKEDнесколько релеев работают, не блокируя друг друга, но порядок событий одного агрегата тогда не гарантирован. Плюсы: ничего лишнего в инфраструктуре. Минусы: задержка ≈ полинтервала, постоянные запросы и лишний WAL отUPDATE, таблицу надо убирать. - CDC (Debezium). Читает WAL через логический слот и публикует изменения
outbox-таблицы; Outbox Event Router SMT достаёт
payloadи ставит топик и ключ поaggregate_id. Плюсы: задержка в миллисекундах, нулевая нагрузка на базу от опроса, порядок = порядок коммитов. Минусы: Kafka Connect как отдельная система и главный риск - нечитаемый replication slot не даёт PostgreSQL удалять WAL, и диск базы заканчивается.
Потому что bigserial выдаёт номера вне транзакции. T1 взяла
id=5 и ещё не закоммитилась, T2 взяла id=6 и закоммитилась;
релей видит 6, сдвигает курсор - и строку 5, закоммиченную мгновением позже, не
выберет никогда. Отметка должна жить на строке
(published_at или DELETE), а не в курсоре. Тот же
аргумент хоронит фильтр по created_at.
Гарантии и цена
Outbox даёт at-least-once, а не exactly-once. Дубликаты неизбежны: если релей
опубликовал и упал до UPDATE published_at, он опубликует снова; плюс
ретраи продюсера, плюс перезапуск с середины пачки. Поэтому outbox почти всегда идёт
в паре с inbox или идемпотентным обработчиком на приёме. Порядок гарантирован
внутри агрегата, если ключом партиционирования взять aggregate_id.
Ещё платишь ростом таблицы (нужна чистка партициями или пачками), лишней записью в каждой
бизнес-транзакции и ещё одним процессом, за которым надо следить.
Главный алерт всей конструкции - метрика «возраст самой старой неопубликованной
строки».
processed_messages с уникальным ключом, INSERT первым,
бизнес-работа следом, ack брокеру строго после COMMIT.Механика
Нужна таблица processed_messages(consumer, message_id, processed_at) с
первичным ключом по паре «консьюмер + идентификатор сообщения». Обработчик открывает
транзакцию и делает INSERT ... ON CONFLICT DO NOTHING. Ноль затронутых
строк означает дубликат: работу пропускаем и просто подтверждаем брокеру. Если строка
вставилась, выполняем бизнес-работу в той же транзакции и коммитим. Ошибка в
работе откатывает и отметку, поэтому сообщение честно вернётся и обработается снова.
Порядок внутри транзакции важен: сначала отметка. INSERT берёт блокировку по первичному ключу, и две конкурентные доставки одного сообщения (две реплики консьюмера после ребалансировки) сериализуются, а не делают работу дважды.
Выбор ключа
- Хорошо:
message_id, сгенерированный продюсером один раз и неизменный при всех ретраях (в outbox это id строки), либо бизнес-ключorder_id + event_type + version. - Плохо: хеш тела. Ретрай с обновлённым timestamp даст другой хеш и проедет как новое, а два легитимных одинаковых события (два списания по 100 ₽), наоборот, схлопнутся в одно. Вместо дедупликации получится потеря данных.
- Плохо: глобальная уникальность без имени консьюмера - первая группа «съест» сообщение у всех остальных.
Ретеншен
Окно дедупликации должно быть шире максимально возможного повтора: retention топика,
длительность retry-лестницы, планируемые replay. Чистят пачками DELETE по
индексу processed_at, а не одним запросом на миллион строк. Перестараешься
с чисткой, и повторное проигрывание старого топика даст полный набор дубликатов.
- Не всегда нужна таблица. Если операция естественно идемпотентна
(
UPSERTпо ключу,SET status='paid'), дедупликация не нужна вовсе. Проблемы бывают только у накопительных операций (balance = balance + 100) и внешних эффектов. - Побочные эффекты вне БД транзакция не откатит. Письмо отправлено, платёж
в шлюзе проведён. Помогает идемпотентный внешний вызов с
Idempotency-Keyили запись «надо отправить» в свой outbox, чтобы эффект уходил наружу уже из релея. RedisSETNXс TTL работает быстро, но не атомарно с бизнес-работой: упали между SETNX и работой - сообщение считается обработанным, хотя ничего не произошло.
Competing consumers
Несколько одинаковых обработчиков читают одну очередь, а брокер следит, чтобы
сообщение досталось одному. В RabbitMQ это несколько basic.consume на
очередь с раздачей по кругу и оглядкой на prefetch, потолка практически нет. В Kafka
нужен общий group.id, и потолок жёсткий: число партиций, лишние
консьюмеры простаивают (у share group из Kafka 4.2 потолка нет, но нет и порядка). В NATS это queue group, в SQS visibility timeout.
Что ломается при переходе от одного консьюмера к N: порядок (в двух потоках
«оплачен» может обработаться раньше «создан»; лечится шардированием по ключу: партиции Kafka
или consistent-hash exchange в RabbitMQ), гонки на общих данных (оптимистическая
блокировка по версии или тот же шардинг), poison message (нужны лимит попыток
и DLQ, иначе одно сообщение обойдёт всех консьюмеров по очереди), долгие задачи
(max.poll.interval.ms в Kafka, AckWait в JetStream, продление
visibility в SQS).
Fanout vs job distribution
| Система | Fanout: копия каждому | Job distribution: одному |
|---|---|---|
| RabbitMQ | fanout/topic exchange + своя очередь на подписчика | Одна очередь, несколько консьюмеров |
| Kafka | Разные group.id | Один group.id |
| NATS | Обычные подписчики на subject | Queue group с общим именем |
- Три сервиса подписаны на fanout через одну общую очередь. Каждое событие достаётся одному случайному из них. Выглядит это как «иногда письмо не приходит» и отлаживается неделями. Очередей нужно столько же, сколько подписчиков.
- Одинаковый
group.idу разных сервисов в Kafka. Зеркальная беда: сервисы делят партиции и видят по половине потока. Вgroup.idпишут имя сервиса, а не имя топика и не «app».
И про стоимость. В Kafka веерная рассылка бесплатна по хранению: N групп читают один лог, копия одна. RabbitMQ при fanout физически размножает сообщение: десять подписчиков дают десять копий в памяти и на диске. Если сообщения крупные, помогает claim check: в событие кладут ссылку на объект в S3, а не сам объект.
Event notification
В сообщении только идентификатор и тип факта:
{OrderCreated, order_id: 42}. Плюсы: контракт тонкий, данные не
дублируются, потребитель всегда читает свежее состояние. Минусы серьёзнее, чем
кажется. Возвращается рантайм-зависимость: источник лёг, и обработка встала,
доступность снова перемножается. На поток событий появляется N+1 синхронных вызовов. И
есть неприятная гонка - событие обгоняет репликацию, консьюмер идёт в read-реплику за
заказом и получает 404 на объект, о создании которого только что узнал.
Event-carried state transfer
Сообщение несёт полное состояние сущности на момент события. Потребитель становится автономным: держит локальную реплику, переживает недоступность источника, не делает обратных вызовов. Минусы: килобайты вместо сотен байт, копия данных у каждого потребителя, связанность по схеме (изменил поле - сломал всех подписчиков) и персональные данные, расползающиеся по системе: из-за них удаление по GDPR превращается в проект (в Kafka через log compaction и tombstone-записи).
Event sourcing
Уже не про форму сообщения, а про хранение: истину хранит лог событий, а текущее состояние - лишь свёртка, которую можно выбросить и пересобрать. Отсюда полный аудит, temporal-запросы, пересборка проекций и честная отладка «как мы дошли до такого». Цена: схемы событий живут вечно (десятилетний лог придётся читать сегодняшним кодом, отсюда upcasting), нужны снапшоты, нельзя «просто поправить строку», а персональные данные удаляют через crypto shredding: шифруют ключом на субъекта и уничтожают ключ. Здоровая практика - применять его к одному-двум агрегатам, где история ценна, а не ко всей системе. Публикация событий в Kafka сама по себе ещё не event sourcing.
Для интеграции между доменами по умолчанию берут state transfer: автономия
потребителя дороже трафика. Notification выбирают, когда данные крупные или
чувствительные (не хочется рассылать PII всем подряд) и потребителей мало. Чаще
всего живут посередине: id, номер версии и три-четыре самых нужных поля.
Большинству этого хватает, остальные делают один вызов вместо всех. Для тяжёлых
полезных нагрузок есть claim check: в событии ссылка, тело в объектном хранилище. А
version в событии дёшево страхует от гонок и от применения событий не
по порядку.
reserved), Avro - на именах
с обязательными default. Schema Registry не столько хранит схемы, сколько
не даёт зарегистрировать несовместимую. BACKWARD - обновляем консьюмеров первыми,
FORWARD - продюсеров, FULL - порядок не важен.Protobuf
Имена полей в wire-формате не передаются, передаются номера. Отсюда и все правила.
Поле с новым номером добавлять безопасно (старый консьюмер положит его в unknown
fields). Переименование безопасно для бинарного формата, но ломает JSON-маппинг.
Удалять поле можно только вместе с reserved. Смена типа или номера
ломает контракт, как и поле, ставшее обязательным по смыслу: старые продюсеры его не
заполняют.
Поле 4 было string discount_code, и его удалили. Через год
кто-то добавляет int32 quantity = 4: номер же свободен. Старое
сообщение прилетает новому консьюмеру, тот ждёт varint, получает length-delimited и
молча откладывает поле в unknown fields: quantity = 0, ошибки нет. А при
совпавшем wire type он прочитает чужое число и пойдёт с ним дальше. Это худший класс поломок: данные декодируются успешно, но означают другое.
С reserved 4; и reserved "discount_code"; это становится
ошибкой компиляции .proto и всплывает в единственный момент, когда
чинить ещё дёшево.
Avro и Schema Registry
В Avro схема сопровождает данные, и читатель делает schema resolution:
сопоставляет writer schema с reader schema по именам полей и подставляет
default для отсутствующих, поэтому у любого добавляемого поля default
обязателен. В Confluent-формате идут magic byte 0 + 4 байта schema id, дальше
тело. Консьюмер тянет схему по id из реестра и кэширует, так что по проводу схема не
ходит.
BACKWARD vs FORWARD
| Режим | Гарантия | Порядок деплоя | Когда нужен |
|---|---|---|---|
| BACKWARD (дефолт) | Новый консьюмер читает старые данные | Сначала консьюмеры | Kafka: в топике лежит история, её надо уметь прочитать |
| FORWARD | Старый консьюмер читает новые данные | Сначала продюсеры | Консьюмеров обновить нельзя: внешние клиенты, мобильные приложения |
| FULL | Оба направления | Любой | Порядок выкатки не контролируется - обычная ситуация в микросервисах |
| *_TRANSITIVE | То же против всех версий, не только последней | Любой | Чтобы цепочка мелких совместимых шагов не увела схему далеко от исходной |
Реестр проверяет только форму. Добавили в enum status значение
PARTIALLY_REFUNDED - схема совместима, а консьюмер со
switch без default либо падает, либо считает заказ
неоплаченным. Перевели total из рублей в копейки, не тронув тип, и
схема всё ещё совместима, а счета выросли в сто раз. Спасают такие практики:
tolerant reader (игнорировать неизвестное, всегда иметь ветку по умолчанию),
смысл существующего поля не менять, а добавлять новое, schema_version в
заголовках сообщения, контракты в общем репозитории и проверка совместимости в
CI, а не в реестре постфактум.
Диагностика
Растущий лаг сам по себе не диагноз. Считаем скорость разгребания = consume_rate − produce_rate: при пике она положительна и лаг вернётся, при проблеме устойчиво отрицательна. Оценка, которую полезно произнести вслух: время до переполнения = свободный объём / (produce − consume) - она сразу переводит панику в «у нас четыре часа». Второй вопрос: консьюмеры действительно заняты работой или ждут БД и внешние вызовы? Если ждут, новые поды только добьют базу.
Ступени
- Горизонтальное масштабирование consumer group в Kafka работает до числа партиций (дальше лишние поды просто простаивают), в RabbitMQ потолка почти нет.
- Основной резерв обычно в том, чтобы ускорить обработку: батчить записи в
БД, убрать N+1, распараллелить внутри консьюмера воркер-пулом по
hash(key) % N, коммитя только сплошной префикс офсетов, чтобы не потерять порядок по ключу. - Увеличить партиции и назвать цену. Меняется
hash(key) % N, ключ уезжает в другую партицию, и порядок по ключу ломается на границе изменения; уменьшить обратно нельзя вообще; растут дескрипторы, память брокеров, время ребалансировок; уже лежащие сообщения не перераспределяются, поэтому мгновенного облегчения не будет. - Троттлинг продюсера делают через quotas в Kafka
(
producer_byte_rate: брокер задерживает ответы, продюсер тормозит сам), rate limiter в сервисе или429на HTTP-границе. Давление должно дойти до источника трафика, иначе оно копится посередине и рвётся там. - Отбрасывание и приоритеты. Заранее решают, что можно потерять (телеметрия,
аналитика), а что нельзя (платежи), и разводят по разным топикам с отдельными
пулами консьюмеров. В RabbitMQ есть
x-max-lengthсoverflow=drop-headили, честнее,reject-publish, а для данных, бессмысленных через минуту, TTL. Осознанное отбрасывание лучше неосознанного: второе всё равно случится, просто жертву выберет само. - Буферизация. Брокер и есть буфер, и копить правильно именно в нём: диск
дешевле памяти консьюмера. Но он конечен (
retention.ms/retention.bytes,x-max-length, размер диска) и даёт только время, чтобы успеть применить предыдущие пункты.
- Буфер продюсера. Kafka:
buffer.memoryполон →send()блокируется наmax.block.ms→ ошибка. Это хороший исход: давление дошло до кода и его видно. - Диск брокера. Kafka молча удаляет хвост по retention, консьюмер получает
OffsetOutOfRangeи пропускает данные, а продюсер не видит ни одной ошибки. - Память консьюмера. Push-модель без prefetch: брокер заливает всё, что есть, RSS растёт, OOM-killer убивает под, unacked возвращаются, и цикл повторяется. Классический краш-луп под нагрузкой.
- Узел RabbitMQ. Memory alarm (0,6 от RAM, в 3.x было 0,4) или
disk_free_limitзаставляют брокер перестать читать TCP от всех продюсеров узла: publish зависает даже у тех, кто не создавал нагрузки.
Что стоит запомнить
- Outbox атомарно фиксирует бизнес-изменение и событие в одной БД-транзакции; relay публикует позже.
- SKIP LOCKED позволяет нескольким relay workers забирать разные строки, но не устраняет повтор после публикации и до отметки published.
- Inbox/processed_messages фиксирует дедупликацию и бизнес-эффект в одной транзакции consumer.
Частые ошибки
- Не помечай outbox опубликованным до broker confirm; иначе сбой теряет событие.
- Если confirm получен, но DB update published_at не зафиксировался, relay опубликует повтор - consumer должен дедуплицировать.
- Не переиспользуй номера и смысл удалённых полей в protobuf/schema evolution.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Посчитай окно повтора relay
Relay публикует сообщение, получает broker confirm и падает до UPDATE outbox SET published_at. Что произойдёт после рестарта и как система должна отреагировать?
if err := broker.Publish(ctx, row.EventID, row.Payload); err != nil { return err }
return markPublished(ctx, row.ID)
Ответ и разбор
Строка останется неопубликованной в БД и будет отправлена снова. Это ожидаемый дубликат at-least-once. Consumer должен использовать стабильный outbox/event ID как message key и фиксировать уникальную processed_messages запись вместе с бизнес-изменением в одной транзакции.
Код-ревью
2. Найди потерю в relay
Relay сначала ставит published_at, затем отправляет сообщение в брокер. При падении между действиями событие исчезнет из выборки, но никогда не попадёт в брокер. Переставь действия и опиши оставшийся риск.
markPublished(ctx, row.ID)
if err := broker.Publish(ctx, row.Payload); err != nil { return err }
Ответ и разбор
Публикуй событие и дожидайся broker confirm, затем ставь published_at. Для стабильного дедупа передай event ID вместе с payload:
if err := broker.Publish(ctx, row.EventID, row.Payload); err != nil { return err }
return markPublished(ctx, row.ID)Падение после confirm, но до отметки приведёт к повторной публикации, поэтому consumer должен быть идемпотентным. Ограничивай выборку через FOR UPDATE SKIP LOCKED и не держи DB lock во время неограниченного сетевого ожидания.
Применение
3. Оцени схему дедупликации
Inbox имеет PRIMARY KEY (consumer, message_id), а команда предлагает секционировать таблицу по processed_at. Почему прямой RANGE partition может сломать гарантию уникальности?
Ответ и разбор
В PostgreSQL уникальный/первичный ключ секционированной таблицы должен включать ключ секционирования. Если добавить processed_at в PK, одна и та же пара (consumer,message_id) сможет существовать в разных временных partitions. Оставь глобальную таблицу с индексом времени и чисткой пачками либо выбери схему, которая сохраняет глобальную уникальность.
Применение
4. Совмести изменение схемы события
Consumer старой версии читает поле total и игнорирует незнакомые поля. Producer хочет добавить coupon, а старое поле discount_code удалить. Как провести совместимое изменение protobuf?
Ответ и разбор
Добавь coupon с новым номером поля, не меняя тип и смысл total. Удалённое поле discount_code зарезервируй по номеру и имени, чтобы их не использовали повторно. Выкатывай consumer, способный читать новую схему, до producer, который начинает отправлять новое поле; старое удаляй только после прекращения зависимости.