handbookНаписать

patterns/enterprise/outbox/README.md · тема · 4 языка · 19 тестов

Outbox (Transactional Outbox) ​

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

Проблема ​

Магазин сохраняет заказ в базу и публикует событие OrderPlaced в брокер сообщений: по нему склад резервирует товар, а почта шлёт письмо. Это две записи в две разные системы, и общей транзакции у них нет. DirectOrderService делает именно так: коммитит заказ, потом вызывает брокер. Если брокер в этот момент недоступен, заказ уже сохранён, а событие потеряно: склад о заказе не узнает никогда. Поменять порядок не помогает: опубликовать событие, а потом упасть на коммите ещё хуже, тогда склад резервирует товар под несуществующий заказ.

Это проблема двойной записи (dual write). Ретраи вокруг вызова брокера её не решают: процесс может упасть между коммитом и публикацией, и ретраить будет некому.

Решение ​

Не писать в две системы сразу. OrderService в одной транзакции вставляет заказ в таблицу orders и сообщение в таблицу outbox той же базы. База гарантирует, что сохранится либо и то и другое, либо ничего.

Публикацию делает отдельный отправитель (relay) OutboxRelay: он читает неотправленные сообщения, публикует их в брокер и помечает отправленными. Если брокер лежит, сообщения ждут в таблице и уйдут на следующем проходе. Отправитель не знает о заказах: он переносит строки из таблицы в брокер.

Структура ​

one transaction

commit

then publish

unsent, markSent

publish

Database

+orders: map

+outbox: OutboxMessage[]

+transaction(work)

+insertOrder(order)

+addToOutbox(type, payload)

+unsent() : OutboxMessage[]

+markSent(id)

OutboxMessage

+id: int

+type: string

+payload: string

OrderService

+placeOrder(order)

DirectOrderService

+placeOrder(order)

«interface»

MessageBroker

+publish(type, payload)

InMemoryBroker

+down: bool

+published: string[]

OutboxRelay

+publishPending() : int

Отправитель обходит неотправленные сообщения по порядку id. Сообщение помечается отправленным только после успешной публикации; если брокер отказал, обход останавливается, и всё, что не ушло, остаётся в таблице до следующего вызова publishPending. Отправленные повторно не публикуются. В проекте отправитель запускают по таймеру или в фоновом процессе; здесь его вызывает тест.

Как это собирается ​

В приложении две точки входа над одной базой. Обработчик POST /orders получает OrderService и вызывает placeOrder: заказ и сообщение OrderPlaced пишутся одной транзакцией, и ответ 201 зависит только от базы. Фоновый процесс получает OutboxRelay с той же базой и клиентом брокера и по таймеру вызывает publishPending. Связывает их корень приложения: создаёт Database и InMemoryBroker (в проекте - пул соединений и клиент брокера) и раздаёт обоим.

В примере брокер лежит, пока покупатели оформляют два заказа: оба получают 201. Первый проход отправителя ничего не отправляет, и два сообщения ждут в таблице. Брокер поднялся - второй проход публикует оба по порядку, третий не находит ничего и повторно не шлёт. Путь вызова: обработчик -> сервис -> транзакция в базе, а отдельно: таймер -> отправитель -> outbox -> брокер.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Брокер за интерфейсом MessageBroker с одним методом publish. Подделка InMemoryBroker копит опубликованные сообщения в списке и по флагу down бросает BrokerUnavailable, как сеть при недоступном брокере. Хранилище Database — две таблицы в памяти; транзакция снимает их копию и восстанавливает при исключении, так что откат настоящий, хоть и без SQL. Полезная нагрузка — JSON заказа, суммы в центах.

ЯзыкОсобенность
PHPtransaction(Closure) с шаблонным типом для PHPStan; копия массивов при входе даёт откат бесплатно (copy-on-write)
GoTransaction(func(tx) error) откатывает по возвращённой ошибке; PublishPending возвращает число и error с %w, тест проверяет errors.Is
PythonТранзакция — контекстный менеджер with db.transaction(): на @contextmanager; брокер — Protocol
TypeScripttransaction<T>(work: () => T): T; отправитель ловит только BrokerUnavailable, остальные ошибки пробрасывает

Запуск: make test T=patterns/enterprise/outbox

Плюсы и минусы ​

Тесты одинаковы во всех языках и проверяют четыре случая: двойная запись при лежащем брокере сохраняет заказ, но теряет событие; с outbox тот же сбой оставляет сообщение в таблице, и когда брокер возвращается, отправитель публикует OrderPlaced {"orderId":1,"totalCents":2500}; исключение внутри транзакции откатывает и заказ, и сообщение; второй проход отправителя не публикует уже отправленное.

  • ✅ Событие не теряется: оно сохранено вместе с данными и уйдёт, когда брокер станет доступен.
  • ✅ Сервис заказов не зависит от доступности брокера: заказ принимается, даже если брокер лежит.
  • ✅ Порядок событий задаётся порядком строк в таблице.
  • ❌ Доставка «как минимум один раз»: если отправитель упадёт после публикации, но до отметки, сообщение уйдёт повторно. Потребителю нужна идемпотентность — это разбирает практика.
  • ❌ Событие доходит с задержкой: между коммитом и публикацией проходит время до следующего прохода отправителя.
  • ❌ Лишняя таблица, фоновый процесс и очистка отправленных строк.

Когда не применять ​

  • Событие не обязано дойти (метрика, подсказка в интерфейсе): хватит прямой публикации или внутрипроцессного наблюдателя.
  • Получатели живут в том же процессе и могут отработать в той же транзакции: событие обрабатывается сразу, брокер не нужен.
  • Состояние уже хранится как поток событий (event sourcing): запись события и есть изменение, отдельная таблица outbox не нужна.
  • Получатель вызывается синхронно и должен ответить сразу: это не событие, а запрос.

Отличия между языками ​

  • PHP — массивы копируются при присваивании, поэтому снимок таблиц для отката — просто [$this->orders, $this->outbox]. Ловится Throwable: откат нужен при любом сбое, а исключение пробрасывается дальше. В Doctrine то же делают wrapInTransaction и запись outbox-сущности в одном flush.
  • Go — транзакция принимает функцию и откатывается по возвращённой ошибке, как обёртки над database/sql. maps.Clone и slices.Clone дают снимок. Отправитель возвращает ошибку брокера вызывающему: решение «попробовать позже» принимает тот, кто его запустил.
  • Python — @contextmanager превращает транзакцию в блок with: исключение внутри блока попадает в except генератора, тот откатывает и пробрасывает дальше. Так же устроены with connection: в sqlite3 и session.begin() в SQLAlchemy.
  • TypeScript — transaction принимает функцию и возвращает её результат; catch получает unknown, поэтому отправитель проверяет instanceof BrokerUnavailable и пробрасывает остальное. С асинхронным драйвером сигнатура станет transaction<T>(work: () => Promise<T>): Promise<T>, суть та же.

Практика ​

  • Учебный отправитель публикует всё за один проход, а брокер либо работает, либо нет. Практика: надёжный отправитель outbox показывает, что бывает в жизни: брокер падает на N-й попытке, подтверждение теряется после доставки и получатель видит дубль, сообщения одного заказа должны прийти по порядку, а отправитель берёт их пачками. Получатель там идемпотентен по id сообщения.
  • Брокер - своя система с простыми отказами. Внешний API отвечает сложнее: Telegram просит подождать retry_after секунд, навсегда отказывает в чатах, где бота заблокировали, и не отсекает дубли сам. Практика: уведомления клиентам в Telegram показывает уведомление о заказе в одной транзакции с заказом, отправку пачками, паузу на 429, мёртвые чаты и ключ идемпотентности, из-за которого повтор заказа не даёт второго сообщения.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS