Сервис заказов сохранил заказ в PostgreSQL, и в этот момент процесс упал. Событие OrderCreated в брокер не ушло: склад не зарезервировал товар, Leyla не получила письмо, а заказ в базе есть. Это проблема двойной записи, и она портит жизнь почти каждой событийной системе. В этом уроке разберём, как её решает transactional outbox, как сделать потребителя идемпотентным, как менять формат событий, не ломая читателей, как устроены саги и почему пользователю стоит показывать статус «в обработке».

События и команды

Сообщения бывают двух видов, и от выбора зависит дизайн. Команда — просьба «сделай это». Событие — факт «это произошло».

КомандаСобытие
Смысл«зарезервируй товар»«товар зарезервирован»
Имяглагол: ReserveStockпрошедшее время: StockReserved
Адресатодин известный сервиснеизвестно кто: подписчики
Отказполучатель может отказатьуже случилось, отказать нельзя

События называют в прошедшем времени: OrderCreated, OrderPaid, PaymentFailed. Имя вроде SendEmail — это команда, замаскированная под событие; она привязывает отправителя к конкретному получателю. Событие всегда лежит в конверте с id, типом, версией и временем:

json
{
  "id": "0f6c1c5e-3d0a-4a4b-9c58-5f6c1f1d9a10",
  "type": "OrderPaid",
  "version": 1,
  "occurredAt": "2026-10-04T10:15:00.000Z",
  "data": { "orderId": 7, "total": 87, "currency": "AZN" }
}

Кладите в data только то, что нужно читателям: идентификаторы и ключевые значения, а не весь заказ с персональными данными. Поля id и version пригодятся дальше.

Проблема двойной записи

Сервис сохранил заказ в базе, но упал до публикации: событие потеряно, и другие сервисы ничего не узнали
Сервис сохранил заказ в базе, но упал до публикации: событие потеряно, и другие сервисы ничего не узнали

Заказ нужно записать в базу и сообщить об этом брокеру. Это две разные системы, и общей транзакции между ними нет. Что бы вы ни выбрали, остаётся окно для сбоя:

  • сначала база, потом брокер: упали между ними — событие потеряно, заказ без последствий;
  • сначала брокер, потом база: база отказала — фантомное событие о заказе, которого нет.

Повторы (retry) не спасают: падает сам процесс, а не вызов. Распределённые транзакции между базой и брокером почти не применяют: они сложные и медленные.

Transactional outbox

Один транзакционный шаг записывает заказ и событие в таблицу outbox, а relay читает её и публикует события в брокер
Один транзакционный шаг записывает заказ и событие в таблицу outbox, а relay читает её и публикует события в брокер

Идея проста: брокера в транзакции нет, значит, пишем событие в базу. В той же транзакции, что и заказ, вставляем строку в таблицу outbox. Либо сохранятся обе записи, либо ни одной. Отдельный процесс relay читает неопубликованные строки, отправляет их в брокер и помечает опубликованными. Модели Prisma 7: