Сервис заказов сохранил заказ в PostgreSQL, и в этот момент процесс упал. Событие OrderCreated в брокер не ушло: склад не зарезервировал товар, Leyla не получила письмо, а заказ в базе есть. Это проблема двойной записи, и она портит жизнь почти каждой событийной системе. В этом уроке разберём, как её решает transactional outbox, как сделать потребителя идемпотентным, как менять формат событий, не ломая читателей, как устроены саги и почему пользователю стоит показывать статус «в обработке».
События и команды
Сообщения бывают двух видов, и от выбора зависит дизайн. Команда — просьба «сделай это». Событие — факт «это произошло».
| Команда | Событие | |
|---|---|---|
| Смысл | «зарезервируй товар» | «товар зарезервирован» |
| Имя | глагол: ReserveStock | прошедшее время: StockReserved |
| Адресат | один известный сервис | неизвестно кто: подписчики |
| Отказ | получатель может отказать | уже случилось, отказать нельзя |
События называют в прошедшем времени: OrderCreated, OrderPaid, PaymentFailed. Имя вроде SendEmail — это команда, замаскированная под событие; она привязывает отправителя к конкретному получателю. Событие всегда лежит в конверте с id, типом, версией и временем:
{
"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 читает неопубликованные строки, отправляет их в брокер и помечает опубликованными. Модели Prisma 7: