Что кладём в событие: notification vs event-carried state transfer

Самое базовое решение событийной архитектуры — что положить в событие: тонкое уведомление (просто «заказ изменился», ID) или толстое событие с полным состоянием. Первое слабо связывает, но провоцирует «GET-storm» обратно на источник; второе автономно, но раздувает payload, устаревает и тащит связанность по данным. Разбираем два паттерна из таксономии Фаулера, их компромиссы (связанность ↔ автономность ↔ staleness), версионирование нагрузки, приватность в событии и когда что выбирать

Прежде чем спорить про Event Sourcing и CQRS, событийная система упирается в куда более приземлённый вопрос: что вообще положить в событие? Два ответа. Тонкое уведомление (event notification) — «заказ 42 изменился», и всё; кому надо — сходит и запросит детали. Толстое событие с состоянием (event-carried state transfer) — «заказ 42: статус paid, сумма 1000, позиции […]»: консьюмер получает всё сразу и никуда не ходит. Выбор выглядит как деталь сериализации, а на деле определяет связанность сервисов, нагрузку на источник и то, насколько свежими будут данные у потребителя.

Эта статья закрывает базовый пробел серии: концептуальная карта разводит command/event/message и domain/integration, а ES и CQRS — это уже продвинутые паттерны из той же таксономии Фаулера; здесь — два оставшихся, самых частых в повседневной интеграции.

Тонкий конверт с одним ID-тегом и петлёй-стрелкой обратно к архиву-картотеке (notification) против толстого конверта, набитого полным состоянием, и автономной фигуры рядом (event-carried state transfer)

В статье

Два паттерна

Возьмём «заказ оплачен». Одно и то же событие можно оформить двумя способами.

Event notification (тонкое): событие несёт минимум — тип, ID, может пару полей.

OrderPaid { orderId: "42" }
→ консьюмер: "ага, заказ 42 оплачен; надо детали — схожу спрошу GET /orders/42"

Event-carried state transfer (толстое): событие несёт нужное состояние целиком.

OrderPaid { orderId: "42", status: "paid", amount: 1000,
            currency: "RUB", items: [...], customer: {...} }
→ консьюмер: "всё уже есть, никуда идти не надо"

Технически оба — обычные сообщения в брокере. Разница — в объёме и в том, самодостаточен ли получатель.

Главный компромисс

Ось Notification (тонкое) State transfer (толстое)
Размер события минимальный крупный
Связанность по данным слабая (только ID) сильная (схема состояния)
Автономность консьюмера низкая (идёт за деталями) высокая (всё в событии)
Свежесть данных всегда актуальные (запросил сейчас) снимок на момент публикации
Нагрузка на источник высокая (GET-storm) низкая
Устойчивость к недоступности источника нет (не обработать без него) да

Ни одна колонка не выигрывает целиком. Это тот же водораздел «где живёт знание», что в хореографии vs оркестрации, только на уровне данных: notification оставляет знание в источнике (иди спроси), state transfer раздаёт его копией в каждое событие.

Обратная сторона notification: GET-storm

Тонкое событие выглядит дёшево — но переносит стоимость на потом. На каждое OrderPaid N подписчиков синхронно идут в источник за деталями: GET /orders/42. Одно событие → N запросов обратно. При всплеске событий это GET-storm: источник, который событиями хотели разгрузить, получает синхронную нагрузку от всех потребителей разом — и мы вернулись к той самой синхронной связанности, которую event-driven обещал убрать.

Хуже того — это временная связанность: если источник недоступен, консьюмер не может обработать событие (детали-то не забрать). Асинхронность на бумаге, синхронная зависимость на деле.

Обратная сторона state transfer

Толстое событие автономно, но платит четырьмя вещами:

  • Размер. Полное состояние в каждом событии раздувает лог и трафик; на высокочастотных потоках это ощутимо.
  • Staleness. Состояние в событии — снимок на момент публикации. Пока событие дошло и обработалось, данные могли устареть; потребитель действует по слепку прошлого.
  • Связанность по схеме. Теперь все консьюмеры зависят от структуры состояния в событии — любое изменение формата задевает всех (прямой мостик к контрактам событий).
  • Приватность. Рассылать полное состояние «на всякий случай» — значит разложить PII по всем подписчикам и логам брокера. Что не нужно потребителю — не кладём в событие (мостик к крипте и GDPRСкоро).

Версионирование и приватность payload

Как только событие несёт состояние, оно становится контрактом данных — и живёт по правилам эволюции схем: обратная/прямая совместимость, добавление полей, upcasting старых версий. Это ровно тема контрактов событий и schema evolution, и толстые события делают её обязательной, а не опциональной: у notification ломать почти нечего (там один ID), у state transfer — целая структура, от которой зависят все.

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

Связь с ES и CDC

  • Event Sourcing: лог событий внутри границы агрегата — по сути поток state transfer (события несут, что изменилось). Но наружу публикуют не сырой внутренний лог, а специально оформленные integration events — иначе внутренняя модель протекает во все сервисы.
  • CDCготовится, с 6 сентября: захват изменений строк — это автоматически порождаемый state transfer (было/стало по строке). Мощно, но именно поэтому связывает потребителей со схемой таблиц источника — о чём часто забывают, включая Debezium «чтобы просто получать события».

Лунная база: телеметрия датчиков

Датчики модуля публикуют показатели. Два оформления — разная цена.

Notification: ModuleReadingsChanged { module: "A" }. Диспетчерская, аналитика и система оповещения на каждое событие идут в шлюз датчиков за деталями. В штатном режиме терпимо; во время нештатной ситуации, когда показатели меняются каждую секунду, три подписчика × частота = GET-storm ровно тогда, когда шлюз и так под нагрузкой. И если шлюз просел — оповещение не обработает событие, потому что не заберёт детали.

State transfer: ModuleReadings { module: "A", pressure: 98.2, temp: 21.4, o2: 20.9, ts: ... }. Каждый потребитель автономен, шлюз не дёргают. Цена — крупнее поток и риск staleness: если событие задержалось, оповещение сработает по устаревшему давлению.

Отсюда практический вывод — гибрид: критичную телеметрию слать state transfer (автономность важнее объёма), а редкие «что-то поменялось в конфигурации модуля» — notification.

Когда что и почему гибрид

Notification уместен, когда: много разнородных потребителей с разными потребностями в данных (каждый заберёт своё), детали тяжёлые и не всем нужны, критична слабая связанность по данным, а нагрузка на источник управляема.

State transfer уместен, когда: потребитель должен быть автономным и переживать недоступность источника, данные компактны, важна независимость от доступности источника, высокая частота обращений сделала бы GET-storm неподъёмным.

Гибрид — норма. Тонкое уведомление + ссылка/версия для тех, кому нужны детали; или state transfer для «горячих» полей, за которыми ходят все, + notification для остального. Как и с координацией, выбор делается на конкретный поток событий, а не на всю систему. Как это ложится на прочие событийные решения — в матрице решенийготовится, с 8 августа.

Checklist: что кладём в событие

  1. Кто потребители события и какие данные им реально нужны?
  2. Если notification — выдержит ли источник GET-storm от всех подписчиков на пике?
  3. Критична ли для потребителя автономность / устойчивость к недоступности источника? → склоняет к state transfer.
  4. Если state transfer — заложена ли эволюция схемы события (совместимость, версии)?
  5. Нет ли в payload PII, которого потребителям не нужно (приватность)?
  6. Не устареет ли состояние в событии к моменту обработки (staleness приемлем)?
  7. Решение принято на конкретный поток, а не на всю систему разом?

Демо и версии

  • Demo: digital-cookbook/architecture/event-payload — один поток «заказ оплачен» в двух формах: (1) notification (тип+ID, консьюмер идёт за деталями — видно всплеск запросов к источнику под нагрузкой); (2) state transfer (полное состояние в событии — консьюмер автономен, но payload крупнее и устаревает при задержке). Go/Java.
  • Версии брокера/библиотек пиновать при написании.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии