voidlearn
 

8.4Паттерны асинхронной интеграции

Разбери двойную запись между БД и брокером и собери outbox/inbox вокруг локальных транзакций. Затем проверь, что сообщение достаточно версионировано, конкурентно опубликовано без потери и обработано при at-least-once доставке.

Двойная запись - корень всех бед

Задача выглядит безобидно: сохранить заказ в PostgreSQL и опубликовать событие OrderCreated, чтобы отреагировали склад, биллинг и аналитика. Два действия, две разные системы, и ни одного способа сделать их атомарно. Какой порядок ни выбери, между ними остаётся окно, в котором процесс может умереть - под убьёт OOM-killer, нода уедет на обслуживание, сеть моргнёт.

Двойная запись: оба порядка сломаны, ломаются по-разному Красная молния - процесс умирает между двумя операциями. Это не редкость: это нормальный режим работы кластера. Порядок A: сначала COMMIT в БД, потом publish COMMIT заказ 42 сохранён crash publish не выполнился Заказ есть в базе, события нет никогда. Склад не резервирует товар, биллинг не выставляет счёт, аналитика не видит продажу. Тихая рассинхронизация. Порядок B: сначала publish, потом COMMIT publish событие ушло crash COMMIT откат по таймауту Событие есть, заказа нет. Консьюмеры пошли за деталями в API и получили 404 - или, хуже, зарезервировали товар под заказ-призрак. Отменить опубликованное нельзя. Почему «просто поретраить» не спасает Ретрай живёт в памяти того же процесса, который умер. Сдвинуть окно можно, убрать нельзя: какую бы пару операций вы ни взяли, между ними всегда есть момент «первая прошла, вторая нет». 2PC/XA формально решает, практически - нет: Kafka не поддерживает XA вовсе, координатор блокирующий, а его падение оставляет in-doubt транзакции, держащие блокировки в БД.
От порядка зависит лишь то, как испортятся данные, а не то, испортятся ли они. Начнёшь с БД, потеряешь события; начнёшь с брокера, получишь фантомные. Поэтому нужен паттерн, а не аккуратность.
Чем не решается
  • «Публиковать внутри транзакции». Брокер в транзакции БД не участвует: 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()
}
Почему нельзя вести курсор по «последнему обработанному id»

Соблазнительно вместо флага 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 в рабочую базу.

PollingCDC / Debezium
Нагрузка на БДПостоянные запросы + UPDATE каждой строки (второй проход по heap, больше WAL)Чтение WAL, обычной нагрузки почти нет
ЗадержкаПоловина интервала опроса в среднемМиллисекунды
ПорядокВнутри одного релея; между несколькими - не гарантированСтрого порядок коммитов в WAL
ИнфраструктураНичего лишнего: горутина в сервисеKafka Connect кластер, конфиги коннекторов, мониторинг слотов
СхемаПолный контроль над телом сообщенияНужен Outbox Event Router SMT, чтобы вытащить payload и поставить key/topic
РискЗабыть уборку → распухшая таблицаСлот не читается → WAL не удаляется → диск БД кончается
Трюк «INSERT + DELETE в одной транзакции»

С 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 и на лаг слота в байтах.

Transactional outbox: где событие рождается, кто его выносит и что бывает при падении сервис заказов BEGIN INSERT orders INSERT outbox COMMIT PostgreSQL - одна транзакция orders 42 · new · 1200 ₽ outbox id 7 · OrderCreated · 42 published_at = NULL атомарно с orders relay: polling SELECT ... published_at IS NULL FOR UPDATE SKIP LOCKED relay: CDC Debezium читает WAL через replication slot Kafka key = aggregate_id порядок внутри заказа Точки отказа - и что происходит в каждой ① Падение до COMMIT Откатывается всё: ни заказа, ни события. Клиент получил ошибку и повторит - это корректное состояние, а не порча данных. ② Падение после COMMIT, до publish Строка лежит с published_at = NULL. Релей (любой из экземпляров) подхватит её в следующем тике. Задержка, но не потеря. ③ Publish прошёл, UPDATE не успел Строка снова видна как неотправленная - и будет опубликована второй раз. Отсюда берутся дубликаты. Всегда. Итоговая гарантия outbox - at-least-once, и никакая аккуратность её не улучшит: между «брокер записал» и «мы это отметили» окно есть всегда, потому что это снова две системы. Дубликаты добавляют и ретраи продюсера, и перезапуск релея с середины пачки, и повторный запрос клиента. Поэтому outbox почти никогда не живёт один - на приёме его дополняет inbox или идемпотентный обработчик.
Outbox убирает потерю событий, но не дубликаты. Вместо «двух систем без общего коммита» получается «одна транзакция плюс надёжный вынос наружу» - а вынос наружу по-прежнему может повториться.

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) отметка и результат становятся видимыми вместе
}
Inbox: отметка и работа - одна транзакция, поэтому промежуточного состояния не бывает брокер msg id=A msg id=A повтор консьюмер: BEGIN … COMMIT INSERT processed_messages ON CONFLICT DO NOTHING UPDATE balance бизнес-работа rows = 1 → первое появление: работаем и коммитим rows = 0 → дубликат: работу пропускаем, брокеру шлём ack ошибка в работе → ROLLBACK снимает и отметку: сообщение вернётся COMMIT - отметка и результат становятся видимыми одновременно Так делать нельзя: две транзакции tx1: UPDATE balance … COMMIT ✗ crash здесь tx2: INSERT processed_messages … COMMIT Отметки нет, деньги списаны. Повтор спишет ещё раз. Так правильно: одна транзакция BEGIN INSERT processed_messages → UPDATE balance COMMIT (ack брокеру - только после) Промежуточного состояния не существует в принципе.
Inbox устроен как outbox наоборот. Там мы вносили в транзакцию публикацию, здесь вносим отметку об обработке. Оба паттерна держатся на одном: атомарность есть только у базы, значит всё критичное должно оказаться в ней.

Детали, на которых валятся

  • Ключ дедупликации. Лучше всего подходит 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+)Участники читают одни и те же партиции, записи раздаются и подтверждаются поштучноНет, но порядок внутри партиции не гарантирован
NATSQueue 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 (превысишь - консьюмера исключат из группы и запустят ребалансировку), в JetStream AckWait, в SQS продление visibility timeout. Долгие задачи лучше дробить или уносить в отдельный поток с большими таймаутами, чтобы не тащить эти настройки на весь сервис.

Fanout / multicast и job distribution

Эти два режима постоянно путают, хотя разница ровно в одном: сколько раз сообщение будет обработано. При fanout каждый подписчик получает свою копию (широковещание). При job distribution оно достаётся ровно одному из группы (конкуренция). Оба режима даёт одна и та же инфраструктура, и ошибка в конфигурации молча превращает один в другой.

СистемаFanout (каждому копия)Job distribution (одному из группы)
RabbitMQfanout/topic exchange + своя очередь на каждого подписчикаОдна очередь + несколько консьюмеров
KafkaРазные group.id у каждого сервисаОдин group.id на все инстансы сервиса
NATSОбычные подписчики на subjectQueue 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: «случилось, детали спроси сам» orders источник истины { "type":"OrderCreated", "order_id":"42", "version":7 } shipping получил только id обратный синхронный вызов GET /orders/42 - связанность вернулась источник лежит - потребитель встал. И возможна гонка Event-carried state transfer: «случилось, вот всё состояние» orders источник истины { "type":"OrderCreated", "order_id":"42", "version":7, "items":[…], "total":1200, "address":{…}, "user":{…} } сообщение выросло в 20 раз shipping работает автономно локальная реплика заказа источник может лежать сутки Цена notification: N+1 синхронных вызовов, доступность перемножается, и гонка «событие обогнало реплику БД». Цена state transfer: трафик, копии данных у всех, жёсткая связанность по схеме и разъезжающиеся по системе персональные данные.
Тонкое событие экономит трафик, но возвращает синхронную зависимость; толстое покупает автономию потребителя ценой дублирования данных и связанности по схеме. На практике чаще выбирают середину: id, версия и три-четыре поля, которых хватает большинству.
Event notificationEvent-carried state transferEvent sourcing
Что в сообщенииИдентификатор и тип фактаПолное состояние сущности на момент событияВсе изменения; события - сами по себе источник истины
Состояние живётТолько в сервисе-источникеКопия у каждого потребителяВосстанавливается свёрткой лога, плюс снапшоты
РазмерСотни байтКилобайты и десятки килобайтМелкие события, но их много и хранятся долго
СвязанностьОбратная рантайм-зависимость: нужен живой источникЗависимость от схемы: поменял поле - сломал всехСхема событий вечна: старые записи придётся читать всегда
Источник недоступенОбработка встаётПотребитель продолжает работатьНе применимо: лог и есть источник
СогласованностьЧитаем свежее, но можно поймать гонку «событие быстрее реплики»Данные слегка устаревшие, зато согласованные с событиемПолная история, любые temporal-запросы
ПлюсТонкий контракт, нет дублированияАвтономия, устойчивость, легко строить проекцииАудит, replay, пересборка проекций, отладка «как дошли до такого»
МинусN+1 вызовов, доступность перемножаетсяТрафик, копии данных, PII расползаетсяВысокая сложность, версионирование навсегда, тяжело удалять данные
Когда берутСобытия редкие, данные крупные, потребителей малоДефолт для интеграции доменов; кэши и локальные репликиДомены, где ценна история: деньги, склад, юридически значимые действия
Гонка, которую стоит назвать вслух

У event notification есть неочевидный отказ: сервис orders закоммитил заказ, опубликовал событие - и shipping тут же пошёл за деталями через GET /orders/42, а балансировщик отправил запрос на реплику для чтения, куда изменения ещё не доехали. В ответ приходит 404 на объект, о создании которого мы только что получили событие. Лечат это чтением с мастера для этого сценария, ретраем с бэкоффом, номером версии в событии с опросом «пока version < 7 - жди» или переходом на state transfer, где данные уже лежат в сообщении.

Стоит предложить компромисс: тонкое событие плюс самые нужные поля и номер версии. Большинству потребителей хватает того, что пришло, а тем, кому нужны детали, остаётся один вызов вместо всех. Для больших полезных нагрузок есть claim check: в событии ссылка на S3, а тело лежит там.

Event sourcing не сводится к «мы используем Kafka»

Публикация событий в брокер сама по себе не делает систему 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 у консьюмера - нет
Сделать поле обязательным по смыслуНетСтарые продюсеры его не заполняют; валидация уронит консьюмера
Зачем нужен reserved

Поле 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). Она сразу переводит разговор из «надо что-то делать» в «у нас четыре часа».

Продюсер быстрее консьюмера - вопрос только в том, что переполнится первым продюсер 30 000 msg/s брокер буфер на диске 4 000 консьюмер 4 000 msg/s Долг растёт на 26 000 сообщений в секунду. Это ≈ 1,6 млн в минуту. Любой буфер - это просто отсрочка, измеряемая в часах. Что переполнится первым - зависит от того, где стоит самое слабое звено ① Продюсер, буфер отправки Kafka: buffer.memory (32 МБ) полон → send() блокируется на max.block.ms, потом TimeoutException. Это хороший исход: давление дошло до кода. ② Брокер, диск и лимиты Kafka: retention.ms/bytes молча удаляет хвост - консьюмер получит OffsetOutOfRange и пропустит данные. Потеря без единой ошибки у продюсера. ③ Консьюмер, память Push без prefetch: брокер заливает всё, что есть → RSS растёт → OOM-killer. Под перезапускается, unacked возвращаются, цикл повторяется - «краш-луп под нагрузкой». ④ RabbitMQ: memory alarm (0.6 от RAM) или disk_free_limit Брокер перестаёт читать TCP от всех продюсеров узла - publish зависает даже у тех, кто не виноват. ⑤ Как надо: давление доходит до источника Quota на брокере, rate limiter в сервисе, 429 на входе HTTP. Отказ на границе честнее, чем OOM в середине пайплайна. Правило: буфер покупает время, а не пропускную способность. Если consume < produce устойчиво - надо менять одно из двух чисел, а буфер использовать только чтобы дожить до момента, когда вы это сделаете.
«Увеличим буфер» ничего не решает, только выбирает место, где рванёт. Полезно заранее знать, что переполнится первым: от этого зависит, увидишь ты аккуратный 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 или ждут БД: если ждут, новые поды сделают хуже. Дальше по порядку: масштабирование до числа партиций, оптимизация и батчинг, увеличение партиций (с пониманием, что это ломает порядок по ключу и необратимо), троттлинг продюсера и в крайнем случае осознанное отбрасывание того, что не жалко. Буфер брокера при этом не решение, а время на то, чтобы решение применить». Такой ответ показывает, что кандидат думает о системе, а не об одной ручке.

Вопросы

6
Суть: решает двойную запись - «сохранить в БД и опубликовать событие» атомарно нельзя, потому что это две системы без общего коммита. Outbox переносит публикацию внутрь транзакции БД: событие пишется в таблицу тем же COMMIT, а отдельный релей выносит его в брокер. Гарантия - 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, и диск базы заканчивается.
Вопрос на добивание: почему нельзя вести курсор по id

Потому что 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. Ещё платишь ростом таблицы (нужна чистка партициями или пачками), лишней записью в каждой бизнес-транзакции и ещё одним процессом, за которым надо следить. Главный алерт всей конструкции - метрика «возраст самой старой неопубликованной строки».

Суть: зеркальное отражение outbox. На приёме тоже две операции - «сделать работу» и «запомнить, что сделали», - и их тоже надо объединить в одну транзакцию. Таблица 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, чтобы эффект уходил наружу уже из релея. Redis SETNX с TTL работает быстро, но не атомарно с бизнес-работой: упали между SETNX и работой - сообщение считается обработанным, хотя ничего не произошло.
Суть: два режима доставки, отличающиеся одним - сколько раз сообщение будет обработано. Job distribution (competing consumers): ровно один из группы, масштабирование подами. Fanout/multicast: копия каждому подписчику. Ошибка конфигурации молча превращает один режим в другой.

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: одному
RabbitMQfanout/topic exchange + своя очередь на подписчикаОдна очередь, несколько консьюмеров
KafkaРазные group.idОдин group.id
NATSОбычные подписчики на subjectQueue group с общим именем
Две ошибки, которые ищут этим вопросом
  • Три сервиса подписаны на fanout через одну общую очередь. Каждое событие достаётся одному случайному из них. Выглядит это как «иногда письмо не приходит» и отлаживается неделями. Очередей нужно столько же, сколько подписчиков.
  • Одинаковый group.id у разных сервисов в Kafka. Зеркальная беда: сервисы делят партиции и видят по половине потока. В group.id пишут имя сервиса, а не имя топика и не «app».

И про стоимость. В Kafka веерная рассылка бесплатна по хранению: N групп читают один лог, копия одна. RabbitMQ при fanout физически размножает сообщение: десять подписчиков дают десять копий в памяти и на диске. Если сообщения крупные, помогает claim check: в событие кладут ссылку на объект в S3, а не сам объект.

Суть: выбор между «тонким событием + обратный вызов за деталями» и «толстым событием, несущим состояние». Первое экономит трафик, но возвращает синхронную зависимость от источника; второе даёт потребителю автономию ценой дублирования данных и жёсткой связанности по схеме. Третья степень - event sourcing, где события сами и есть источник истины.

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 в событии дёшево страхует от гонок и от применения событий не по порядку.

Суть: сообщение - публичный API без компилятора. Protobuf держит контракт на номерах полей (отсюда reserved), Avro - на именах с обязательными default. Schema Registry не столько хранит схемы, сколько не даёт зарегистрировать несовместимую. BACKWARD - обновляем консьюмеров первыми, FORWARD - продюсеров, FULL - порядок не важен.

Protobuf

Имена полей в wire-формате не передаются, передаются номера. Отсюда и все правила. Поле с новым номером добавлять безопасно (старый консьюмер положит его в unknown fields). Переименование безопасно для бинарного формата, но ломает JSON-маппинг. Удалять поле можно только вместе с reserved. Смена типа или номера ломает контракт, как и поле, ставшее обязательным по смыслу: старые продюсеры его не заполняют.

Зачем 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, а не в реестре постфактум.

Суть: сначала диагноз - пик это или тренд, и упирается ли консьюмер в CPU или ждёт БД. Потом ступени от дешёвого к дорогому: масштабирование до числа партиций, оптимизация обработки, увеличение партиций (необратимо и ломает порядок по ключу), троттлинг продюсера, осознанное отбрасывание. Буфер брокера покупает время, а не пропускную способность.

Диагностика

Растущий лаг сам по себе не диагноз. Считаем скорость разгребания = 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, который начинает отправлять новое поле; старое удаляй только после прекращения зависимости.